Refresh token contract (RFC 9700 §2.2.2 + RFC 9449 §5)
The contract
/oauth/tokenacceptsgrant_type=refresh_token- RT TTL = 86400 (24 hours)
- RT is DPoP-bound: the AS embeds
cnf.jktin the RT (as an opaque server-side record, NOT in the JWT itself; RT is opaque to the client) - Refresh-token rotation: every successful refresh issues a NEW RT and invalidates the OLD one
- Replay-cascade-revoke: if the OLD RT is presented after rotation, the AS revokes the ENTIRE
token family (AT + new RT + the mandate the family was issued from), emits
oid4ac.security.refresh_replaySSF event, and pages on-call - DPoP proof on the refresh request MUST match the
jktrecorded in the RT
Token shape
The refresh token is opaque: 32 random bytes base64url-encoded. It is NOT a JWT. The server-side record stores:
| Field | Detail |
|---|---|
rt_id | opaque token id (the client sees this as the rt value) |
family_id | shared across rotations; the cascade-revoke key |
client_id | owning agent |
sub | principal |
mandate_id | the mandate this family was issued under |
jkt | DPoP key thumbprint |
exp | 24 h from issue |
rotated_at | null until a new RT supersedes this one |
Refresh wire shape
POST /oauth/token HTTP/1.1
Host: as.oid4pay.com
Content-Type: application/x-www-form-urlencoded
DPoP: <proof using the same key as the original AT>
grant_type=refresh_token
&refresh_token=<opaque>
&client_id=<agent_client_id>
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=<private_key_jwt>200 response
{
"access_token": "<new JWT-AT>",
"token_type": "DPoP",
"expires_in": 300,
"refresh_token": "<new opaque RT>"
}Replay-cascade-revoke
If the client presents the OLD refresh token after a successful rotation, the AS:
- Returns
400 invalid_grant. - Marks the entire family as revoked (the new RT is killed; the AT is killed; the underlying mandate is killed).
- Emits the
oid4ac.security.refresh_replaySSF event. - Pages on-call (this is a strong signal of token theft).
Worked example
// Successful refresh path
$ curl -sS https://as.oid4pay.com/oauth/token \
-H "DPoP: $DPOP" \
-d grant_type=refresh_token \
-d refresh_token=$OLD_RT \
-d client_id=$CLIENT_ID \
-d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
--data-urlencode client_assertion=$PKJ
{"access_token":"...","refresh_token":"<NEW_RT>","expires_in":300, ...}
// Subsequent reuse of $OLD_RT
$ curl -sS https://as.oid4pay.com/oauth/token \
-H "DPoP: $DPOP" \
-d grant_type=refresh_token \
-d refresh_token=$OLD_RT ...
400 {"error":"invalid_grant", "error_description":"refresh token replay; family revoked"}