Session Contract
BluAuth's session model has two layers that downstream apps touch at different levels:
- The BluAuth session — an opaque, server-side session managed by Better Auth. Stored in Postgres, referenced by an HttpOnly cookie on the
auth.example.comorigin. This is the user's "I am signed into BluAuth" fact. - OIDC tokens — short-lived JWTs (access token + ID token) issued from that session to a specific downstream client. This is "this user, right now, is authenticated to your app."
Downstream apps interact primarily with OIDC tokens. First-party code (admin tooling, same-origin apps) can additionally read session state directly via /api/auth/* endpoints.
This page documents the cookie, the tokens, expiry/refresh behavior, revocation, and what to do when things expire mid-session.
Source: server/utils/auth.ts, server/utils/auth-session-contract.ts, server/utils/oidc/tokens.ts.
Session cookie
BluAuth uses Better Auth for session management. The server sets a session cookie on the BluAuth origin after sign-in:
| Attribute | Value |
|---|---|
| Name | session_token (or the configured cookie prefix) |
HttpOnly | Yes |
Secure | Yes (production) |
SameSite | lax |
Path | / |
Domain | BluAuth origin only — not set on downstream app origins |
The cookie value is an opaque signed token. Downstream apps on different origins cannot read it; they must use OIDC to obtain tokens keyed to the session.
Session record
Each signed-in session corresponds to a row in the sessions table (Postgres, Valkey-cached). Relevant columns:
| Column | Notes |
|---|---|
id | Session UUID — matches the aggregateId on session.created / session.revoked events. |
token | Signed opaque value (same as cookie value). |
userId | The BluAuth user this session belongs to. |
ipAddress, userAgent | Captured at sign-in. |
currentProvider | Provider key for the current session (credential, google, tpauth, etc.). |
expiresAt | Hard expiry (default 7 days from last activity, sliding). |
createdAt, updatedAt | Audit. |
Sessions are sliding-expiry — activity within the window extends expiresAt. After hard expiry, the session is invalid and the user must re-authenticate.
Access tokens (OIDC JWT)
Downstream apps receive JWT access tokens from the token endpoint (POST /api/oidc/token).
| Property | Value |
|---|---|
| Format | JWT, signed |
| Signing algorithm | ES256 (EC P-256) |
| Signing key | Rotatable, published via /api/oidc/jwks. kid in the JWT header identifies the key. |
| TTL | 3600 seconds (1 hour), fixed. |
| Claims | iss, sub (pairwise per client), aud, exp, iat, client_id, scope, token_type |
A SHA-256 hash of every issued access token is stored in the oidc_tokens table so tokens can be looked up by the userinfo, introspect, and revoke endpoints, and so a server-side revocation can immediately invalidate an in-flight token.
ID tokens
Issued alongside the access token when openid is requested. Same algorithm and TTL. Additional claims: nonce (echoed from authorize), at_hash, auth_method, linked_providers, current_provider, mfa_satisfied, auth_assurance_level, assurance_source, plus scoped profile/email claims. See OIDC Integration for the complete list.
Those current-session claims are snapshotted at authorization-code creation time so the ID token and userinfo response describe the same login event.
Note on refresh tokens: BluAuth does not issue OAuth refresh tokens to downstream apps in the current release. Session-to-token refresh happens via silent re-authentication through the authorize endpoint — see refreshing below. The public roadmap includes adding a true
refresh_tokengrant; this page will be updated when it ships.
Detecting session expiry
Different entry points return different signals.
On a protected downstream API
If the downstream app protects its routes with the BluAuth access token, an expired or revoked token returns 401 Unauthorized with a WWW-Authenticate hint:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="Invalid or expired token"
Content-Type: application/json
{
"success": false,
"error": {
"code": "invalid_token",
"message": "Invalid or expired token",
"status": 401
}
}
On this response, attempt token refresh (below). If refresh also fails, drive the user back through full sign-in.
On same-origin UI
First-party UIs on the BluAuth origin can poll GET /api/auth/security-state:
GET https://auth.example.com/api/auth/security-state
Cookie: session_token=...
{
"authenticated": true,
"requirePasswordReset": false,
"isAdmin": false
}
This is a lightweight session check — no user object is returned, no external provider calls, and it does not extend the session's sliding window. Use it to gate UI without hitting the OIDC endpoints repeatedly.
On the OIDC userinfo endpoint
GET https://auth.example.com/api/oidc/userinfo
Authorization: Bearer <expired-token>
Returns 401 with statusMessage: "Invalid or expired token" when the token has expired or been revoked. Safe to use as an authenticated-user probe.
Refreshing access tokens
Because BluAuth does not issue refresh tokens today, the refresh path is silent re-authorization through /api/oidc/authorize:
- Generate a fresh PKCE verifier / challenge,
state, andnonce. - Open
/api/oidc/authorizewithprompt=none(optional) in an iframe or hidden window. - If the user's BluAuth session is still valid, BluAuth issues a new code without showing UI. If the session has expired, BluAuth returns
login_required— fall back to a full redirect. - Exchange the code at
/api/oidc/tokento obtain a new access token + ID token.
const authUrl = new URL('https://auth.example.com/api/oidc/authorize');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('nonce', nonce);
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('prompt', 'none'); // silent refresh
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = authUrl.toString();
document.body.appendChild(iframe);
When the iframe redirects to your callback with a code (or with error=login_required), tear it down and handle accordingly.
Server-side apps typically preempt expiry: refresh in the background a few minutes before the token expires, based on exp in the JWT. Never assume expires_in=3600 — decode exp and schedule off of that.
Revocation
User-initiated (logout)
RP-initiated logout via OIDC:
GET https://auth.example.com/api/oidc/end-session
?id_token_hint=<id_token>
&post_logout_redirect_uri=https%3A%2F%2Fapp.example.com%2Fsigned-out
&state=<opaque>
BluAuth:
- Validates
post_logout_redirect_uriagainst the client's registeredpostLogoutRedirectUris. - Extracts the session token from the cookie.
- Deletes the
sessionsrow (emitssession.revokedwithreason: "logout"). - Clears the Better Auth session cookie.
- Redirects to
post_logout_redirect_uri?state=<echoed>.
Source: server/api/oidc/end-session.get.ts.
Token-level revocation
RFC 7009 token revocation:
POST https://auth.example.com/api/oidc/token/revoke
Authorization: Basic <client_credentials>
Content-Type: application/x-www-form-urlencoded
token=<access_token>&token_type_hint=access_token
BluAuth authenticates the requesting client, verifies the token belongs to that client, and deletes the oidc_tokens row. Per RFC 7009, the response is always 200 { "ok": true } — whether the token existed, was already expired, or never existed.
Use this from a server-side logout path when you want a token to stop working immediately across every instance of your app, not just after its 1-hour TTL.
Admin revocation
From /admin/users/:id, an admin can:
- Revoke a specific session — deletes one
sessionsrow (emitssession.revokedwithreason: "admin_revocation"). - Revoke all sessions for a user — deletes every
sessionsrow for the user.
Downstream tokens issued from revoked sessions are not automatically deleted from oidc_tokens — they remain valid until their exp. This is a known gap; treat access-token validity as capped at 1 hour even after a session is revoked, and use introspection on high-security actions.
Behavior on password change
When a user changes their password:
- BluAuth revokes every session for that user except the current one (the session that performed the change keeps working).
- Revoked sessions fire
session.revokedwithreason: "password_change". - Access tokens issued from revoked sessions remain valid until their
exp(same caveat as admin revocation).
Downstream apps that subscribe to session.revoked webhooks should invalidate cached session state whenever reason === "password_change" — this is the signal that forced re-auth is in flight for every device except one.
Multi-device session list
First-party UIs can enumerate a user's active sessions:
GET https://auth.example.com/api/auth/sessions
Cookie: session_token=...
{
"success": true,
"data": [
{
"id": "<session-uuid>",
"userAgent": "Mozilla/5.0 (Macintosh; ...)",
"ipAddress": "203.0.113.17",
"createdAt": "2026-04-14T09:12:00.000Z",
"expiresAt": "2026-04-21T09:12:00.000Z",
"current": true
},
{
"id": "<session-uuid>",
"userAgent": "BluAuth iOS/2.3",
"ipAddress": "198.51.100.8",
"createdAt": "2026-04-10T18:44:21.000Z",
"expiresAt": "2026-04-17T18:44:21.000Z",
"current": false
}
]
}
current: true marks the session making the request. Downstream UI can display this list and let the user revoke individual sessions via DELETE /api/auth/sessions/:id. Revocation emits session.revoked with reason: "logout".
Pairwise subjects
sub in ID tokens and userinfo responses is pairwise — deterministically derived from (user_id, client_id). The same user has a different sub at each downstream client. Don't use sub as a global user identifier across apps — use the email / emails claim or ask BluAuth's admin API for the canonical user UUID.
This also means: if a client's ID changes (rare — usually only in dev), every sub it has seen becomes unresolvable. Pin client_id to the same UUID across environments where you care.
What downstream apps should assume
- Session-long sign-in, token-short auth. A user stays signed into BluAuth for days; individual tokens last 1 hour.
- Tokens are stateless until revoked. Normal validation is just JWT signature +
exp+iss+aud. Introspection is only needed when you need to detect server-side revocation. - Always validate the ID token on login. Never trust a raw token-endpoint response without verifying
iss,aud,nonce,exp, and signature. - Implement local logout that hops through
/api/oidc/end-session. Otherwise the user stays signed in at BluAuth, and any federated app will silently re-auth them. - Subscribe to
session.revokedif you maintain server-side session state that should mirror BluAuth's view.
See also: OIDC Integration, Webhook Events.