Session Architecture
Session Architecture
How sessions work in core.auth — JWT access tokens, stateful session validation, and the full lifecycle.
Design Decision
core.auth uses JWT access tokens with stateful session validation — a pragmatic hybrid that gives you stateless request verification today with a clean path to further optimization later.
- Stateless access: JWT verification is local (HMAC signature check), no DB hit per request
- Stateful refresh: session validity is checked against the DB only when the token expires
- Simple revocation: delete the session row → user is logged out within the token's TTL
- Standard format: JWT is universally supported, no custom token formats
The Architecture
| Component | Type | Lifetime | Verification | Purpose |
| JWT access token | Stateless | 15 minutes (configurable) | HMAC-SHA256 signature check, local | Proves identity for API requests |
Session cookie (session_ref) | Stateful reference | 30 days (configurable) | DB lookup at core.auth | Renewing access tokens |
Why This Over a Fully Stateful System?
A fully stateful system checks the database on every API request. That's secure but expensive — 100 requests per minute from a user means 100 DB queries per minute.
With JWT, API requests verify the token locally (signature + expiry check). The DB is only hit on refresh — once every 15 minutes per user. That's a ~100,000x reduction in DB load per active user.
Why Not a Fully Stateless System?
A fully stateless system (long-lived JWT, no DB) can't be revoked. If a token is stolen, it's valid until it expires. The session database gives you a revocation lever: delete the row, and the next refresh fails.
Session Lifecycle
1. User authenticates at core.auth
│
▼
2. core.auth creates:
├── session record (DB)
└── JWT access token (HMAC-signed, returned to client)
│
▼
3. core.auth sets:
├── Response body: { "access_token": "eyJ..." }
└── Set-Cookie: session_ref=<session-uuid>; HttpOnly; Secure; SameSite=Lax
│
▼
4. App makes API requests with JWT access token
├── App verifies HMAC signature locally (no DB hit)
└── Token valid → proceed
│
▼
5. JWT access token expires (15 min)
│
▼
6. App sends refresh request via core.console (relayed to core.auth)
POST /session/refresh
Cookie: session_ref=<session-uuid> (sent automatically by browser)
│
▼
7. core.auth validates session:
├── Session exists + not revoked + not expired → issue new JWT
└── Session missing/revoked/expired → 401, force re-authentication
│
▼
8. Repeat from step 4What Happens on Each Step
Step 1 — Authentication:
User provides credentials (password + optional 2FA). core.auth validates them against the
users table.Step 2 — Session + JWT creation:
core.auth creates a
sessions record (tracking metadata, expiry) and signs a JWT access token containing the user ID, app slug, scopes, session ID, and expiry.Step 3 — Token delivery:
The JWT is returned in the response body. The session reference is set as an HttpOnly, Secure, SameSite=Lax cookie on the core.auth domain. The cookie is the refresh credential — the client never needs to manage it.
Step 4 — API requests:
The app includes the JWT in requests (
Authorization: Bearer <token> or cookie). The receiving service verifies the HMAC signature locally — no database lookup, no network call to core.auth.Step 5 — Expiry:
JWT access tokens have a short TTL (default 15 minutes). After expiry, the app must refresh.
Step 6 — Refresh:
The app sends a request to core.auth's
/session/refresh endpoint. For browser clients, the session_ref cookie goes along automatically. For mobile clients, the session ID is sent as a header (X-Session-Ref).Step 7 — Session validation:
core.auth looks up the session row in the database. If the session exists, is not revoked, and is not expired, it issues a new JWT. If the session is missing or revoked, it returns 401 and the user must re-authenticate.
Step 8 — Continuity:
The cycle continues. The user stays logged in as long as their session row exists in the DB and they keep refreshing.
Why This Is Simple
There's no refresh token. No token rotation. No family tracking. No stolen-token detection chains. The session row is the single source of truth:
- Session row exists → user is authenticated → issue new JWT
- Session row deleted → user is logged out → next refresh fails
Revocation is a single
DELETE or UPDATE on the sessions table.Session Cookie
The
session_ref cookie is the credential for the refresh endpoint. It's a reference to the session row — not a token, not a JWT, just a session UUID.Properties
| Property | Value | Why |
| HttpOnly | true | JavaScript can't access it (XSS can't steal it) |
| Secure | true | HTTPS only |
| SameSite | Lax | Sent on top-level navigations, not cross-site requests |
| Path | /session/refresh | Only sent to the refresh endpoint, not every request |
| Max-Age | 30 days | Matches the session lifetime |
Mobile Clients
Native mobile apps don't handle cookies automatically. They store the session ID locally and send it as a header on refresh:
Browser: Cookie: session_ref=abc-123
Mobile: X-Session-Ref: abc-123
Backend: check cookie first, fall back to header.Same endpoint, different transport. The backend doesn't care where the session ID comes from.
Sticky Sessions (Persistent Logins)
Following the pattern set by Google, Facebook, and other major providers, core.auth supports persistent login — the "remember me" behavior users expect.
The session cookie is long-lived (up to 30 days by default), and as long as the user keeps refreshing their JWT, they stay logged in — until the hard session cap is reached.
Hard Session Cap
The hard cap forces re-authentication regardless of activity. Default: 30 days from initial authentication.
This balances convenience with security:
- Convenience: users don't get randomly logged out during active use
- Security: abandoned sessions eventually expire
Configuration Per App
registered_apps:
├── access_token_ttl: 900 (15 min default)
├── session_ttl: 2592000 (30 days default)
├── hard_session_cap: 2592000 (30 days from login)
└── allow_remember_me: trueApps can configure their own TTLs within limits set by core.auth. A banking app might want 5-minute JWTs and 7-day hard caps. A personal todo app might want 15-minute JWTs and 90-day hard caps.
Silent Refresh
To avoid interrupting the user when the JWT expires, apps should implement silent refresh — automatically refreshing the token in the background before it expires.
JWT issued at T, expires at T+15min
│
▼
App sets a timer for T+13min (2 min before expiry)
│
▼
Timer fires → app sends refresh request to core.auth
│
▼
New JWT received
│
▼
App updates stored token
│
▼
User never noticesThis can be implemented as:
- A JavaScript timer in the frontend
- A middleware check in the backend that refreshes proactively
- A combination (frontend for SPA, backend for server-rendered)
Multi-Session Support
A user can have multiple active sessions simultaneously — one per device/browser. Each session has its own:
session_id- IP address / user agent tracking
- Independent expiry
Sessions are visible in the account dashboard. Users can see "where am I logged in" and revoke individual sessions or all sessions at once.
User at laptop (Chrome) → session_1, IP: 1.2.3.4, last active: 5 min ago
User at phone (Safari) → session_2, IP: 5.6.7.8, last active: 2 hours ago
User at tablet (Firefox) → session_3, IP: 9.10.11.12, last active: 3 days agoScaling Path
This architecture is designed to evolve without changing the client-side contract:
| Phase | What Changes | DB Load per User | Migration Effort |
| Phase 1 (now) | Session check in PostgreSQL | 1 query per 15 min | — |
| Phase 2 (scale) | Session check in Redis | 1 query per 15 min (sub-ms) | Transparent swap, no client change |
| Phase 3 (optimize) | Add refresh tokens with rotation | Near zero (stateless refresh) | Backend-only, client contract unchanged |
Each phase only changes the backend. The frontend always does: "send JWT with requests, call refresh endpoint when JWT expires."
Next: Scope Model — three-tier scoping system.