seshcookie 0.1.0

Stateless, encrypted, type-safe session cookies for Rust web applications.
docs.rs failed to build seshcookie-0.1.0
Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.

seshcookie

crates.io docs.rs CI License MSRV

Stateless, encrypted, typed session cookies for Axum + Tower applications.

seshcookie stores the entire session payload inside a single authenticated cookie - no database, no Redis, no shared state. A user-supplied secret is stretched to a ChaCha20-Poly1305 key via HKDF-SHA256, and the sealed cookie includes an authenticated issued_at timestamp so server-side expiry cannot be forged or extended by the client. Multi-key rotation is built in: deploy a new primary key alongside the old one as a fallback, and active sessions migrate to the new key automatically on their next request.

Install

[dependencies]
seshcookie = "0.1"
axum       = "0.8"
serde      = { version = "1", features = ["derive"] }
tokio      = { version = "1", features = ["macros", "rt-multi-thread"] }

Quickstart

use axum::{routing::{get, post}, Router};
use seshcookie::{Session, SessionConfig, SessionKeys, SessionLayer};
use serde::{Deserialize, Serialize};

#[derive(Clone, Serialize, Deserialize)]
struct User { id: u64, name: String }

async fn whoami(session: Option<Session<User>>) -> String {
    match session {
        Some(s) => s.get().await.map(|u| u.name).unwrap_or_else(|| "anon".into()),
        None => "no session layer".into(),
    }
}

async fn login(session: Session<User>) {
    session.insert(User { id: 1, name: "alice".into() }).await;
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let secret = std::env::var("SESSION_SECRET").expect("SESSION_SECRET must be at least 16 bytes");
    let keys = SessionKeys::new(secret.as_bytes())?;
    let layer = SessionLayer::<User>::new(keys, SessionConfig::default())?;

    let app = Router::new()
        .route("/whoami", get(whoami))
        .route("/login", post(login))
        .layer(layer);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
    axum::serve(listener, app).await?;
    Ok(())
}

Key rotation

Rotate a key without invalidating active sessions by listing the new key as primary and the old as a fallback. Active sessions decrypt under the fallback and silently re-encrypt under the primary on their next request - within one max_age window, every active session has migrated.

# use seshcookie::SessionKeys;
# fn ex() -> Result<(), seshcookie::BuildError> {
let new_key = std::env::var("SESSION_KEY_V2").unwrap();
let old_key = std::env::var("SESSION_KEY_V1").unwrap();

let keys = SessionKeys::new(new_key.as_bytes())?
    .with_fallback(old_key.as_bytes())?;
# Ok(()) }

For multiple generations of fallbacks:

# use seshcookie::SessionKeys;
# fn ex() -> Result<(), seshcookie::BuildError> {
# let primary = [0u8; 32];
# let older_1 = [1u8; 32];
# let older_2 = [2u8; 32];
let keys = SessionKeys::new(&primary)?
    .with_fallbacks([&older_1[..], &older_2[..]])?;
# Ok(()) }

Rotation schedule:

  1. Deploy with new as primary and old as fallback.
  2. Wait for one max_age to pass (default 24h) - all active sessions auto-migrate.
  3. Deploy with only new as primary; drop old entirely.

The authenticated issued_at inside each cookie is preserved across rotation: migrating to a new encryption key does not reset the session's age or extend its lifetime.

Configuration reference

Setter Default Purpose
cookie_name(name) "session" Cookie name. Distinct layers need distinct names.
path(path) "/" Path attribute on the emitted cookie.
domain(host) / no_domain() host-scoped Domain attribute, or omit for host-scoped.
max_age(d) 24h Server-side session lifetime. issued_at + max_age < now means expired.
secure(bool) true Secure attribute. Set false only for local HTTP development.
http_only(bool) true HttpOnly attribute. Prevents JS access to the cookie.
same_site(SameSite) Lax SameSite attribute. Use Strict for CSRF-sensitive flows.
refresh_after(Option<Duration>) None Opt-in sliding-refresh threshold (see below).

Sliding refresh

By default sessions do not extend their lifetime. Enable sliding refresh to re-issue the cookie with a fresh issued_at after a configurable threshold, keeping active users logged in while letting idle sessions expire:

use seshcookie::SessionConfig;
use std::time::Duration;

let config = SessionConfig::default()
    .max_age(Duration::from_secs(24 * 60 * 60))
    .refresh_after(Some(Duration::from_secs(60 * 60))); // refresh after 1 hour of activity

With refresh_after(Some(1h)) and max_age(24h), a request received two hours after the session was issued produces a Set-Cookie with issued_at = now and the same payload - the session's effective expiry is pushed back by the fresh issued_at. A request 25 hours after issue is rejected as expired even with sliding refresh enabled (past-max-age takes precedence).

Threat model

Protected against:

  • Cookie confidentiality. Captured cookies cannot be read without the encryption key.
  • Cookie integrity. Any tampering fails AEAD authentication; the server treats the cookie as absent.
  • Session-lifetime forgery. issued_at is authenticated - a client cannot backdate or extend their own session.
  • Format-version downgrade. The format version byte lives inside the AEAD plaintext; a future v2 server cannot be tricked into v1 parsing by byte-level replay.

Not protected against (consumer responsibilities):

  • XSS cookie exfiltration. HttpOnly mitigates, but seshcookie cannot prevent XSS in the application itself.
  • Replay of a stolen valid cookie within max_age. Any valid cookie is honored. Consumers requiring revocation should add a generation-counter field to their typed payload and verify it against a server-side value in their auth middleware.
  • CSRF. SameSite=Lax default mitigates naive CSRF. Use SameSite=Strict or layer CSRF tokens for stronger protection.
  • Oversize payloads. Browsers cap cookies at ~4 KB. seshcookie does not split payloads. Keep session payloads compact (IDs and claims, not full profiles).

Notes on the session payload type T

The response path suppresses no-op cookie rewrites by SHA-256-comparing the candidate serialized payload against the one decrypted from the incoming cookie. For this comparison to work reliably, serde_json must produce byte-identical output for equal values of T on every serialization.

  • Standard types work fine: structs (derived Serialize), Vec, Option, nested enums, primitives, String, BTreeMap, BTreeSet, [T; N].
  • Avoid HashMap / HashSet inside T. Their iteration order is non-deterministic across process restarts, which can cause the hash-compare to miss and produce spurious Set-Cookie emissions on otherwise-read-only handlers. Use BTreeMap / BTreeSet for key-value or set fields in session payloads.
  • Float fields may round-trip differently under some serde_json flags; prefer integer or string representations of money, time, or similar precise values.

Comparison with related crates

Crate Model Crypto Types Rotation Sliding refresh
seshcookie Stateless (payload in cookie) ChaCha20-Poly1305 via ring Generic over T Built-in, auto-migrate Opt-in (refresh_after)
biscotti Cookie-level crypto primitives (no session layer) AES-GCM via cookie::PrivateJar Untyped Manual via key list Not applicable
tower-sessions Session-ID in cookie + pluggable backing store N/A (ID only) Untyped map Implicit via new ID Backing-store policy

Pick seshcookie for stateless, strongly-typed sessions with zero server-side storage. Pick biscotti for cookie-level crypto without a session abstraction. Pick tower-sessions for stateful sessions with server-side storage.

MSRV

1.95 (edition 2024).

License

MIT. See LICENSE.