Session validation now also checks created_at so the 12-hour cap holds while expires_at is still in the future. Genre seed avoids ON CONFLICT, which burns SMALLSERIAL values on every startup.
2.6 KiB
ADR-0002: Server-side sessions with opaque tokens hashed at rest
Date: 2026-10-02 Status: Accepted
Context
After login, the browser needs a credential for subsequent API calls. Flask's default session is a signed
cookie (itsdangerous, HMAC-SHA1 by default) holding the session data client-side. That cookie cannot be
revoked before it expires: logout only asks the browser to forget it, and a stolen cookie stays valid until
SECRET_KEY rotates. Healthcare applications are expected to support automatic logoff after inactivity
(HIPAA Security Rule, 45 CFR 164.312(a)(2)(iii)) and real revocation.
Decision
Store sessions in PostgreSQL, keyed by the SHA-256 of an opaque random token.
- Issue: on login/register,
token = secrets.token_urlsafe(32)(256 bits). Insertsha256(token),user_id,created_at,expires_at = now() + 30 minutes. The raw token exists only in the cookie. - Cookie:
sid=<token>; HttpOnly; Secure; SameSite=Lax; Path=/api. - Validate and slide: one statement per authenticated request:
This gives a 30-minute idle timeout and a 12-hour absolute cap. The
UPDATE sessions SET expires_at = LEAST(now() + interval '30 minutes', created_at + interval '12 hours') WHERE token_hash = %s AND expires_at > now() AND created_at > now() - interval '12 hours' RETURNING user_id;created_atpredicate is required:expires_atcan still be up to 30 minutes in the future when the 12-hour cap passes. No row → 401. - Revoke: logout deletes the row. Deleting a user cascades to their sessions.
- Cleanup: a user's expired rows are deleted when they log in; no scheduled job.
CSRF
The cookie is SameSite=Lax, so browsers do not send it on cross-site POST/PATCH/DELETE. In addition,
every mutating route requires Content-Type: application/json; a cross-origin request with that type
triggers a CORS preflight, and the API never grants CORS. Together these remove the need for a CSRF token.
Consequences
- Logout and server-side expiry actually revoke access.
- A database leak exposes only token hashes, which cannot be replayed as cookies.
- One indexed UPDATE per authenticated request; negligible at this scale.
- Hashing tokens with plain SHA-256 (not a slow KDF) is correct here: tokens carry 256 bits of entropy, so brute force is infeasible.
Known gaps (out of scope)
- No login rate limiting or lockout. Online password guessing is bounded only by PBKDF2 cost. Before production, add per-username and per-IP throttling.
- No "log out other devices" UI, though the schema supports it (
DELETE … WHERE user_id = %s).