navree

Sessions and CSRF

The optional session module: encrypted session cookies, memory and database stores, CSRF protection for cookie clients, and when to use it instead of bearer tokens.

On this page

SessionModule (crate navree-session, feature session) gives each browser a server-side session identified by an encrypted cookie, and protects cookie-carrying requests against cross-site request forgery. It speaks the same protocol as Laravel Sanctum's single-page-app mode: the client fetches a CSRF cookie once, then sends its value back in a header on every state-changing request.

The module is optional. Navree's built-in authentication (AuthModule) uses bearer tokens and doesn't need sessions, and the reference CRM doesn't import this module. Read when to use sessions first.

Sessions or bearer tokens

Sessions (SessionModule)Bearer tokens (AuthModule)
The browser holdsan HttpOnly session cookiean access token your code stores and sends in Authorization
Stateon the server, in the session storein the signed JWT; refresh tokens in the database
CSRF protectionneeded, and built innot needed: browsers don't attach Authorization by themselves
Clientsbrowsers on your siteanything: mobile apps, other servers, CLIs, browsers
Sign-inyour own login route and authenticator (below)built in: /api/auth/login, refresh, logout, 2FA

Use sessions when a browser talks to the API directly and you want the credential in an HttpOnly cookie: server-rendered pages, or a single-page app on the same site as the API. Use bearer tokens for everything else.

The CRM takes a third route, a backend-for-frontend: its Next.js server keeps the bearer tokens in HttpOnly cookies on its own domain and calls the API with Authorization: Bearer. The browser never holds a token and the API needs no sessions or CSRF checks. See authentication.

Setting up

Add SessionModule after CryptoModule, which encrypts the cookie with the app key, and HttpModule:

src/app.rs

DatabaseModule is only needed for the database store. The app needs an application key (APP_KEY, from navree key:generate). Without one the app boots with a warning, but every request that writes a session fails with a 500.

Configuration

config/app.toml
KeyDefaultWhat it does
session.storememorywhere session data lives; see stores
session.cookienavree_sessionthe session cookie's name (letters, digits, -, _, .; not XSRF-TOKEN)
session.lifetime_minutes120how long an idle session lives (1 minute to about a year)
session.securetruethe cookies' Secure attribute; browsers accept Secure cookies from http://localhost
session.same_sitelaxthe cookies' SameSite attribute; none needs secure = true
session.domainnone (the API host only)the cookies' Domain, to share them with sibling hosts such as app.example.com and api.example.com
session.csrf_except[]path globs exempt from CSRF checks (* matches any characters, ? one)

