Token
Exchange a code, a refresh token or client credentials for tokens, with the client authentication, DPoP rules, response members and errors.
On this page
The token endpoint turns a grant into tokens. It serves four grants: authorization_code, refresh_token, client_credentials and the RFC 8693 token exchange. This page covers the request rules every grant shares and then each grant.
Request#
POST to the token_endpoint from discovery, which is /token, with Content-Type: application/x-www-form-urlencoded. A different content type or an oversized body is invalid_request.
| Parameter | Rule |
|---|---|
grant_type | Required exactly once. One of the four grants. A grant the client was not registered for is unauthorized_client; an unknown one is unsupported_grant_type. |
client_id | Required exactly once. A missing or repeated client_id is invalid_client. When the oauth_cimd flag is on (it defaults OFF), it may be an https URL naming your client metadata document; if that document has expired and cannot be refreshed, the client is unknown here and its refresh tokens stop working. |
| Client authentication | See below. |
Client authentication#
Identity authenticates the client before it reads the grant. The method depends on how the client is registered, and discovery lists the methods.
| Method | What you send |
|---|---|
private_key_jwt | client_assertion_type set to urn:ietf:params:oauth:client-assertion-type:jwt-bearer, and client_assertion, a JWT signed with the private key whose public half is registered for the client. |
none | Nothing, for a public client. A public client must prove a DPoP key instead. |
Client-certificate authentication (tls_client_auth) exists only where mutual TLS is enabled; see Discovery.
A client assertion has these rules.
- The header carries
alg(EdDSAorES256) and akidthat matches a live registered key. issandsubare both yourclient_id.audis the issuer, as one string, not an array.expis at most 300 seconds ahead, andjtiis present. Ajtiis accepted once; send a fresh assertion with every request, including a retry.- It is at most 8192 bytes.
A suspended client is refused invalid_client whatever it presents.
DPoP#
A client registered with require_dpop sends a DPoP proof header, and the access token and refresh token are bound to its key. Even without that setting, only a first-party web client may receive an unbound token. Any other client that does not prove a key is refused invalid_client. See DPoP for proofs, the nonce challenge and key storage.
Authorization code grant#
| Parameter | Rule |
|---|---|
code | The code from the authorization response. |
redirect_uri | The same value that was pushed. |
code_verifier | 43 to 128 characters from A-Z a-z 0-9 - . _ ~. Its S256 hash must equal the pushed code_challenge. |
resource | Optional, at most once. It must be one of the resources authorized when the request was pushed, and its app must still be installed in the code's organization at this moment. If several were authorized you must name one; ask once per resource. With no resource, aud is your client id. |
Identity refuses the code if it is unknown, expired, or already used, if redirect_uri or the verifier does not match, if the code was bound to a DPoP key the request did not prove, or if the session behind it has ended or the person or organization is no longer active. All of these are invalid_grant, as is a session that was started through a single sign-on connection not linked to the code's organization. A code lives at most 60 seconds and works once.
Refresh token grant#
| Parameter | Rule |
|---|---|
refresh_token | Required exactly once. |
scope | Optional. Must be a subset of what the token already holds; it cannot widen. |
resource | Optional, as above, including the check that the resource's app is still installed. |
Each successful refresh returns a new refresh token and retires the old one. See Refresh token for rotation, reuse and the retry grace. A token that is unknown, expired, revoked or reused is invalid_grant, with one description for all of those. A refresh with a different DPoP key than the token is bound to is invalid_dpop_proof.
Client credentials grant#
For a service client acting as itself. There is no person, so there is no ID token and no refresh token.
| Parameter | Rule |
|---|---|
scope | Optional. Space separated, each value one the client may request. With none, the client's full allowed scopes are issued. |
resource | Not supported. It returns invalid_target: the token is issued to the calling client. |
The client must be linked to a service identity, or the answer is unauthorized_client. The access token lives at most 900 seconds. To reach another service, exchange this token; see Service to service.
Token exchange grant#
urn:ietf:params:oauth:grant-type:token-exchange (RFC 8693) takes subject_token, subject_token_type, audience and optionally scope, requested_token_type, actor_token, actor_token_type and authorization_details. The exchange rules, the limits and what is registered are on Service to service.
Response#
A successful response is 200 with Cache-Control: no-store and a DPoP-Nonce header.
| Member | Meaning |
|---|---|
access_token | A signed JWT. See Access token. |
token_type | DPoP when the token is bound to a key, otherwise Bearer. Send it back in the Authorization scheme. |
expires_in | Seconds until the access token expires. Never more than 900. |
scope | The scopes granted, space separated. |
refresh_token | Only on the code grant when the client may refresh and the request included offline_access, and on every refresh. Opaque. |
id_token | On the code grant when openid was granted. See ID token. A refresh response normally carries no ID token, so do not depend on one. For a client registered pairwise, its sub is that client's per-sector value; the access token keeps the real sub. |
issued_token_type | On an exchange only. |
Errors#
Errors are JSON: {"error": "...", "error_description": "..."}. The status is 401 for invalid_client, 500 for server_error and 400 for everything else. Branch on error; the description is for logs.
error | When |
|---|---|
invalid_request | A missing, repeated or malformed parameter other than client_id, a wrong content type, or an oversized body. |
invalid_client | Unknown client, failed authentication, a suspended client, a replayed assertion, or a client that must prove a key and did not. |
invalid_grant | A bad code, verifier, redirect address or refresh token, an ended session, or an inactive person or organization. |
unauthorized_client | The client does not hold the requested grant. |
unsupported_grant_type | Not one of the four grants. |
invalid_scope | A requested scope is not allowed, or a refresh tried to widen. |
invalid_target | A resource that is not authorized, is repeated, is sent on client_credentials, or belongs to an app no longer installed in the token's organization. |
insufficient_user_authentication | RFC 9470. The token's organization requires single sign-on (the sso_enforcement flag, default OFF) and the session behind the grant has no recent enough single sign-on login. The body adds acr_values (aal1) and max_age: start a new authorization that asks for both. Applies to the code, refresh and exchange grants. |
invalid_dpop_proof | The proof is invalid or the refresh token is bound to another key. |
use_dpop_nonce | Retry once with the DPoP-Nonce value as the proof's nonce. |
server_error | Identity could not complete the request. Retry later. |
A request can also be refused with 429 and a Retry-After header. See Error codes.
Worked example#
curl -s https://identity.tdacorp.in/token \ -H "DPoP: $DPOP_PROOF" \ --data-urlencode 'grant_type=authorization_code' \ --data-urlencode 'client_id=tdaid_c_example' \ --data-urlencode "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \ --data-urlencode "client_assertion=$ASSERTION" \ --data-urlencode "code=$CODE" \ --data-urlencode 'redirect_uri=https://app.example.com/api/auth/callback' \ --data-urlencode "code_verifier=$VERIFIER"{ "access_token": "eyJhbGciOiJFZERTQSIsInR5cCI6ImF0K2p3dCIsImtpZCI6Ii4uLiJ9...", "token_type": "DPoP", "expires_in": 600, "refresh_token": "...", "id_token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIs...", "scope": "openid profile offline_access"}The first call may instead return 400 with {"error": "use_dpop_nonce"} and a DPoP-Nonce header. Sign a new proof with that nonce and send the request again, with a new client assertion.
Security notes#
- Verify the ID token before you trust the response. A code exchange that returns tokens is not yet a login. See ID token.
- Never retry a code exchange. A code works once. A retry that reaches identity after the first succeeded gets
invalid_grant. - Treat
invalid_granton a refresh as the end of that token. Do not loop. Send the person through sign-in again. - Keep the DPoP key. Refresh tokens are bound to it; a new key at every start loses every refresh token.
- Do not log token responses. Everything in them is a credential.