Deep dive
Decision table
| Situation | Choice | Why |
|---|---|---|
| Server-rendered or same-site SPA | Opaque session cookie | Instant revocation, no token in JS |
| SPA on another domain than API | BFF: backend holds tokens, browser gets session cookie | Keeps tokens out of the browser |
| Mobile app | Opaque or JWT access token + rotating refresh token in Keychain/Keystore | No cookies; secure storage exists |
| Microservices behind a gateway | Gateway verifies session, mints 5-min internal JWT | Services verify locally, no shared DB |
| Public API for third parties | OAuth 2 access tokens (opaque + introspection, or JWT) | Standard, scoped, revocable |
| Need "log out everywhere" in seconds | Server sessions, or JWT + denylist checked per request | JWTs alone can't be revoked |
| Very high RPS, session store is the bottleneck | Short JWT (1-5 min) + refresh against store | Lookup only on refresh |
Safe defaults
- Cookie:
__Host-sid=<id>; Path=/; Secure; HttpOnly; SameSite=Lax. The__Host-prefix forbidsDomain=so subdomains can't set it. - Rotate the session ID on login and privilege change (stops session fixation).
- Idle timeout order of 30 min-24 h, absolute timeout order of 7-30 days depending on risk; re-authenticate for sensitive actions.
- CSRF: SameSite=Lax blocks cross-site POSTs in modern browsers; still require a CSRF token or check
Origin/Sec-Fetch-Siteon state-changing requests. - JWT verifier: fixed algorithm list, check
exp,nbf,iss,aud, key bykidfrom a trusted JWKS; ≤ 60 s clock leeway. - Refresh tokens: one-time use; on reuse of an old one, revoke the whole token family (it was stolen).
Refresh token rotation
- Login issues an access token (minutes) and a refresh token (days), both tied to a server-side "token family" row.
- Each refresh returns a new pair and marks the old refresh token used.
- If a used refresh token arrives again, someone copied it: revoke the family and force re-login.
- Bind refresh tokens to the client where possible (DPoP key, mTLS, device-bound key) so a copied token alone is useless.
Code
Rust: opaque sessions (rand = "0.8", sha2, base64 = "0.22").
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use rand::{rngs::OsRng, RngCore};
use sha2::{Digest, Sha256};
pub struct NewSession { pub cookie_value: String, pub db_key: [u8; 32] }
pub fn new_session() -> NewSession {
let mut id = [0u8; 32]; // 256 bits of entropy
OsRng.fill_bytes(&mut id);
NewSession {
cookie_value: URL_SAFE_NO_PAD.encode(id),
db_key: Sha256::digest(id).into(), // DB leak doesn't leak live sessions
}
}
pub fn lookup_key(cookie_value: &str) -> Option<[u8; 32]> {
let raw = URL_SAFE_NO_PAD.decode(cookie_value).ok()?;
(raw.len() == 32).then(|| Sha256::digest(&raw).into())
}
pub fn set_cookie_header(cookie_value: &str, max_age_s: u64) -> String {
format!("__Host-sid={cookie_value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={max_age_s}")
}
Store: sessions(key BYTEA PRIMARY KEY, user_id, created_at, last_seen, expires_at). Logout = delete row; "log out everywhere" = delete by user_id.
JWT verification with jsonwebtoken = "9":
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use serde::Deserialize;
#[derive(Deserialize)]
pub struct Claims { pub sub: String, pub exp: u64, pub scope: String }
pub fn verify_access_token(token: &str, ed25519_pub_pem: &[u8]) -> Option<Claims> {
let mut v = Validation::new(Algorithm::EdDSA); // only EdDSA accepted: no alg confusion
v.set_issuer(&["https://auth.example.com"]);
v.set_audience(&["orders-api"]);
v.leeway = 30;
let key = DecodingKey::from_ed_pem(ed25519_pub_pem).ok()?;
decode::<Claims>(token, &key, &v).ok().map(|d| d.claims)
}
Efficiency
- Cost: a session costs one primary-key read by a 32-byte hash per request, order of 0.1 ms in Redis, order of 1 ms in a remote SQL store. A JWT costs one signature check, order of 50-100 µs for Ed25519, no network, but adds bytes to every request. Bound: store latency (sessions), CPU and bandwidth (tokens).
- Levers:
- Session lookup by hashed ID — SHA-256 of the ID is negligible next to the read — default for first-party web.
- Short in-process cache — saves most reads, delays revocation by the cache time — hot stores.
- Short-lived JWT (1-15 min) with local verify — no revocation list needed — service-to-service.
- Minimal claims — the session cookie is 43 characters for 256 bits; a JWT adds an 86-character Ed25519 signature plus claims on every request — bandwidth-sensitive calls.
- Naive → standard → state of the art:
- Naive · long-lived JWT plus a denylist checked on every request: token cost plus a lookup, no gain
- Standard · one session table with an index on
user_id· order of 0.1-1 ms per request - State of the art · short-lived Ed25519 JWTs with refresh against the store, once the store is measurably the bottleneck · order of 50-100 µs, no network
Pitfalls
- Accepting
algfrom the token header:none, or HS256 signed with your RSA public key as the HMAC secret. - Long-lived JWTs (days) with no revocation path; a stolen token works until expiry.
- Putting PII or permissions that change often into JWTs; claims are readable (base64, not encrypted) and stale.
- Session IDs from
rand()/UUIDv1/timestamps, or storing them in plaintext in the DB. - Tokens in URLs (
?token=): land in logs, Referer headers, browser history. - Not rotating session ID on login: an attacker who planted a session ID inherits the login.
- Forgetting to invalidate sessions on password change or account deletion.
- Using cookies with
SameSite=Nonewithout CSRF protection.
In production
- Cloud infra: Kubernetes ServiceAccounts authenticate to the API server with signed JWTs (docs).
- IoT: AWS IoT Core custom authentication accepts devices that present a bearer token (AWS docs).
- Finance: open-banking ecosystems use the FAPI profile of OAuth 2, built on access tokens (OpenID FAPI).
- Healthcare: SMART on FHIR apps obtain OAuth 2.0 access tokens for health-record APIs (HL7).
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| jsonwebtoken | Rust | Keats/jsonwebtoken | cargo add jsonwebtoken | — | — | — | — | JWT encode/verify, fixed algorithm list, EdDSA/ES256 |
| tower-sessions | Rust | maxcountryman/tower-sessions | cargo add tower-sessions | — | — | — | — | session middleware for axum and tower; pluggable stores |
| actix-session | Rust | actix/actix-extras | cargo add actix-session | — | — | — | — | session middleware for Actix Web; cookie and Redis stores |
| Keycloak | Java | keycloak/keycloak | n/a (service) | — | — | — | — | OIDC/OAuth 2 server with sessions and refresh tokens |
Sources
- OWASP, Session Management Cheat Sheet. doc
- OWASP, Cross-Site Request Forgery Prevention Cheat Sheet. doc
- Jones, Bradley, Sakimura, RFC 7519: JSON Web Token (JWT), 2015. rfc
- Sheffer, Hardt, Jones, RFC 8725: JSON Web Token Best Current Practices, 2020. rfc
- Barth, RFC 6265: HTTP State Management Mechanism, 2011. rfc
- Lodderstedt et al., RFC 9700: Best Current Practice for OAuth 2.0 Security (refresh token rotation), 2025. rfc
- Fett et al., RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP), 2023. rfc