From the environment: APP_SESSION__STORE=database, APP_SESSION__SECURE=false, and APP_SESSION__CSRF_EXCEPT=/webhooks/*,/stripe/* as a comma-separated list. Invalid options stop the boot.

In code, use SessionModule::for_root:

Rust

Using the session

Take Session as an extractor. Values are stored as JSON, so anything Serialize and Deserialize fits:

src/cart/controller.rs
MethodWhat it does
get::<T>(key)the value, or None when it's missing or not a T
insert(key, &value)stores a value; fails for values that aren't JSON and for the reserved key _token
remove(key)removes a value and returns it
contains(key)whether a value is stored
regenerate()a new session id and CSRF token, keeping the values: call it when a user signs in
invalidate()a new id and CSRF token, and no values: call it when a user signs out
flush()removes every value, keeping the id and the CSRF token
csrf_token()the session's CSRF token, created on first use
id()the session id; it's a credential, so never log it

The session is saved after the response, only when it changed. Option<Session> gives None instead of a 500 in apps without the module. Session::detached() makes a session no request carries, for unit tests of code that takes a &Session.

The session cookie holds only the session id, encrypted with the app key (AES-256-GCM); the data stays on the server, in the store. The cookie is HttpOnly, with Path=/, the configured SameSite, Secure and Domain, and Max-Age equal to the lifetime:

HTTP
  • A cookie that doesn't decrypt, or names an expired or unknown session, starts a new session with a new id. Navree never adopts an id the client chose.
  • Sessions expire after lifetime_minutes without requests. Each request extends the session, with at most one store write a minute when nothing else changed.
  • Changing the app key signs everyone out, unless you move the old key to app.previous_keys (see encryption).

Stores

Storesession.storeNotes
MemoryStorememoryin the process: fast, but lost on restart and not shared between instances
DatabaseStoredatabasethe sessions table (id, data as JSON text, expires_at), created by the module's session migration set; survives restarts and is shared by every instance

Run your migrations to create the sessions table (migrate, or database.migrate_on_boot). Both stores delete expired sessions on every hundredth save; prune_expired() on either store does it on demand. The module exports its store only as dyn SessionStore, which has no prune_expired, so to prune the database store from a scheduled task, build one on the app's Db with DatabaseStore::new(db). The database store writes outside the request's unit of work: it loads the session before the transaction starts and saves it after the commit.

To use another store, implement navree::contracts::SessionStore and replace the module's store with AppBuilder::override_value::<dyn SessionStore>(Arc::new(MyStore)). There's no Redis store yet; see the roadmap.

CSRF protection

A browser attaches cookies to requests that other sites trigger: a form on evil.example can post to your API, and the browser sends the session cookie along. CSRF protection makes sure state-changing requests come from your own front end.

The rule

A request with a state-changing method (anything but GET, HEAD, OPTIONS and TRACE) that carries the session cookie must send the session's CSRF token in one of two headers:

  • X-XSRF-TOKEN: the value of the XSRF-TOKEN cookie (URL-decoded by the server), as Axios sends it;
  • X-CSRF-TOKEN: the token as is, for example from session.csrf_token() rendered into a page.

Otherwise it gets a 403 before any handler runs:

403 Forbidden
  • Requests with an Authorization: Bearer token skip the check: they can't be forged by another site.
  • A state-changing request without a session cookie has no token to send and passes, unless the browser marks it Sec-Fetch-Site: cross-site and its Origin isn't in http.cors.allowed_origins. That stops a forged form from signing a victim in to the attacker's account (login CSRF).
  • Paths matching session.csrf_except skip both checks. Use it for webhooks, which authenticate with signatures instead.
  • Tokens are compared in constant time and never logged. Each failure is logged at WARN on navree::security (CSRF token mismatch, with the reason missing, mismatch or cross_site) and, with the audit module, written to the audit trail as csrf.mismatch.

GET /sanctum/csrf-cookie (no API prefix) answers 204 No Content with the XSRF-TOKEN cookie, starting a session if there is none. The cookie isn't HttpOnly, so your JavaScript can read it. A single-page app calls it once before its first state-changing request:

app/api.ts

Axios does the header part for you on same-origin requests, and on cross-origin ones with withXSRFToken: true. The response to any request that saves the session also refreshes the XSRF-TOKEN cookie.

Signing in with a session

AuthModule authenticates bearer tokens only; it doesn't read the session. To sign users in with a session, register your own Authenticator. The session layer runs before authentication, so the authenticator finds the Session in the request:

src/session_auth/mod.rs

check_password is yours to write: look the user up and verify the password with the hasher. Every route still needs its access declaration, and #[authenticated] routes now accept the session. The login itself needs a CSRF token too, so the front end calls /sanctum/csrf-cookie first.

An app has exactly one dyn Authenticator, and AuthModule registers its JWT one: with both modules in the app, the boot fails. So a session-based app uses a module like this one instead of AuthModule. To accept both cookies and bearer tokens, write one authenticator that handles both.

Testing

The test client keeps cookies between requests and has two helpers: csrf_cookie() calls GET /sanctum/csrf-cookie, and .xsrf() on a request sends the XSRF-TOKEN cookie back as X-XSRF-TOKEN:

tests/cart.rs

Test apps have no app key unless you set one, which is why the example sets app.key.

Popular pages

  • Installation
  • Your first app
  • Routing and controllers
  • Validation
  • Authentication
  • Benchmarks