Session engine — REST + JWT + server-side revocation + per-tier rate limits
Status: Draft
Owner: software-architect
Last updated: 2026-04-22
Parent epic: #183
Related: auth.md, mbt-paper-trading-engine.md, ADR 0001, ADR 0002, ADR 0013
1. Context + goal
auth.md specifies the WebAuthn flow and a sessions table. This doc pins down the operational shape: REST endpoints for session lifecycle, JWT token format, server-side revocation, and per-tier rate limits per the pricing matrix (Free 60/min, Pro 600/min, Pro+ 3000/min).
Goal: session management that is a first-class REST surface (consumable by browser, iOS, console, and CLI) while preserving instant revocation and per-tier enforcement.
2. Invariants
All auth.md §2 invariants apply. Session-specific:
- Session tokens are not replayable user secrets. They are bearer tokens bound to a specific passkey assertion at mint time, short-lived, and revocable server-side.
- No long-lived bearer tokens in v1 (no PATs). When they are added later, they will be passkey-attested and per-use user-verified; separate ADR.
- Step-up assertion is required within 5 minutes of any
env=liveaction, credential change, or GDPR erasure.
3. Endpoints
All under /api/sessions/*. Authentication on protected endpoints is via the session cookie (primary) or Authorization: Bearer <jwt> header (secondary, for non-browser clients).
| Method | Path | Purpose |
|---|---|---|
| POST | /api/sessions |
Create a new session from a WebAuthn assertion. Body: {assertion}. Response: session cookie set + JSON body with token details (no raw JWT in browser flow; cookie only). |
| GET | /api/sessions/current |
Return the caller's session metadata: {user_id, role, tier, issued_at, expires_at, fresh_until}. No token material returned. |
| DELETE | /api/sessions/current |
Revoke the current session (logout). Cookie is cleared + session row revoked_at is stamped. |
| POST | /api/sessions/refresh |
Roll the session within its rolling window. Requires the cookie; does not require a fresh assertion unless the 24h step-up window has lapsed. |
| GET | /api/sessions |
List the caller's active sessions (multi-device). Returns session metadata with ip_prefix, user_agent, last_seen_at. |
| DELETE | /api/sessions/{id} |
Revoke a specific session (remote logout of another device). Requires step-up assertion. |
| POST | /api/sessions/step-up |
Refresh the fresh_until timestamp on the current session using a new WebAuthn assertion. Used before live-trade actions and credential changes. |
Create / step-up endpoints are rate-limited more strictly than data endpoints (per-IP + per-email bucket) to block brute-force and enumeration.
4. Token format
In the browser (primary path)
- Session cookie is opaque (256-bit random; stored hashed server-side in the
sessionstable perauth.md§3). - Cookie flags:
HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=43200(12h rolling). - No JWT in the browser. This keeps the revocation story simple and avoids token introspection in Antlers.
For non-browser clients (iOS, CLI, console)
- On successful
POST /api/sessions, the server can issue a short-lived JWT (15-minute TTL) alongside the cookie if the client presentsAccept: application/jwtor omits theOriginheader (i.e. is not a browser). - JWT claims:
{ "sub": "<user_id>", "sid": "<session_id>", "tier": "free|pro|pro_plus", "role": "user|admin", "iat": <ts>, "exp": <ts>, "fresh": <ts> }. - Signed with
SESSION_JWT_SECRET(HS256). Key rotatable via env + dual-accept overlap window. - Refresh: POST
/api/sessions/refreshwith the JWT inAuthorizationheader returns a new JWT + extends the server-side session.
Validation order
Every authenticated request:
- Parse cookie → look up
session_id(hashed) in the session store (Redis). - If not present, try
Authorization: Bearer <jwt>: verify signature, extractsid, look up in Redis. - Check
revoked_at IS NULLandexpires_at > now. - Attach
{user_id, tier, role, session_id, fresh_until}to the request context.
If any check fails: 401 with WWW-Authenticate: Session (browser) or WWW-Authenticate: Bearer (non-browser). No error message leakage beyond "session invalid."
5. Server-side session store
sessions lives in Redis for O(1) lookup + TTL-based expiry. Persistent shadow copy in Postgres/SQLite for audit and cross-region replay. Schema per auth.md §3 extended:
sessions (Redis key: session:<sid_hash>)
user_id string
credential_id string
tier 'free'|'pro'|'pro_plus'
role 'user'|'admin'
issued_at timestamp
expires_at timestamp (TTL on Redis key)
fresh_until timestamp -- updated on step-up
revoked_at timestamp | null
last_seen_at timestamp
ip_prefix string -- /24 or /48
user_agent string
Revocation is DEL session:<sid_hash> in Redis + update the shadow row. The next request fails lookup → 401.
Tier pinning: the tier on the session is captured at mint time and refreshed only on POST /api/sessions/refresh. A user who upgrades mid-session must refresh to see new rate-limit headroom; this is a UX note (Antlers triggers refresh after a successful upgrade).
6. Per-tier rate limits
Per docs/marketing/pricing.md:
| Tier | Rate limit |
|---|---|
| Free | 60 req/min |
| Pro | 600 req/min |
| Pro+ | 3,000 req/min |
Enforcement
- Keyed on
user_id, not IP or session id. A user using multiple sessions does not get N× the limit. - Implemented in
backend_v2/api/middleware/rate_limiter.pywith Redis-backed token bucket: one bucket per user per tier. Bucket refill rate = tier limit / 60 per second, bucket size = tier limit. - Scoped endpoints: all
/api/*authenticated routes. Auth endpoints (/api/auth/*,/api/sessions) have their own tighter per-IP + per-email buckets that are independent of tier. - Response: 429 with
Retry-Afterheader; body includes{"error": {"code": "rate_limited", "retry_after_seconds": N, "tier": "<tier>"}}. - Headers on every response:
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset. Encourages good client behavior.
Exceptions
- Market-data streaming endpoints (SSE) count against a separate per-user concurrent-connection cap, not the token-bucket budget.
- Admin routes (
/api/admin/*) have their own budget (not per-tier — operators are not users in the tier sense). - Kill switch:
RATE_LIMIT_DISABLED=1short-circuits all buckets to allow-through; emergency use only, logged.
7. Step-up flow
Any route decorated @require_fresh_assertion(max_age_minutes=5):
- Pulls
fresh_untilfrom the session context. - If
now - fresh_until > 5m, returns 401 with{"error": {"code": "step_up_required"}}. - Antlers handles this by prompting a WebAuthn assertion, calling
POST /api/sessions/step-up, then retrying the original request.
Step-up endpoints are: live-trading actions, credential add/remove, GDPR erasure, admin actions, session revocation for other devices.
8. Security considerations
- No credential replay. Session tokens are bearer tokens, not user secrets. Compromise of a session = compromise of that session; revocable instantly.
- Cookie scope:
Path=/, no cross-subdomain.console.raxx.apphas its own session domain perconsole.md. - Session fixation: session id is regenerated on every fresh WebAuthn assertion (mint or step-up).
- CSRF:
SameSite=Strictis the primary defense. Origin-check middleware double-guards state-changing routes. - Token theft from non-browser clients: JWTs are short-lived (15 min). A stolen JWT expires quickly and cannot refresh itself without the server-side session also being valid.
- Device-level revocation:
GET /api/sessions+DELETE /api/sessions/{id}exposes every active session to the user. "Sign out everywhere" isDELETE /api/sessions?all=true. - Audit: every
sessionsrow lifecycle event (create, refresh, step-up, revoke) writes anaudit_logrow. - Breach response:
SESSION_REVOKE_ALL=1env flag revokes every non-admin session in one shot (writesaudit_logper session).
9. Open questions
- Sliding vs fixed expiry. Current proposal is 12h rolling (extends on use). Alternative: 12h fixed to force daily re-assertion. Product call. Default here is rolling with 24h hard step-up ceiling.
- Refresh-token pattern for non-browser clients. Do we issue a separate refresh token to iOS/CLI, or require them to re-assert WebAuthn every 12h? Leaning toward "re-assert every 12h" for simplicity — iOS has good platform passkey support. Blocker for mobile roadmap, not for MBT v1.
- Admin session TTL. Console has 8h fixed per
console.md. User sessions are 12h rolling. Keep divergent or align?
End of doc. Consumed by MBT (mbt-paper-trading-engine.md) and auth (auth.md).