Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Refresh Tokens

A refresh token lets a client obtain a new access token without asking the user to re-authenticate. ahdapa implements refresh token rotation: every use of a refresh token immediately invalidates it and issues a replacement. Reuse of an old token signals theft and revokes the entire family.


When are refresh tokens issued?

Refresh tokens are issued only for:

  • Authorization code flow (authorization_code grant) — always, as part of the issue_tokens call.
  • Device authorization flow (device_code grant) — always, as part of the same issue_tokens call.

Refresh tokens are not issued for:

  • client_credentials — RFC 6749 §4.4.3 says the server SHOULD NOT issue one. Re-request a new access token directly.
  • token-exchange — no refresh token.
  • jwt-bearer — no refresh token.

Refresh tokens are only issued when the client requests the offline_access scope (OIDC Core §11 / RFC 9700). Flows that do not include offline_access in the granted scope receive an access token (and, if requested, an ID token) but no refresh token. Re-authenticate or include offline_access to obtain a refresh token.


Using a refresh token

POST /token with grant_type=refresh_token. Authenticate the client the same way as on the original token request.

curl -s -X POST https://idp.example.com/token \
  -u "CLIENT_ID:CLIENT_SECRET" \
  -d "grant_type=refresh_token" \
  -d "refresh_token=BASE64URL_ENCRYPTED_REFRESH_TOKEN"

Optional parameters:

  • scope — Request a subset of the original scopes. Scope widening (requesting scopes not in the original grant) is rejected with invalid_scope. When omitted, the original scope is preserved unchanged.

Successful response — 200 OK:

{
  "access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K0pXVCIsImtpZCI6IkFCQ0QxMjM0In0...",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "NEW_BASE64URL_ENCRYPTED_REFRESH_TOKEN",
  "id_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IkFCQ0QxMjM0In0...",
  "scope": "openid profile email offline_access"
}

The response always includes a new refresh_token. The old token is immediately invalid.


Rotation and replay detection

Refresh tokens are stateful: ahdapa tracks each token family in the CRDT using a monotonically increasing token_index. When a refresh token is used:

  1. The server checks the CRDT family record. If token_index in the presented token is less than the stored max_index, the token has already been used — replay detected, return invalid_grant.
  2. The server increments max_index and stores the new state (gossipped to all cluster nodes).
  3. A new refresh token with token_index = max_index is issued.

If a token is replayed (an old index is re-presented), the server returns invalid_grant immediately. This signals that the refresh token may have been stolen; the client should force the user to re-authenticate.

The family is explicitly revocable via DELETE /api/admin/refresh-families/{family_id} (admin API). Revoking a family sets max_index = u64::MAX, permanently invalidating all tokens in that family.


Expiry

Refresh tokens expire after tokens.refresh_token_ttl seconds (default: 86400 = 24 hours). An expired token returns invalid_grant. The expiry is from the time the token was issued, not from its last use.

When a refresh token expires:

  1. The client receives invalid_grant on the next refresh attempt.
  2. The user must re-authenticate via the authorization code flow or device authorization flow to obtain new tokens.

Scope narrowing on refresh

You can request a subset of the originally granted scopes on refresh:

curl -s -X POST https://idp.example.com/token \
  -u "CLIENT_ID:CLIENT_SECRET" \
  -d "grant_type=refresh_token" \
  -d "refresh_token=BASE64URL_ENCRYPTED_REFRESH_TOKEN" \
  -d "scope=openid"

The acr, amr, and auth_time claims in the resulting access token and ID token are preserved from the original authentication session — refreshing does not change the authentication assurance level.


DPoP and mTLS on refresh

If the original access token was DPoP-bound or mTLS-bound, include the same DPoP proof or client certificate on the refresh request. The new access token will carry the same cnf binding.


Best practices

  • Store refresh tokens securely (server-side session, encrypted persistent storage). Never store them in browser localStorage or cookies accessible to JavaScript.
  • Detect invalid_grant on refresh and prompt the user to re-authenticate.
  • Use the shortest access token lifetime that is practical for your deployment (tokens.access_token_ttl). Refresh tokens compensate for short-lived access tokens without requiring frequent re-authentication.
  • Revoke refresh token families on explicit sign-out via POST /revoke. See Revocation and Sign-Out.