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_id family — so whichever refreshed second was read as a replay. Omitting device_id is 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:

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