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
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.
All auth.md §2 invariants apply. Session-specific:
env=live action, credential change, or GDPR erasure.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.
sessions table per auth.md §3).HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=43200 (12h rolling).POST /api/sessions, the server can issue a short-lived JWT (15-minute TTL) alongside the cookie if the client presents Accept: application/jwt or omits the Origin header (i.e. is not a browser).{ "sub": "<user_id>", "sid": "<session_id>", "tier": "free|pro|pro_plus", "role": "user|admin", "iat": <ts>, "exp": <ts>, "fresh": <ts> }.SESSION_JWT_SECRET (HS256). Key rotatable via env + dual-accept overlap window./api/sessions/refresh with the JWT in Authorization header returns a new JWT + extends the server-side session.Every authenticated request:
session_id (hashed) in the session store (Redis).Authorization: Bearer <jwt>: verify signature, extract sid, look up in Redis.revoked_at IS NULL and expires_at > now.{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."
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).
Per docs/marketing/pricing.md:
| Tier | Rate limit |
|---|---|
| Free | 60 req/min |
| Pro | 600 req/min |
| Pro+ | 3,000 req/min |
user_id, not IP or session id. A user using multiple sessions does not get N× the limit.backend_v2/api/middleware/rate_limiter.py with Redis-backed token bucket: one bucket per user per tier. Bucket refill rate = tier limit / 60 per second, bucket size = tier limit./api/* authenticated routes. Auth endpoints (/api/auth/*, /api/sessions) have their own tighter per-IP + per-email buckets that are independent of tier.Retry-After header; body includes {"error": {"code": "rate_limited", "retry_after_seconds": N, "tier": "<tier>"}}.X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. Encourages good client behavior./api/admin/*) have their own budget (not per-tier — operators are not users in the tier sense).RATE_LIMIT_DISABLED=1 short-circuits all buckets to allow-through; emergency use only, logged.Any route decorated @require_fresh_assertion(max_age_minutes=5):
fresh_until from the session context.now - fresh_until > 5m, returns 401 with {"error": {"code": "step_up_required"}}.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.
Path=/, no cross-subdomain. console.raxx.app has its own session domain per console.md.SameSite=Strict is the primary defense. Origin-check middleware double-guards state-changing routes.GET /api/sessions + DELETE /api/sessions/{id} exposes every active session to the user. "Sign out everywhere" is DELETE /api/sessions?all=true.sessions row lifecycle event (create, refresh, step-up, revoke) writes an audit_log row.SESSION_REVOKE_ALL=1 env flag revokes every non-admin session in one shot (writes audit_log per session).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).