Skip to content

Token endpoint

העתקת עמוד

POST /api/oauth/token — exchange an authorization code for tokens, or refresh them. Both grants, the request and response shapes, refresh-token rotation, and family revocation on replay.

עודכן

The token endpoint mints tokens. It handles two grants: authorization_code (exchange the code from /authorize) and refresh_token (rotate an expiring session). It is a machine endpoint — CSRF-exempt and CORS-open (*), because it carries client credentials and PKCE proofs, never cookies.

POST /api/oauth/token
Content-Type: application/x-www-form-urlencoded

Send application/x-www-form-urlencoded (JSON is also accepted). Client authentication is client_secret_basic (HTTP Basic), client_secret_post (secret in the body), or none for public clients — where PKCE is the proof.

Grant: authorization_code

Exchange the one-time code for tokens.

Request

FieldRequiredNotes
grant_typeauthorization_code
codeThe code from the redirect (60-second TTL, single use).
redirect_uriMust equal the authorize request’s redirect_uri byte-for-byte.
code_verifierThe PKCE verifier (43–128 chars) whose S256 hash was sent to /authorize.
client_idYour client.
client_secretConfidential clients only (or use HTTP Basic).
# --data-urlencode form-encodes each value safely (redirect URIs carry reserved characters)
curl -X POST https://entrybit.net/api/oauth/token \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=SplxlOBeZQQYbYS6WxSbIA" \
  --data-urlencode "redirect_uri=entrybitresident://oauth/callback" \
  --data-urlencode "client_id=eb_9f1c2ab34cd56ef7" \
  --data-urlencode "code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"

Response

{
  "access_token": "<RS256 JWT>",
  "token_type": "Bearer",
  "expires_in": 900,
  "scope": "openid profile email offline_access passes:read",
  "refresh_token": "<opaque>",
  "id_token": "<RS256 JWT>"
}

Cache-Control: no-store is set on every token response. The id_token is present when openid was granted; the refresh_token is present when offline_access was granted.

Auth-code-injection defense: a replayed authorization code revokes the entire refresh-token family it originally minted. Never exchange a code twice.

Grant: refresh_token

Exchange the current refresh token for a fresh set — with a new refresh token.

Request

FieldRequiredNotes
grant_typerefresh_token
refresh_tokenThe current refresh token (they rotate on every use).
client_idYour client.
client_secretConfidential clients only (or use HTTP Basic).
scopeOptional narrowing of the granted scope — never widening.
# A refresh token is a live credential — keep it in your secret store, not the command line
curl -X POST https://entrybit.net/api/oauth/token \
  --data-urlencode "grant_type=refresh_token" \
  --data-urlencode "refresh_token=$REFRESH_TOKEN" \
  --data-urlencode "client_id=eb_9f1c2ab34cd56ef7"

The response has the same shape as the authorization-code grant — including a brand-new refresh_token.

Refresh-token rotation (read this)

Refresh tokens are opaque, single-use, and rotate on every exchange. The rules that keep a session healthy:

  • Always persist the newest refresh_token returned by each refresh. The old one is now dead.
  • Never keep two copies, and never refresh the same token twice concurrently. Presenting a superseded or revoked token is treated as theft: EntryBit revokes the entire token family (the stolen-token kill switch) and returns a generic invalid_grant.
  • On invalid_grant, clear your tokens and send the user to log in again.

In code, the contract is an ordering — persist the new refresh token before the new access token is used, and keep at most one refresh in flight:

// Sketch — a complete client (single-flight + invalid_grant handling) is in
// the React Native quickstart's lib/session.ts.
const tokens = await postTokenRefresh(stored.refreshToken); // ONE at a time, app-wide

await secureStore.set('refreshToken', tokens.refresh_token); // 1. the old token is dead
authorizeWith(tokens.access_token);                          // 2. only then use the new pair

Refresh tokens live 60 days; access tokens live ~15 minutes. Access tokens are stateless JWTs — they simply expire and cannot be revoked individually (revoke the refresh token to end a session; see Revocation).

The tokens

TokenFormatLifetimeClaims / notes
access_tokenRS256 JWT, typ: at+jwt (RFC 9068)~15 miniss sub aud client_id scope iat nbf exp jti; aud = your client_id.
id_tokenRS256 JWT, typ: JWTiss sub aud iat exp + nonce? auth_time? amr? at_hash? + scope-gated identity. See ID token & UserInfo.
refresh_tokenOpaque, SHA-256 stored60 daysSingle-use, rotating.

Both JWTs verify offline against the JWKS.

Errors

StatuserrorMeaning
400invalid_grantCode/refresh expired, reused, mismatched, or family-revoked.
400invalid_requestA required parameter is missing or malformed.
400invalid_scopeRequested a scope the client (or grant) doesn’t allow.
400unsupported_grant_typegrant_type is not authorization_code or refresh_token.
401invalid_clientClient authentication failed.
429temporarily_unavailableRate limited — back off and retry.

Descriptions are deliberately generic (no token-state oracle). Branch on the error code — see Errors.