axum-impl-auth-session
Overview
Session-based authentication stores NO user data in the cookie. The cookie carries only an opaque, unguessable session ID; all real state (user id, roles, CSRF token) lives server-side in a session store. This is the structural opposite of JWT, where the token IS the state and the server holds nothing.
Axum ships no session machinery. This skill covers the four stateful and delegated auth approaches and the crates that provide them:
| Approach | Crate | Use for |
|---|---|---|
| Raw sessions | tower-sessions |
session layer, no opinion on login |
| Full login system | axum-login (on tower-sessions) |
typed user, login/logout, route guards |
| HTTP Basic | axum-extra TypedHeader |
machine-to-machine over TLS |
| OAuth2 | oauth2 |
delegated identity (Google, GitHub, SSO) |
JWT (stateless bearer tokens) is a separate mechanism owned by the
axum-impl-auth-jwt skill.
Version Sensitivity
The four crates here are third-party and evolve faster than Axum. Builder method names and macro argument shapes change across releases. The verified versions for this skill are:
| Crate | Verified version |
|---|---|
tower-sessions |
0.15.0 |
axum-login |
0.18.0 |
oauth2 |
5.0.0 |
axum-extra |
current (typed-header feature) |
ALWAYS confirm every snippet below against the exact version pinned in your
Cargo.toml. Snippets are marked // version-sensitive where the API shape is
known to drift. The crates themselves are independent of the Axum 0.7 vs 0.8
split; the only Axum-version concern is that a custom extractor built around
them uses the 0.8 native-async trait form, no #[async_trait].
Quick Reference
| Need | Do this |
|---|---|
| Add a session layer | .layer(SessionManagerLayer::new(store)) |
| Read session value | session.get::<T>("key").await returns Option<T> |
| Write session value | session.insert("key", value).await |
| Destroy a session | session.delete().await or session.flush().await |
| Rotate session id on login | session.cycle_id().await |
| Full login system | axum-login AuthSession + AuthnBackend impl |
| Guard routes | .route_layer(login_required!(Backend, login_url = "/login")) |
| HTTP Basic header | TypedHeader<Authorization<Basic>> extractor |
| Delegate to Google/GitHub | oauth2 crate authorization-code flow |
tower-sessions Session method |
Effect |
|---|---|
insert(key, value) |
serde-serialize a value into the session |
get::<T>(key) |
read a value, Option<T> |
remove::<T>(key) |
drop one key |
clear() |
drop all keys, keep the session row |
cycle_id() |
issue a new session id, keep the data |
flush() / delete() |
destroy the server-side session |
Full API signatures: references/methods.md.
Decision Trees
Which auth mechanism
Authenticate a request. Pick ONE primary mechanism per route.
1. Is auth delegated to an external identity provider
(Google / GitHub / corporate SSO)?
YES -> OAuth2 (oauth2 crate, authorization-code + PKCE).
After the callback, establish a LOCAL session.
NO -> continue.
2. Is the caller a browser using a server-rendered app, OR do you
need instant revocation OR mutable server-side per-user state?
YES -> Session-based auth.
raw layer only -> tower-sessions
full login system -> axum-login on tower-sessions
NO -> continue.
3. Is the caller a machine / internal admin tool over TLS, and is a
username+password sent on every request acceptable?
YES -> HTTP Basic (axum-extra TypedHeader<Authorization<Basic>>).
TLS ONLY. Never the primary login for human browsers.
NO -> continue.
4. Is the caller a stateless API client (SPA, mobile, service) where
every request carries its own credential and tokens valid-until-exp
is acceptable?
YES -> JWT. See the axum-impl-auth-jwt skill.
Tie-breakers:
- OAuth2 is an IDENTITY mechanism, not a session mechanism. After the callback you still pick session OR JWT to carry the logged-in state. OAuth2 + session is the normal browser combination.
- Session vs JWT, the deciding question is revocation and state: need to kill a login immediately or store mutable per-user data, choose session. Need zero server state and horizontal scale with no shared store, choose JWT.
- NEVER mix two primary mechanisms on one route. One route, one auth extractor.
Raw tower-sessions or full axum-login
Do you need a typed User, login/logout helpers, and route guards?
├── YES -> axum-login. It layers on tower-sessions and handles
│ session-key bookkeeping for you.
└── NO -> tower-sessions alone. Use it for CSRF tokens, flash
messages, wizard state, a cart: any server-side state
that is not a full authentication system.
Patterns
Pattern: tower-sessions setup and session state
// version-sensitive: tower-sessions 0.15
use tower_sessions::{Expiry, MemoryStore, Session, SessionManagerLayer};
use time::Duration;
let store = MemoryStore::default(); // dev only, NEVER production
let session_layer = SessionManagerLayer::new(store)
.with_secure(true) // true behind TLS; false only for local HTTP
.with_expiry(Expiry::OnInactivity(Duration::seconds(600)));
let app = Router::new().route("/", get(handler)).layer(session_layer);
// the Session extractor is FromRequestParts, so it is just a handler argument
async fn handler(session: Session) {
let count: i32 = session.get("count").await.unwrap().unwrap_or(0);
session.insert("count", count + 1).await.unwrap();
}
ALWAYS use a persistent SessionStore in production (tower-sessions-sqlx-store
or tower-sessions-redis-store). MemoryStore loses every session on restart
and is not shared across instances.
Pattern: rotate the session id on login
// version-sensitive: tower-sessions 0.15
async fn login(session: Session, Form(creds): Form<Credentials>) -> impl IntoResponse {
let user = verify_credentials(&creds).await; // your check, constant-time hash compare
match user {
Some(user) => {
session.cycle_id().await.unwrap(); // ALWAYS rotate id on privilege change
session.insert("user_id", user.id).await.unwrap();
Redirect::to("/dashboard").into_response()
}
None => StatusCode::UNAUTHORIZED.into_response(),
}
}
async fn logout(session: Session) -> impl IntoResponse {
session.delete().await.unwrap(); // destroy server-side state
Redirect::to("/").into_response()
}
ALWAYS call cycle_id() right after a successful login. Rotating the session ID
on a privilege change defeats session-fixation attacks. axum-login's login()
rotates the ID for you.
Pattern: axum-login full login system
axum-login needs an AuthnBackend implementation and the AuthUser trait on
your user type. The full backend implementation is in references/examples.md.
Wiring:
// version-sensitive: axum-login 0.18, tower-sessions 0.15
use axum_login::{login_required, AuthManagerLayerBuilder};
use tower_sessions::{MemoryStore, SessionManagerLayer};
let session_layer = SessionManagerLayer::new(MemoryStore::default());
let backend = Backend::new(db_pool); // your AuthnBackend impl
let auth_layer = AuthManagerLayerBuilder::new(backend, session_layer).build();
let app = Router::new()
.route("/protected", get(protected_handler))
.route_layer(login_required!(Backend, login_url = "/login")) // guards routes ABOVE
.merge(public_routes)
.layer(auth_layer);
The login handler uses the AuthSession extractor directly:
// version-sensitive: axum-login 0.18
async fn login(mut auth: AuthSession<Backend>, Form(creds): Form<Credentials>)
-> impl IntoResponse
{
let user = match auth.authenticate(creds).await {
Ok(Some(user)) => user,
Ok(None) => return StatusCode::UNAUTHORIZED.into_response(),
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
};
if auth.login(&user).await.is_err() {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
Redirect::to("/protected").into_response()
}
ALWAYS apply login_required! with .route_layer(...), NEVER .layer(...).
route_layer runs only on matched routes, so an unmatched-path 404 stays a 404
instead of becoming a login redirect. Routes added AFTER the route_layer call
are not protected by it: place guarded routes above it.
Pattern: HTTP Basic auth (TLS only)
// version-sensitive on the headers re-export path
use axum_extra::TypedHeader;
use axum_extra::headers::{authorization::Basic, Authorization};
async fn admin(
TypedHeader(auth): TypedHeader<Authorization<Basic>>,
) -> Result<&'static str, Response> {
if auth.username() == "admin" && verify_hash(auth.password()) {
Ok("ok")
} else {
// 401 + WWW-Authenticate so the browser re-prompts for credentials
Err((
StatusCode::UNAUTHORIZED,
[(header::WWW_AUTHENTICATE, "Basic realm=\"admin\"")],
).into_response())
}
}
TypedHeader requires the typed-header feature on axum-extra. Basic auth
sends the password base64-encoded, NOT encrypted. ALWAYS serve Basic-protected
endpoints over TLS only. ALWAYS compare the password with a constant-time check
against a hash (argon2), NEVER with == against a plaintext literal. Use
Basic only for machine-to-machine or internal admin endpoints, NEVER as the
primary login for a human-facing browser app.
Pattern: OAuth2 authorization-code flow
OAuth2 delegates auth to an external provider; your app needs exactly two
routes. The full two-route code is in references/examples.md. The shape:
// version-sensitive: oauth2 5.0
// redirect route: build the provider URL, store csrf + pkce verifier in the session
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let (auth_url, csrf_token) = client
.authorize_url(CsrfToken::new_random)
.add_scope(Scope::new("read".to_string()))
.set_pkce_challenge(pkce_challenge)
.url();
// store csrf_token + pkce_verifier in the tower-session, then Redirect::to(auth_url)
// callback route: verify state == stored csrf_token, THEN exchange the code
let token = client
.exchange_code(AuthorizationCode::new(returned_code))
.set_pkce_verifier(pkce_verifier)
.request_async(&http_client)
.await?;
// then load/create the local user and establish a session
ALWAYS verify the returned state equals the stored CsrfToken BEFORE
exchanging the code; skipping it opens a login-CSRF hole. ALWAYS use PKCE, even
for confidential clients. NEVER hardcode client_secret; read it from the
environment and fail fast if absent.
Common Mistakes
| Mistake | Result | Fix |
|---|---|---|
MemoryStore in production |
Sessions vanish on restart, not shared across instances | Persistent SessionStore (sqlx / Redis crate) |
with_secure(false) in production |
Session cookie sent over plain HTTP, sniffable | with_secure(true) behind TLS |
No cycle_id() on login |
Session-fixation hole | Rotate id after authentication |
| Basic auth over plain HTTP | Password effectively cleartext | TLS only |
Skip OAuth2 state check |
Login-CSRF hole | Verify state == CsrfToken before code exchange |
login_required! on .layer() |
A 404 becomes an auth redirect | Use .route_layer(...) |
Hardcoded client_secret / signing key |
Credential baked into binary and git history | Read from environment |
Full anti-pattern analysis with WHY each fails: references/anti-patterns.md.
Reference Links
references/methods.md: complete API signatures fortower-sessions,axum-login,axum-extraTypedHeader, and theoauth2crate, with feature flags and verified versions.references/examples.md: full working code, including a completeAuthnBackendimplementation, the OAuth2 two-route flow, andCargo.toml.references/anti-patterns.md: the seven session-auth anti-patterns with root-cause explanations and fixes.
Related skills:
axum-impl-auth-jwt: stateless bearer-token auth, the JWT branch of the decision tree.axum-impl-authorization: role and permission checks after authentication.axum-syntax-responses:Redirect, status codes, andIntoResponse.
Verified sources (2026-05-20):