Skip to content

Tokens

This page helps you understand the tokens your app gets, and check one before you trust it.

Your app gets up to three tokens from the token endpoint:

  • An access token, which your app sends to an API in an Authorization: Bearer header. It says what your app may do there.
  • An ID token, which tells your app who signed in and how. Your app gets one only when it asks for the openid scope.
  • A refresh token, which your app trades for new tokens without asking the user to sign in again. See Refresh tokens.

Each one is a JWT (JSON Web Token): a set of claims, statements like “the user is sub”, signed by the auth server. The access token and the ID token are for your app and your APIs to read. The refresh token is for the auth server alone, so treat it as an opaque string.

Your API checks every access token it’s sent, and your app checks the ID token before it trusts who signed in. Most JWT and OpenID Connect libraries do all of this for you once they know the auth server’s discovery URL.

  1. Check the signature. Take the key whose kid matches the one in the token’s header from the key set at /certs, and verify the token with it. Tokens are signed with RS256. See Discovery and JWKS.

  2. Check iss is the issuer from the discovery document, and that exp hasn’t passed.

  3. Check aud. An ID token’s is your client identifier. An access token’s names the resource of each permission it carries, such as product-api, so your API checks its own resource identifier is there.

  4. Check what the token is for:

    • An ID token’s nonce is the one your app sent in the authorization request.
    • An access token’s scope holds the permission the operation needs, such as product-api:read.

A decoded access token looks like this:

{
"iss": "https://auth.example.com",
"sub": "c5b9b6a2-4b39-4b8e-9a8e-6f7f2b3a1d10",
"aud": ["authserver", "product-api"],
"scope": "openid profile product-api:read",
"typ": "Bearer",
"iat": 1760000000,
"nbf": 1760000000,
"exp": 1760000300,
"jti": "9a6f1c1e-2d0b-4f3e-8a55-0c1b7a3e2f44",
"auth_time": 1759999950,
"acr": "urn:goiabada:level2_optional",
"amr": ["pwd"],
"sid": "0b7e6a31-5f2c-4d8e-9c1a-3e4f5a6b7c8d",
"auth_state_generation": 1
}

These settings are under Admin, Tokens in the admin console, and apply to every client:

Setting Default
Token expiration in seconds 300 (5 minutes)
Include OpenID Connect claims in the access token Off
Include OpenID Connect claims in the ID token On

The first sets how long both the access token and the ID token last. The other two decide whether user claims such as email or name go in each token, as Scopes describes. A client can override all three on its own Tokens tab: a lifetime of 0 there uses this setting, and each claims switch can be on, off, or left to this setting. See Clients.

Keep the lifetime short. An access token can’t be taken back once it’s issued: an API checking it by signature alone accepts it until exp, whatever happens to the user’s session meanwhile. The lifetime is the longest that can last. See what ending a session doesn’t reach.

An authorization code works once, for 60 seconds. Refresh tokens have lifetimes of their own.

Claim
iss The issuer
sub The user’s subject. In a token from the client credentials flow, the client identifier
aud The resource of each permission scope, and authserver when the scope has an OpenID Connect scope, since /userinfo belongs to it. A string when there’s one, an array when there are more
scope The scopes the token carries
typ Bearer
iat, nbf, exp When it was issued, when it becomes valid, which is the same moment, and when it expires
jti The token’s own identifier
auth_time, acr, amr When and how the user signed in. See ACR and AMR
sid The session the token came from, when there is one. See below
auth_state_generation Reserved. See below
nonce The nonce of the authorization request, when it had one

User claims, groups and attributes come on top, as Scopes describes. A token from the client credentials flow names no user, so it carries none of auth_time, acr, amr, sid, auth_state_generation or nonce.

sid isn’t always there. It’s in an access token only when the grant it came from is tied to a session. A token from an offline_access grant has none, nor does one from the password grant: those grants are meant to outlive the browser session that started them. Don’t treat a missing sid as an invalid token.

auth_state_generation is reserved. It’s how the auth server tells a token issued before the user’s credentials last changed from one issued after. Don’t parse it, compare it between tokens or build anything on it: its meaning can change.

Claim
iss The issuer
sub The user’s subject
aud Your client identifier
iat, nbf, exp When it was issued, when it becomes valid, and when it expires
jti The token’s own identifier
auth_time When the user last entered a credential, a password or a one-time code
acr, amr How the user signed in. See ACR and AMR
sid The session the user signed in with, when there is one. /auth/logout matches on it
nonce The nonce of the authorization request, when it had one
at_hash A hash of the access token issued beside it, in the implicit flow only

User claims, groups and attributes come on top, as Scopes describes. auth_time is never simply when the token was issued: a sign-in that reused a session, prompt=none included, keeps the time the user last signed in, which is what max_age is measured against.

The scope your app asks for isn’t always the scope it gets. Once the user has signed in, the auth server drops every permission scope the user doesn’t hold, directly or through a group, without an error. OpenID Connect scopes and offline_access are always kept. If that leaves nothing, the request ends with access_denied, “The user is not authorized to access any of the requested scopes”.

What’s left is what the consent screen asks about, when it’s shown. The user can untick scopes there, and the tokens carry only the ones they approved.

A user can sit on the consent screen for as long as they like, so just before it issues the code, or the implicit flow’s tokens, the auth server checks the request again, in this order:

  1. The redirect URI is still registered on the client. If it isn’t, the user sees a page, and nothing reaches your app.
  2. The client is still enabled, or the user sees an error page. The flow is still on for it, or your app gets unauthorized_client. It’s still allowed to request any administrative scope it asked for, or it gets invalid_scope.
  3. The user who signed in is the one the id_token_hint names, when there was one, or your app gets login_required, “The authenticated user does not match the id_token_hint”.
  4. The user’s session is still there, still valid, and still theirs, or the sign-in starts again from the password.
  5. The user is still enabled, or your app gets access_denied with “The user account is disabled.” Their credentials haven’t changed since they signed in, or the sign-in starts again from the password.
  6. The user still holds at least one of the scopes, or your app gets access_denied.

A silent request can’t start a sign-in again, so it gets login_required, “User authentication is required”, instead.