Token Lifecycle
Token Types
Keymaster issues three types of tokens:
| Token | Format | Lifetime | Storage |
|---|---|---|---|
| Access Token | RS256 JWT | 15 min (configurable per app) | Client-side or session store |
| Refresh Token | Opaque string (64 hex chars) | 30 days (configurable per app) | Server-side session store only |
| SSO Session | Opaque string (httponly cookie) | 8 hours | km_sso cookie on Keymaster domain |
Obtaining the First Token Pair (Login)
Apps start the login flow by redirecting the user to the authorize endpoint:
GET {KEYMASTER_URL}/login?app_id=<APP_ID>&redirect_uri=<URI>
Optional parameters: state (echoed back), prompt=login (force re-authentication), and PKCE (code_challenge=<S256>&code_challenge_method=S256).
How the credential is delivered back depends on the redirect scheme:
| Redirect scheme | Callback delivers | Next step |
|---|---|---|
http / https (web) |
?code=<ONE_TIME_CODE> |
Exchange the code at POST /token/exchange for the token pair |
| Native custom scheme (mobile) | ?access_token=…&refresh_token=… on the deep link |
Use the tokens directly |
| Any error | ?error=<reason> |
Handle the failure |
Web apps must exchange the code — they never receive tokens directly on the callback URL. This avoids leaking tokens through browser history, referrers, and server logs.
POST {KEYMASTER_URL}/token/exchange
{
"code": "<ONE_TIME_CODE>",
"app_id": "your-app-id",
"code_verifier": "<original-verifier>" // required only if you sent a code_challenge
}
→ {
"access_token": "eyJhbG...",
"refresh_token": "a1b2c3...",
"token_type": "Bearer",
"expires_in": 900
}
The one-time code is single-use and expires 60 seconds after issuance. Once you hold the token pair, the refresh/rotation lifecycle below is identical for web and native.
Access Token (JWT)
Claims
{
"sub": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"name": "Jane Doe",
"aud": "a56e4998-e65d-4817-b69d-009ab7dee28f",
"iss": "https://keymaster.cloud-monitor.com",
"iat": 1710547200,
"exp": 1710548100,
"roles": ["user", "admin"],
"token_type": "access"
}
| Claim | Description |
|---|---|
sub |
User UUID (stable, unique per user across all apps) |
email |
User's email address |
name |
Display name (may be null) |
aud |
App UUID this token was issued for |
iss |
Keymaster base URL |
iat / exp |
Issued-at and expiration timestamps |
roles |
Array of roles for this user in this app |
token_type |
Always "access" — distinguishes user access tokens from service tokens |
Verification
Always verify access tokens locally using JWKS:
GET https://keymaster.cloud-monitor.com/.well-known/jwks.json
Check: alg=RS256, iss matches Keymaster URL, aud matches your app_id, exp is in the future.
Do NOT call Keymaster on every request. Cache the JWKS keys (refresh hourly or when kid doesn't match).
Alternative: Server-Side Verification
If you can't verify JWTs locally:
POST /token/verify
{
"token": "eyJhbG...",
"audience": "your-app-id"
}
→ { "valid": true, "claims": { ... } }
→ { "valid": false, "error": "Token has expired" }
audience is required — pass your app_id. The endpoint validates it against the token's aud claim and rejects service tokens (token_type: "service") presented as user tokens.
Refresh Token Rotation
Refresh tokens are rotated on every use. When you exchange a refresh token, the old one is revoked and a new pair (access + refresh) is issued.
POST /token/refresh
{
"refresh_token": "a1b2c3d4...",
"app_id": "your-app-id"
}
→ {
"access_token": "new-jwt...",
"refresh_token": "new-refresh-token...",
"token_type": "Bearer",
"expires_in": 900
}
Critical: You must store the new refresh token. The old one is now invalid.
Replay Detection
If a revoked refresh token is reused (potential token theft), Keymaster revokes refresh tokens as a security precaution, forcing re-authentication. Revocation is scoped to the device family the replayed token belongs to:
| Replayed token | What gets revoked |
|---|---|
Issued with a device_id |
Only that device's tokens for the user+app |
| Issued without one | Only the user+app tokens that also have no device_id — one shared family, so these clients can still revoke each other |
Two clients therefore cannot knock each other out — provided each one sends its own device_id.
Device Scoping (device_id)
Send a device_id from every client, native and web. Without one, every client a user owns shares a single token family, and because tokens rotate on every use, the second client to refresh presents a token the first already rotated away. That is indistinguishable from a replay, so it revokes the family and logs the user out — typically presenting as "biometric/stored login works, then bounces to the Keymaster login page".
Pass it when starting the login flow:
GET /login?app_id=...&redirect_uri=...&device_id=<stable-per-install-id>
GET /oauth/{provider}/start?app_id=...&redirect_uri=...&device_id=<same>
It is carried through the OAuth state and bound to every refresh token that login issues, then preserved automatically across rotations — you send it once per login, not on /token/refresh.
What to use: a random id generated on first launch and stored in durable device storage (Keychain, Keystore, Preferences). It identifies a token family, not a person, so do not derive it from hardware identifiers. It must be stable — a new id per launch strands the previous family on every login and is no better than sending none.
What to use in a browser: a random id in localStorage, created on first visit. The right scope is the scope of the cookie jar — one id per browser profile.
Correction (v4.1.2). This guide previously said "web clients don't need one: a browser is a single client with a single cookie". That is wrong, and it cost the first integrator a day. A browser does not collide with its own tabs, but a user's browsers collide with each other: laptop Chrome, a kiosk, a phone browser and the native app each hold a separate refresh cookie and, sending no id, all shared the one no-
device_idfamily — so whichever refreshed second was read as a replay. Omittingdevice_idis still accepted and still unchanged behaviour, but it is no longer the recommendation for anyone.
One refresh at a time, per family (this is on you)
Keymaster rotates on every refresh and rejects the token it just rotated away. A device_id separates your clients from each other; it does not protect a client from itself. Whatever holds a family must serialise its own refreshes.
The trap is a browser with several tabs. They share one cookie, so they are one client and correctly share one device_id — and when a session lapses they all refresh at once, and every loser presents a token the winner already rotated. Keymaster cannot tell that from theft, so it revokes the family and every tab is logged out. A page reload is the ideal trigger, because every tab boots simultaneously.
Two mistakes worth naming, because both were made in the reference integration:
- A lock that isn't atomic is not a lock. A read-then-write of a
localStoragekey lets two tabs starting together both observe it empty and both proceed. Usenavigator.locks.request(), which is atomic and releases if the tab dies, so there is no timeout to guess at. BroadcastChanneldoes not echo to the tab that posted. So it cannot deduplicate callers within one tab — and a page normally has several requests in flight, which all fail together when the session lapses. Keep a single in-flight refresh promise per tab and hand it to every caller; use the channel only to share the result between tabs.
Server-side integrations have the same obligation: one refresh at a time per stored session, and a request that arrives mid-refresh should wait for the result rather than start its own.
Correct App Integration Pattern
Store both tokens in a durable session store (database, not memory):
# On each authenticated request:
# 1. Look up session in DB
# 2. Decode access JWT exp claim locally (no network call)
# 3. If expired (or within 30 seconds of expiry):
# → POST /token/refresh → update both tokens in DB
# 4. If refresh returns 401:
# → Token revoked/expired → redirect to login
# 5. If network error:
# → Degrade gracefully (user was previously authenticated)
DO NOT: - Store tokens in memory (lost on server restart) - Use the access token as a one-shot identity check and then ignore it - Forget to update the refresh token after rotation - Let two threads, tabs or workers refresh the same family at once (the loser looks like a replay and the family is revoked) - Set a short cookie expiry that doesn't match the refresh token lifetime
DO:
- Set cookie max_age to 30 days (match refresh token lifetime)
- Refresh proactively (30-second buffer before expiry)
- Handle refresh failure gracefully (redirect to login, don't crash)
- Send a device_id from every client, so one client's rotation cannot invalidate another's
- Serialise refreshes within a client — one at a time per family, with concurrent callers awaiting the same result
Token Revocation
Revoking a Refresh Token (Logout)
POST /token/revoke
{
"refresh_token": "a1b2c3d4..."
}
→ { "status": "ok" }
Always revoke on logout. This prevents the token from being used even if it was intercepted.
Password Change
When a user changes their password, Keymaster automatically revokes all SSO sessions for that user. They must re-authenticate everywhere. Refresh tokens for individual apps are NOT automatically revoked — the app will continue working until the refresh token expires or the user explicitly logs out.
Service Tokens (Client Credentials)
For server-to-server authentication, apps use the client credentials grant:
POST /auth/token
grant_type=client_credentials
client_id={app_id}
client_secret={secret}
scope=push:send
Service tokens are distinct from user tokens:
- token_type: "service" claim (prevents type confusion; not accepted as a user token)
- sub is the app_id, not a user_id; there is no aud claim
- carries a scope claim (space-separated granted scopes)
- 15-minute expiry (expires_in 900), no refresh — just re-authenticate
- Scoped to specific capabilities (e.g., push:send). The app is only granted scopes it is entitled to — each requested scope must be listed in the app's config["service_scopes"], otherwise the request returns 400 invalid_scope.
See Server-to-Server Guide for details.
Token Lifetimes at a Glance
| Scenario | What expires | What to do |
|---|---|---|
| Access JWT expires (15 min) | Access token | Call /token/refresh |
| Refresh token expires (30 days) | Refresh token | Redirect to Keymaster login |
| User idle for 30+ days | Both tokens | Redirect to Keymaster login |
| SSO session expires (8 hours) | km_sso cookie |
User sees login screen on next app switch |
| Password changed | All SSO sessions | User re-authenticates everywhere |
| Refresh token replayed | That device's tokens (or every token with no device_id, if the replayed one had none) |
User re-authenticates on that device |