Tokens & Sender Constraint
Docs version v1 (main)Nauthera is pre-1.0, in a design-partner programUpdated
Token formats
| Token | Format | Default lifetime |
|---|---|---|
| Access token | Signed JWT, typ: at+jwt (RFC 9068) | issuer.access_token_ttl, 10m |
| ID token | Signed JWT | issuer.id_token_ttl, 10m |
| Refresh token | Opaque, kept in the database | issuer.refresh_token_ttl, 720h |
| Authorization code | Opaque, single use | issuer.auth_code_ttl, 60s |
A client registered through the AdminService can set its own access and ID token lifetimes
(access_token_ttl_seconds, id_token_ttl_seconds; 0 means the issuer default), and
its own refresh lifetime (refresh_), at most issuer.refresh_token_ttl,
which is also its default. Dynamically registered clients always use the issuer
defaults. Every token the server verifies must carry an expiry, and the server
enforces the token's audience on its own resources.
Refresh tokens
A refresh token is issued only when the client may refresh and the user granted
offline_access. Refresh tokens rotate on every use. The refresh token's binding to
its client is checked before the token is consumed. Narrowing the scope on a refresh
narrows that token, not the underlying grant. Each new refresh token lives for the
client's refresh lifetime (by default issuer.refresh_token_ttl), so a family that keeps
refreshing has no end; an absolute lifetime for a family is planned (#310).
Every token minted from one authorization, the refresh chain and the access tokens
beside it, belongs to one grant family, which a user's access token names in its
private fam claim. Presenting an already-used refresh token, or redeeming an
authorization code a second time, revokes that family: the live refresh token descended
from it stops rotating, and its access tokens are refused at the server's own
endpoints (UserInfo, introspection, the admin API and client registration) even before
they expire. A resource server that validates access tokens locally does not see this.
Telling a client's retry of a refresh apart from a stolen token's replay is planned
(#1387).
Refresh tokens are kept in the database, under a SHA-256 digest of the token, with the tombstones that recognize a reused one and the markers that revoke a family, a session or an account. A Redis outage or restart therefore neither signs users out nor undoes a revocation. For one refresh-token lifetime after an upgrade from a build that kept them in Redis, the server still reads the tokens and markers that build left there, so nobody is signed out by the upgrade and no revocation is lost. Authorization codes and their tombstones stay in Redis.
Account status
The user's account is read on every grant that issues tokens for a user: the
authorization code, a refresh and the device code. A
suspended or blocked account is refused
with the grant's ordinary invalid_grant, the answer it gives a spent or unknown
credential, so a code or token holder does not learn the account's status.
Suspending or blocking an account (POST …/ or /block, see
Admin API & Users) takes effect at once:
every session ends, every refresh grant the account was given is retired, device-code
grants included, and relying parties are told over back-channel logout. A code or grant
still in flight is refused with that ordinary invalid_grant. After reactivation the user
signs in again. Access tokens already issued stay valid until they expire.
Profile claims
The email scope releases email and email_verified; profile releases name and
preferred_username. They appear in the ID token, the access token and UserInfo alike.
email_verified is true once the user followed a
verification link
sent to their current address, or reset their password from a link sent there, or an
administrator vouched for the address, and false otherwise. Changing the address clears
it, unless the administrator vouches for the new one. It is released only together with a
non-empty email.
Revocation and introspection
/revoke (RFC 7009) revokes refresh tokens only, and only the one token named, not the
grant behind it. A revoked access token stays valid until it expires (#91).
/introspect (RFC 7662) reports an access token's state, including cnf and
token_type for DPoP-bound tokens; a refresh token is reported inactive (#93).
Introspection is caller-bound: only the confidential client a token was issued to
can introspect it. Public clients are refused, and a token belonging to another client is
reported inactive. A separate resource server therefore cannot use /introspect; it
should validate the JWT locally against the JWKS.
Ending a user's access also retires refresh tokens:
| Event | Refresh tokens retired |
|---|---|
| Logout (hosted sign-out, RP-initiated logout) | Those issued in the session that ended |
| An administrator ends one session | Those issued in that session |
| A password reset, by the user from an emailed link or by an administrator; an administrator ending every session of the user | Every refresh token the account was given until then, from any session, client or flow, device-code grants and grants that outlived their session included |
| A second-factor change that Multi-Factor Authentication lists | The same, and every other session of the user ends |
| An administrator suspends or blocks the user | Every refresh token the account was given until then; every session ends |
| The user is deleted | Every refresh token of the account |
A grant from a sign-in after the event keeps working. Access tokens already issued run to their expiry in every case. Revoking every grant of a client on demand is planned (#1389).
DPoP: sender-constrained tokens
DPoP (RFC 9449) binds a token to a key the client holds, so a stolen token is useless without that key.
- Proofs are verified once per request: signature,
htm,htu, theiatwindow, the nonce,athat a protected resource, and a replay cache forjti. Accepted proof algorithms are ES256, PS256/384/512 and RS256/384/512. - Nonces are always required. A proof without a current nonce is refused with
use_dpop_nonce, and aDPoP-Nonceheader carries the nonce to retry with:400at/token,401with aWWW-Authenticate: DPoPchallenge at a protected resource. A nonce stays valid for five minutes, and every successful token response to a request with a proof carries a fresh one. A nonce belongs to one organization: a nonce from one organization's endpoints is refused at another's, and the refusal carries a new one. - Binding. If the token request carries a proof, the issued access token carries
cnf.jkt(the key's thumbprint). This works for every grant: authorization code, refresh, client credentials and device code. - Early binding.
dpop_jkton/authorizeand/parbinds the authorization code to the key before the code exists. On/device_authorizationit binds the device code: a poll with that device code must carry a proof for that key. ADPoPheader sent to/paris ignored (#133); use thedpop_jktparameter there. The key requested at/authorizeis carried through sign-in and consent. - Enforcement. A bound token presented without a matching proof is refused at
UserInfo, the admin API and client registration (RFC 9449 §7.1). It must also be sent
with the
DPoPscheme; underBearerit is refused with401, even with a valid proof.
A client cannot yet require that all of its own tokens be sender-constrained
(dpop_bound_access_tokens, #138). The remaining DPoP work is tracked in #34.
Authorization request integrity
| Feature | Specification | Notes |
|---|---|---|
| Pushed authorization requests | RFC 9126 | POST /par returns a single-use request_uri. Set fapi. to require PAR for every client. |
| Signed request objects | RFC 9101 | A request passed by value, signed with ES256 or RS256 by a key at the client's jwks_uri. none and HMAC are refused. request_uri accepts PAR references only. |
| Rich authorization requests | RFC 9396 | Not usable yet. authorization_details is validated against the detail types a client is registered for, but no registration path can set those types, so every request carrying it is refused. Tracked in #36 and #59. |
| Resource indicators | RFC 8707 | resource at /authorize and /token restricts the token's audience. |
| Issuer identification | RFC 9207 | iss is returned on the authorization response. |
Signing keys
The server signs with ES256 (EC P-256) and RS256. EdDSA and PS256 are not offered
for signing. Configure keys with keys.provider:
memorygenerates keys at startup. It is for development only and is refused for an issuer that is not loopback.filereads PEM keys fromkeys.signing_key_path(EC) andkeys.(RSA). If only the EC key is configured, the deployment is ES256-only and discovery says so.rsa_ signing_ key_ path
Each organization other than the main one has its own signing keys. They are stored in
the database, encrypted under keys.encryption_key.
Rotation is manual. To rotate the main organization's keys, publish the new public keys
first, through keys. and keys., while
the current keys still sign. Restart every replica, then wait at least eleven minutes: the
server's cached copy of the JWKS can take up to 330 seconds to list the new keys, and a
client may keep its copy for 300 seconds more. Then sign with the new keys, publish the
previous public keys the same way, and restart again. Once the longest access and ID
token lifetime has passed since that restart, remove the previous public keys and restart
again. The full procedure is in
Keys & Secrets.
Organizations other than the main one cannot rotate their keys today. Rotating them
without a restart is tracked in #300.
FAPI 2.0 mode
An organization with fapi_mode on, set at creation or later, applies part of the FAPI 2.0
Security Profile:
- pushed authorization requests are required,
- PKCE with S256 is required,
- signing and request-object processing use ES256 only, and
- the authorization code lifetime is capped at 60 seconds.
It does not yet refuse shared-secret client authentication (#33) or require sender-constrained tokens (#54). The full profile is tracked in #35, and naming the switch after what it currently enforces is tracked in #57.