Sessions & Cookies
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against tower-sessions 0.14.0 (with tower-sessions-redis-store 0.16.0, and notes on 0.15.0) and axum-extra 0.12.6 with axum 0.8.9 on Rust 1.99 (edition 2024), checked on 2026-10-06. Bookshelf's server-rendered pages sign users in with a session cookie; its JSON API uses bearer tokens instead (see JWT & Bearer Tokens). The protocol background — why sessions, cookie security, fixation — is in the OWASP Session Management cheat sheet and on this site’s OAuth pages.
Version pairing first
tower-sessions 0.15 is the newest release, but at the time of writing its ecosystem has not caught up:
-
axum-login0.18 requires tower-sessions 0.14; -
tower-sessions-redis-store0.16 requires tower-sessions 0.14; -
tower-sessions-sqlx-store0.15 requires tower-sessions 0.14 and sqlx 0.8, so it cannot share aPgPoolwith an application on sqlx 0.9 (Cargo builds both sqlx versions, and the two pool types are unrelated).
What 0.15 changed is small: it moved to rand 0.9 for session-id generation and fixed a memory-ordering race in the
Session handle; the public API is the same as 0.14. The problem is purely one of type identity. Session,
SessionStore and SessionManagerLayer from tower-sessions-core 0.15 are different types from the 0.14 ones, so a
0.15 layer cannot serve a store or an AuthSession built on 0.14, and the compiler error ("the trait SessionStore
is not implemented for `RedisStore`") names two types that look identical.
Bookshelf therefore pins tower-sessions 0.14 and stores sessions in Redis. Check cargo tree -d after every
upgrade: two copies of tower-sessions in the build mean a layer from one version and an extractor from the other, and
the Session extractor then fails at runtime with a missing-extension 500. Move to 0.15 when axum-login and the
store you use have released versions depending on it; until then, cargo update keeps you on 0.14.x because the
0.14.0 requirement does not match 0.15.
[dependencies]
tower-sessions = "0.14.0" # not 0.15: axum-login 0.18 and the stores need 0.14
tower-sessions-redis-store = "0.16.0"
time = "0.3" # Expiry uses time::Duration
Source: tower-sessions CHANGELOG and Cargo: default requirements
Where it runs in the request
Sessions are spread over two points of the request lifecycle (tokio accepts the connection, hyper parses the request, tower layers wrap the router, the router matches, extractors run, the handler runs, the response travels back out through the layers):
-
On the way in, in the tower layers.
SessionManagerLayer(with theCookieManagerfrom tower-cookies that it installs) parses theCookieheader, reads the session id and puts aSessionhandle into the request extensions. It does not touch the store yet. A malformed id is logged atwarn("possibly suspicious activity") and treated as no session. -
In the extractor phase.
Sessionis aFromRequestPartsextractor that clones the handle out of the extensions; if the layer is missing it rejects with500and "Can’t extract session. IsSessionManagerLayerenabled?". The record is loaded from the store lazily, on the firstget,insertorremovein the handler. -
On the way out, in the same layer. After the handler has produced its response, the layer saves a modified (or, with
with_always_save(true), any) non-empty session and adds theSet-Cookieheader; an emptied session gets a removal cookie. The save is skipped when the response is a5xx, and a failing save replaces the handler’s response with an empty500.
Two consequences follow. Layers added outside the session layer (tracing, timeouts, rate limiting) run without a
session; and a store outage shows up as 500 responses after the handler ran, so make the store’s health part of
the readiness probe.
A session layer and the Session extractor
The example signs a user in by checking an Argon2id hash (as on
Password Authentication), rotates the session id, and stores one
typed value, SignedIn, instead of loose keys. SignedIn carries the sign-in time, so the handler can enforce an
absolute timeout of 12 hours on top of the layer’s 8-hour inactivity timeout:
use std::collections::HashMap;
use std::sync::Arc;
use argon2::{
Argon2,
password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash},
};
use axum::{
Form, Router,
extract::State,
response::{Html, IntoResponse, Redirect},
routing::{get, post},
};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use tower_sessions::{
Expiry, Session, SessionManagerLayer,
cookie::{SameSite, time::Duration},
};
use tower_sessions_redis_store::{RedisStore, fred::prelude::*};
/// Everything the application keeps in the session, under one key.
#[derive(Serialize, Deserialize)]
struct SignedIn {
user_id: i64,
since: i64, // unix seconds, for the absolute timeout
}
impl SignedIn {
const KEY: &'static str = "signed_in";
const ABSOLUTE_TIMEOUT_SECS: i64 = 12 * 60 * 60;
}
#[derive(Clone)]
struct AppState {
users: Arc<HashMap<String, (i64, String)>>, // username -> (id, Argon2id PHC string)
}
#[derive(Deserialize)]
struct Login {
username: String,
password: String,
}
async fn verify(password: String, phc: String) -> bool {
tokio::task::spawn_blocking(move || {
PasswordHash::new(&phc).is_ok_and(|h| Argon2::default().verify_password(password.as_bytes(), &h).is_ok())
})
.await
.unwrap_or(false)
}
async fn login(State(state): State<AppState>, session: Session, Form(form): Form<Login>) -> Redirect {
let Some((user_id, phc)) = state.users.get(&form.username).cloned() else {
return Redirect::to("/login?error=1"); // see Password Authentication for the timing-safe version
};
if !verify(form.password, phc).await {
return Redirect::to("/login?error=1");
}
// new id on every privilege change: defeats session fixation
let signed_in = SignedIn { user_id, since: OffsetDateTime::now_utc().unix_timestamp() };
if session.cycle_id().await.is_err() || session.insert(SignedIn::KEY, signed_in).await.is_err() {
return Redirect::to("/login?error=2");
}
Redirect::to("/account")
}
async fn account(session: Session) -> impl IntoResponse {
match session.get::<SignedIn>(SignedIn::KEY).await {
Ok(Some(s)) if OffsetDateTime::now_utc().unix_timestamp() - s.since < SignedIn::ABSOLUTE_TIMEOUT_SECS => {
Html(format!("<p>signed in as user {}</p>", s.user_id)).into_response()
}
Ok(Some(_)) => {
let _ = session.flush().await; // absolute timeout reached: sign out
Redirect::to("/login?expired=1").into_response()
}
_ => Redirect::to("/login").into_response(),
}
}
async fn logout(session: Session) -> Redirect {
// delete the server-side record AND the cookie
let _ = session.flush().await;
Redirect::to("/")
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// REDIS_URL is not a secret here; production URLs with a password come from a secret store
let config = Config::from_url(&std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".into()))?;
let pool = Pool::new(config, None, None, None, 4)?;
let _connection_task = pool.connect();
pool.wait_for_connect().await?;
// demo user table: production reads the PHC string from the `users` table
let password = std::env::var("DEMO_PASSWORD").expect("DEMO_PASSWORD must be set");
let phc = Argon2::default().hash_password(password.as_bytes()).expect("hash").to_string();
let state = AppState { users: Arc::new(HashMap::from([("ursula".to_owned(), (42, phc))])) };
let sessions = SessionManagerLayer::new(RedisStore::new(pool))
.with_name("__Host-bookshelf") // __Host-: Secure, Path=/, no Domain enforced by browsers
.with_secure(true)
.with_http_only(true) // the default; JavaScript cannot read the cookie
.with_same_site(SameSite::Lax) // sent on top-level navigation, not on cross-site POSTs
.with_path("/")
.with_expiry(Expiry::OnInactivity(Duration::hours(8)))
.with_always_save(true); // slide the inactivity deadline on every request
let app = Router::new()
.route("/login", post(login))
.route("/account", get(account))
.route("/logout", post(logout))
.layer(sessions)
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
Source: tower-sessions 0.14 and tower-sessions-redis-store
$ export DEMO_PASSWORD=$(openssl rand -base64 18)
$ cargo run &
$ curl -si --data-urlencode username=ursula --data-urlencode "password=$DEMO_PASSWORD" localhost:3000/login |
grep -i -E '^HTTP|location|set-cookie'
HTTP/1.1 303 See Other
location: /account
set-cookie: __Host-bookshelf=IMTM5q9pBSX2ErTxcENx7Q; HttpOnly; SameSite=Lax; Secure; Path=/; Max-Age=28800
$ curl -s -H 'cookie: __Host-bookshelf=IMTM5q9pBSX2ErTxcENx7Q' localhost:3000/account
<p>signed in as user 42</p>
$ redis-cli --scan; redis-cli ttl IMTM5q9pBSX2ErTxcENx7Q
IMTM5q9pBSX2ErTxcENx7Q
28799
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'cookie: __Host-bookshelf=IMTM5q9pBSX2ErTxcENx7Q' -X POST localhost:3000/logout
303
$ redis-cli --scan | wc -l
0
Source: tower-sessions: Session::flush and Redis: SCAN
How it works:
-
The cookie contains only a random 128-bit id; all data stays in Redis, so it can hold more than a cookie and can be revoked server-side (delete the record). The Redis key is the id itself, with an
EXATexpiry set from the record. -
Session::getandinserttake anyserdetype. Keep one typed struct per concern (SignedInabove) rather than many loose keys: a rename then becomes a compile error, and an old record that no longer deserialises returnsErrinstead of half-filled data — treat that as "not signed in". -
Expiry::OnInactivity(d)expires the sessiondafter the last save;Expiry::AtDateTimesets an absolute deadline;Expiry::OnSessionEndmakes a browser-session cookie (the stored record still gets a two-week expiry). Withoutwith_always_save(true)an unmodified session is not saved, so "inactivity" would really mean "time since the last change". The example therefore enables it, and pays one store write per request for it. -
OWASP recommends both an idle and an absolute timeout. tower-sessions has one expiry per session, so the absolute limit is enforced in code, from the
sincefield. -
session.clear()empties the data but keeps the record and id;session.flush()also deletes the record and removes the cookie;session.delete()deletes the record only. Log out withflush.
Choosing a store
| Store | Use it for | Watch out for |
|---|---|---|
|
tests and single-process development |
lost on restart; not shared between replicas; grows until records expire |
|
Bookshelf's production store; many replicas |
Redis must be
persistent enough for your logout semantics; |
|
a SQL database you already run |
depends on sqlx 0.8: on sqlx 0.9 it pulls a
second sqlx and a second pool; run its |
|
an in-process cache in front of a remote store |
the cache is per replica, so a logout on one replica is seen late by the others |
your own |
reusing an existing pool or database (below) |
|
A Postgres store on the application’s sqlx 0.9 pool
Because the official sqlx store is still on sqlx 0.8, Bookshelf's Postgres variant implements the four methods of
SessionStore itself (the trait uses async_trait). The record’s data is a HashMap of JSON values, so it maps
naturally onto a jsonb column. Besides tower-sessions 0.14 it needs async-trait = "0.1", serde_json and sqlx 0.9
with the postgres, json and time features:
use async_trait::async_trait;
use axum::{Router, routing::get};
use sqlx::{PgPool, types::Json};
use time::OffsetDateTime;
use tower_sessions::{
Session, SessionManagerLayer, SessionStore,
session::{Id, Record},
session_store,
};
/// tower-sessions store on the application's own sqlx 0.9 pool (no second sqlx in the build).
#[derive(Debug, Clone)]
struct PgSessionStore {
pool: PgPool,
}
type Data = std::collections::HashMap<String, serde_json::Value>;
fn backend(err: sqlx::Error) -> session_store::Error {
session_store::Error::Backend(err.to_string())
}
#[async_trait]
impl SessionStore for PgSessionStore {
async fn create(&self, record: &mut Record) -> session_store::Result<()> {
// `create` must never overwrite another session: on an id collision, draw a new id
loop {
let inserted = sqlx::query(
"insert into sessions (id, data, expiry_date) values ($1, $2, $3) on conflict (id) do nothing",
)
.bind(record.id.to_string())
.bind(Json(&record.data))
.bind(record.expiry_date)
.execute(&self.pool)
.await
.map_err(backend)?;
if inserted.rows_affected() == 1 {
return Ok(());
}
record.id = Id::default();
}
}
async fn save(&self, record: &Record) -> session_store::Result<()> {
sqlx::query(
"insert into sessions (id, data, expiry_date) values ($1, $2, $3)
on conflict (id) do update set data = excluded.data, expiry_date = excluded.expiry_date",
)
.bind(record.id.to_string())
.bind(Json(&record.data))
.bind(record.expiry_date)
.execute(&self.pool)
.await
.map_err(backend)?;
Ok(())
}
async fn load(&self, id: &Id) -> session_store::Result<Option<Record>> {
let row: Option<(Json<Data>, OffsetDateTime)> =
sqlx::query_as("select data, expiry_date from sessions where id = $1 and expiry_date > now()")
.bind(id.to_string())
.fetch_optional(&self.pool)
.await
.map_err(backend)?;
Ok(row.map(|(Json(data), expiry_date)| Record { id: *id, data, expiry_date }))
}
async fn delete(&self, id: &Id) -> session_store::Result<()> {
sqlx::query("delete from sessions where id = $1")
.bind(id.to_string())
.execute(&self.pool)
.await
.map_err(backend)?;
Ok(())
}
}
async fn visits(session: Session) -> String {
let n: u32 = session.get("visits").await.ok().flatten().unwrap_or(0) + 1;
let _ = session.insert("visits", n).await;
format!("visit {n}\n")
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
// normally a migration (see Migrations & Transactions)
sqlx::query(
"create table if not exists sessions (
id text primary key, data jsonb not null, expiry_date timestamptz not null)",
)
.execute(&pool)
.await?;
// expired rows are invisible to `load` but still take space: purge them periodically
let purge = pool.clone();
tokio::spawn(async move {
let mut every = tokio::time::interval(std::time::Duration::from_secs(600));
loop {
every.tick().await;
if let Err(err) = sqlx::query("delete from sessions where expiry_date < now()").execute(&purge).await {
tracing::warn!(%err, "session purge failed");
}
}
});
let sessions = SessionManagerLayer::new(PgSessionStore { pool }).with_secure(false); // dev: plain HTTP
let app = Router::new().route("/visits", get(visits)).layer(sessions);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
$ curl -s -c jar -b jar localhost:3000/visits; curl -s -c jar -b jar localhost:3000/visits
visit 1
visit 2
$ psql "$DATABASE_URL" -c 'select id, data, expiry_date from sessions'
id | data | expiry_date
------------------------+---------------+-------------------------------
SaNlyD3_AaOXSnvSTbPAoQ | {"visits": 2} | 2026-10-21 09:26:09.535418+00
(1 row)
Source: PostgreSQL 16: JSON types
-
createis called for a new session and aftercycle_id; tower-sessions 0.12 split it fromsaveso that a store can refuse to overwrite an existing id (a session takeover, however unlikely with 128 random bits).on conflict do nothingplus a retry with a freshIdis the race-free way to do that in Postgres. -
loadfilters out expired rows itself; the purge task only reclaims space. The official store does the same withcontinuously_delete_expired. -
The store errors become
500responses (see the lifecycle above). Instrument the queries like any other database access (see Databases with sqlx).
Cookie attributes
| Attribute | Why |
|---|---|
|
JavaScript cannot read the cookie, so an XSS bug cannot steal the session |
|
sent only over HTTPS; required for |
|
not sent on cross-site sub-requests or cross-site |
|
browsers only accept the cookie if it is |
|
persistence; omit for a cookie that dies with the browser session |
For local development over plain HTTP, __Host- and Secure cookies are rejected by browsers except on
http://localhost in most of them; drive both settings from configuration rather than commenting them out.
Session fixation and logout
In a session fixation attack, the attacker plants a known session ID in the victim’s browser (through a subdomain or
a URL parameter) and waits for the victim to log in, after which the attacker’s copy of the ID is authenticated.
Calling session.cycle_id() when privileges change — login, logout, role change, password change — issues a new ID
and keeps the data, which defeats it. session.flush() deletes the record and clears the cookie; use it on logout
rather than just removing the user key. axum-login does both automatically (see
Password Authentication).
Client-side state: signed and private cookie jars
For small, non-sensitive state that does not justify a store — the last visited shelf, a "dismissed banner" flag, the
OAuth state parameter during a login redirect — axum-extra’s SignedCookieJar (tamper-evident) and
PrivateCookieJar (encrypted) keep it in the cookie itself. They are shown on axum-extra
and used for the PKCE verifier on OAuth2 & OpenID Connect. Cookie
state cannot be revoked server-side and is limited to about 4 KB, so authentication sessions belong in a store.
Testing sessions
MemoryStore makes session tests self-contained: build the router once (so all requests share the store), send
requests with tower::ServiceExt::oneshot, copy the Set-Cookie value into the next request’s Cookie header, and
assert on what the session does — here, that login replaces a planted id:
use axum::{
Router,
body::Body,
http::{Request, header},
routing::{get, post},
};
use tower::ServiceExt; // for `oneshot`
use tower_sessions::{MemoryStore, Session, SessionManagerLayer};
async fn sign_in(session: Session) -> &'static str {
session.cycle_id().await.expect("cycle id");
session.insert("user_id", 42_i64).await.expect("insert");
"ok"
}
async fn whoami(session: Session) -> String {
format!("{:?}", session.get::<i64>("user_id").await.expect("load"))
}
fn app() -> Router {
let layer = SessionManagerLayer::new(MemoryStore::default()).with_secure(false);
Router::new().route("/sign-in", post(sign_in)).route("/whoami", get(whoami)).layer(layer)
}
/// The `name=value` part of the response's Set-Cookie header.
fn session_cookie(res: &axum::response::Response) -> String {
let set_cookie = res.headers()[header::SET_COOKIE].to_str().expect("ascii");
set_cookie.split(';').next().expect("name=value").to_owned()
}
async fn body(res: axum::response::Response) -> String {
let bytes = axum::body::to_bytes(res.into_body(), 1024).await.expect("body");
String::from_utf8(bytes.to_vec()).expect("utf-8")
}
#[tokio::main]
async fn main() {
let app = app(); // one Router, so one MemoryStore, shared by every request below
// an attacker-planted (well-formed) session id: login must not keep it
let planted = "id=AAAAAAAAAAAAAAAAAAAAAA";
let req = Request::post("/sign-in").header(header::COOKIE, planted).body(Body::empty()).unwrap();
let res = app.clone().oneshot(req).await.unwrap();
let cookie = session_cookie(&res);
assert_ne!(cookie, planted, "session id must change on login");
let req = Request::get("/whoami").header(header::COOKIE, &cookie).body(Body::empty()).unwrap();
assert_eq!(body(app.clone().oneshot(req).await.unwrap()).await, "Some(42)");
let req = Request::get("/whoami").body(Body::empty()).unwrap(); // no cookie: anonymous
assert_eq!(body(app.oneshot(req).await.unwrap()).await, "None");
println!("session tests passed ({cookie})");
}
Source: tower: ServiceExt::oneshot and axum example: testing
$ cargo run --example session_tests
session tests passed (id=7VtwP5NxuvzKqAezA5Gweg)
Source: cargo run
The same assertions fit in #[tokio::test] functions; Testing shows axum-test, whose
TestServer can keep cookies between requests for you.
Common errors and pitfalls
| Symptom | Cause and fix |
|---|---|
|
The route is not inside the session layer: it was added after |
The trait |
Two tower-sessions versions in the build (0.14 for the store, 0.15 for the layer). Pin |
The login works with |
|
Users are signed out after exactly 8 hours although they were active |
|
Session data written by a handler is lost |
The handler returned a |
Random |
The store failed while saving after the handler ran; the layer logs "failed to save session" at |
"possibly suspicious activity: malformed session id" in the logs |
A cookie value that is not a valid id: a cookie from an older application on the same host and name, or tampering. It is treated as no session; rename the cookie if an old one keeps colliding. |
Logout does not take effect on other tabs |
|
Where the books are outdated
Yoon (Beginning Axum, chapter 4) authenticates with JWTs only and does not cover server-side sessions. Material
written for tower-sessions 0.9 or earlier enables stores through feature flags of the main crate; since 0.10 the
stores are separate crates (tower-sessions-memory-store, -redis-store, -sqlx-store).
And as noted above, the newest tower-sessions (0.15) is not yet usable with axum-login or the official stores.