Skip to main content

Refresh token contract (RFC 9700 §2.2.2 + RFC 9449 §5)

The contract

  • /oauth/token accepts grant_type=refresh_token
  • RT TTL = 86400 (24 hours)
  • RT is DPoP-bound: the AS embeds cnf.jkt in 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_replay SSF event, and pages on-call
  • DPoP proof on the refresh request MUST match the jkt recorded 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:

FieldDetail
rt_idopaque token id (the client sees this as the rt value)
family_idshared across rotations; the cascade-revoke key
client_idowning agent
subprincipal
mandate_idthe mandate this family was issued under
jktDPoP key thumbprint
exp24 h from issue
rotated_atnull 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:

  1. Returns 400 invalid_grant.
  2. Marks the entire family as revoked (the new RT is killed; the AT is killed; the underlying mandate is killed).
  3. Emits the oid4ac.security.refresh_replay SSF event.
  4. 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"}