Exchanging and refreshing tokens
The three grant types at the token endpoint, client authentication, and refresh rotation semantics.
Token endpoint: POST {issuer}/token, with an application/x-www-form-urlencoded body. Responses carry Cache-Control: no-store.
Client authentication
| Method | Applies to | How |
|---|---|---|
client_secret_basic | Confidential clients (recommended) | Authorization: Basic base64(client_id:client_secret) |
client_secret_post | Confidential clients | client_id and client_secret in the form body |
none | Public clients (web_client / desktop / ios_macos / android) | Only client_id in the form body |
The authorization endpoint never accepts client_secret
Authorization requests to the consent UI accept only client_id. A secret placed in a browser URL or
form is treated as an invalid request. Confidential clients must keep "obtaining the code" in the
frontend and "exchanging the code" in the backend.
Grant type 1: authorization_code
| Parameter | Required | Notes |
|---|---|---|
grant_type | ✅ | authorization_code |
code | ✅ | The authorization code from the callback |
redirect_uri | ✅ | Exactly the same as in the authorization request (loopback ports still follow the registration rules) |
code_verifier | ✅ | The raw PKCE value; it must derive the code_challenge sent earlier |
client_id | ✅ (with none) | Required for public clients |
The server's validation order: client authentication → code exists and unused → redirect_uri match → PKCE → scope filtering.
On success it returns an access token and a refresh token (the refresh token only when the scope includes offline_access), plus an id_token when openid was requested.
{
"token_type": "Bearer",
"expires_in": 3600,
"access_token": "eyJ...",
"refresh_token": "eyJ...",
"scope": "openid profile email offline_access",
"id_token": "eyJ..."
}expires_in is determined by the redirect platform: 3600 seconds for web_server, 1800 seconds for public client platforms.
Grant type 2: refresh_token
curl -X POST 'https://id.example.com/v1/oauth/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d 'grant_type=refresh_token' \
-d "refresh_token=$REFRESH_TOKEN"Rules:
scopeis optional and may only narrow: it must be a subset of the originally granted scopes; anything outside is rejected.- Every refresh rotates: you get a new access token and a new refresh token, and the old refresh token stops working immediately. Overwrite your stored value — do not keep the old one for a "fallback retry".
- Refresh tokens are signed JWTs, but clients must not parse their contents or assume their length or format is stable.
- Refresh does not restore
authorization_details: fine-grained grants hold only for the original authorization and are not carried in refreshed tokens.
Refresh tokens expire too
30 days for confidential clients, 14 days for public clients, and refreshing never extends beyond that cap. After expiry the user must authorize again.
Grant type 3: client_credentials
Available only to machine clients, for server-to-server calls, with no user involved.
curl -X POST 'https://id.example.com/v1/oauth/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d 'grant_type=client_credentials' \
-d 'scope=auth:sessions:read'- Issued tokens have no
sub; theprincipalclaim distinguishes "represents a user" from "represents an application". offline_accessis not allowed: with no user there is no session to refresh, so requesting that scope yieldsinvalid_scope.- Third-party applications do not need this grant type; if you do, the product shape should be registered as
machine.
Token exchange
First-party admin consoles can use RFC 8693 to obtain tokens for other audiences; third parties do not need it. See Token exchange.
Error response format
The token, revocation and introspection endpoints follow RFC 6749 §5.2, not the unified envelope used by the console APIs:
{
"error": "invalid_grant",
"error_description": "The authorization code has already been used"
}error | HTTP | Meaning |
|---|---|---|
invalid_request | 400 | Missing parameters, duplicate parameters, unsupported grant_type |
invalid_client | 401 | Client authentication failed (wrong secret, wrong method) |
invalid_grant | 400 | Code/refresh token invalid, used or expired; PKCE mismatch; redirect_uri mismatch |
unauthorized_client | 400 | The client may not use this grant type |
invalid_scope | 400 | Invalid scope, or an attempt to widen scopes during refresh |
server_error | 500 | Server-side failure |
Rate limiting returns 429 with the unified envelope and a Retry-After header; see Error codes.