Access token
The access token's format, lifetime and audience, and the checks your API makes before it trusts one.
On this page
An access token is what a client presents to an API. It is not for the client to read: your application treats it as opaque and sends it. The API behind it reads it. This page is for the API.
Format#
A JWT conforming to RFC 9068. The header carries typ: at+jwt, the signing alg (EdDSA or ES256, the algorithm registered for the client that asked) and a kid. The payload is below. A refresh token, by contrast, is opaque and is never a JWT.
| Claim | Notes |
|---|---|
iss, sub, aud, exp, iat, nbf, jti | Always present. |
client_id | The client the token was issued to. |
scope | Space-separated granted scopes. |
ver | Claim-set version. Currently 2. |
kind | The kind of caller: human, service, workload or agent. |
av, pv, pvt, rv | Authorization version counters. rv is the organization's resource version and is present only with tid. |
azp | When the token has a resource audience or came from an exchange: the client that asked. |
tid, tslug | When the sign-in was for an organization, and on a client_credentials token for a service client linked to an organization. |
sid, acr, amr, auth_time | For a token issued for a person. Not on a client_credentials token. |
cnf | The key the token is bound to. See DPoP verification. |
act | On an exchanged token: who is acting for whom. |
authorization_details | Echoed from the request, when one was made. |
All claims are defined in the claims reference. There is no roles claim; ask the decision API what a person may do.
Lifetime#
exp minus iat is at most 900 seconds (15 minutes), and is set per client at or below that. A client_credentials token has the client's access token lifetime like any other: 600 seconds by default, at most 900. A client cannot be registered with a longer life, and an operator can lower the ceiling for the whole deployment, so read exp rather than assuming 900. Refresh by the refresh grant before expiry, or when an API answers 401.
Audience#
A token has exactly one aud, a single string, never a list.
| How the token was requested | aud |
|---|---|
| Normally | The client id of the client that asked. |
With a resource parameter | That one registered resource address, and azp is the client id. |
| By token exchange | The client id of the service being called. |
The resource's product must still be installed for the organization when the token is minted, at the code exchange and at every refresh, not only when the request was authorized. A token request names at most one resource. To reach several resources, ask once for each. Your API accepts a token only when aud is its own identifier: the resource address it is registered under, or its client id. A token for any other audience is refused, even if the signature is perfect. That is the check that stops one service replaying a token meant for another.
Verify the token in your API#
The SDK's createResourceServer does all of this. See Resource-server verification. To do it yourself:
- Read the
Authorizationheader. The scheme isDPoPfor a bound token andBearerotherwise. - Decode the header.
typmust beat+jwtandalgmust be on your allow-list:EdDSAandES256, or just the one your tokens use. Never acceptnone, anHSalgorithm orRS256. Take the algorithm from your configuration, not the token. - Find the key by
kidin the JWKS and verify the signature. issequals the issuer you trust, exactly.audis a single string and equals your own identifier.expis in the future andnbfis not. Allow a few seconds of clock difference (the SDK's default is 5 seconds, and it refuses a setting above 300).expminusiatis at most 900.jtiis present.subis a non-empty string.- If the token has
cnf.jkt, the request must use theDPoPscheme with a valid proof for this exact request and key; see DPoP verification. A bound token sent asBeareris refused. If it has nocnf, theDPoPscheme is refused. - Check that
scopeincludes what the route needs. - Only then act on
sub,tidand the rest.
Verification is local and fast, and it cannot see what happened since the token was issued: a revocation, an ended session or a deactivated person. For a sensitive action, or if you need to react inside the 15 minutes, ask introspection.
Worked example#
Decoded header and payload of a token issued for your API.
{ "typ": "at+jwt", "alg": "EdDSA", "kid": "ex-2026-10" }{ "iss": "https://identity.tdacorp.in", "sub": "0194f2b6-7c1e-7a30-9d4e-2f6f0f3b8c11", "aud": "https://api.example.com", "azp": "tdaid_c_example", "client_id": "tdaid_c_example", "jti": "kq3T0u1mZb0Yx2yV7h1m6Q", "iat": 1790000000, "nbf": 1790000000, "exp": 1790000600, "scope": "openid profile", "ver": 2, "tid": "0194f2b6-1111-7000-8000-00000000aaaa", "tslug": "acme", "sid": "0194f2b6-0000-7000-8000-000000000001", "acr": "aal2", "amr": ["hwk", "user"], "auth_time": 1789999950, "av": 7, "cnf": { "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" }, "kind": "human"}The values are illustrative.
Errors#
On refusal answer 401 with a WWW-Authenticate challenge and {"error": "invalid_token"}, or invalid_dpop_proof for a proof failure. Do not say which claim failed. If the token is valid but its sign-in is too old or too weak for the route, answer 401 with insufficient_user_authentication and the acr_values and max_age you need (RFC 9470); the SDK's requireAuthentication does this and a client answers it with rp.stepUpUrl, see Resource-server verification. Answer 403 with insufficient_scope when the token is valid but lacks a scope.
Security notes#
- Never accept an ID token as an access token. Its
typisJWTand its audience is a client; both checks refuse it. - Never accept a token for another audience because it came from the same issuer.
- Treat
subas an opaque stable key, not an email address. On an access token it is always the person's real identifier, even for a client that sees a pairwisesubin its ID token (claims reference). - Do not log tokens. They are credentials until they expire.
- Refuse a token with no
expor too long a life. A token withexpminusiatover 900 cannot have come from identity.