Home › Security › Authentication & authorization
guide low

Sessions vs tokens

Problem Designers of login state for a web app, mobile app or API must choose between server-side sessions and signed tokens. This guide answers which to use, and how quickly access can be revoked.

Opaque server-side sessions in cookies for browsers, short-lived signed tokens between services; revocation decides most cases.

authenticationsessionsjwtcookiescsrfrefresh-tokens
Overview

A session is a random ID that points to state in your store: easy to revoke, one lookup per request. A self-contained token (JWT) carries signed claims: no lookup, but valid until it expires. Pick by how fast you must be able to kill access, and by who holds the credential.

Key facts

Use when

  • You are designing login state for a web app, mobile app or API.
  • Machine identities too: cluster workloads, IoT devices, partner APIs in health and banking.

Avoid when

  • You are about to build your own token issuer for "SSO": use OAuth 2 / OIDC.

Deep dive

Decision table

SituationChoiceWhy
Server-rendered or same-site SPAOpaque session cookieInstant revocation, no token in JS
SPA on another domain than APIBFF: backend holds tokens, browser gets session cookieKeeps tokens out of the browser
Mobile appOpaque or JWT access token + rotating refresh token in Keychain/KeystoreNo cookies; secure storage exists
Microservices behind a gatewayGateway verifies session, mints 5-min internal JWTServices verify locally, no shared DB
Public API for third partiesOAuth 2 access tokens (opaque + introspection, or JWT)Standard, scoped, revocable
Need "log out everywhere" in secondsServer sessions, or JWT + denylist checked per requestJWTs alone can't be revoked
Very high RPS, session store is the bottleneckShort JWT (1-5 min) + refresh against storeLookup only on refresh

Safe defaults

  • Cookie: __Host-sid=<id>; Path=/; Secure; HttpOnly; SameSite=Lax. The __Host- prefix forbids Domain= 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-Site on state-changing requests.
  • JWT verifier: fixed algorithm list, check exp, nbf, iss, aud, key by kid from 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

  1. Login issues an access token (minutes) and a refresh token (days), both tied to a server-side "token family" row.
  2. Each refresh returns a new pair and marks the old refresh token used.
  3. If a used refresh token arrives again, someone copied it: revoke the family and force re-login.
  4. 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 alg from 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=None without 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

namelangrepoinstallverdictlicencehealthas_ofnotes
jsonwebtokenRustKeats/jsonwebtokencargo add jsonwebtoken————JWT encode/verify, fixed algorithm list, EdDSA/ES256
tower-sessionsRustmaxcountryman/tower-sessionscargo add tower-sessions————session middleware for axum and tower; pluggable stores
actix-sessionRustactix/actix-extrascargo add actix-session————session middleware for Actix Web; cookie and Redis stores
KeycloakJavakeycloak/keycloakn/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

Builds on

Used by

Systems (13):

Alternatives and related