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_codegrant) — always, as part of theissue_tokenscall. - Device authorization flow (
device_codegrant) — always, as part of the sameissue_tokenscall.
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 withinvalid_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:
- The server checks the CRDT family record. If
token_indexin the presented token is less than the storedmax_index, the token has already been used — replay detected, returninvalid_grant. - The server increments
max_indexand stores the new state (gossipped to all cluster nodes). - A new refresh token with
token_index = max_indexis 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:
- The client receives
invalid_granton the next refresh attempt. - 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_granton 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.