# seshcookie
[](https://crates.io/crates/seshcookie)
[](https://docs.rs/seshcookie)
[](https://github.com/bpowers/seshcookie-rs/actions/workflows/ci.yml)
[](https://github.com/bpowers/seshcookie-rs/blob/main/LICENSE)
[](https://crates.io/crates/seshcookie)
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
```toml
[dependencies]
seshcookie = "0.1"
axum = "0.8"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
```
## Quickstart
```rust,no_run
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.
```rust
# 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:
```rust
# 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
| `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:
```rust
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
| `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](https://github.com/bpowers/seshcookie-rs/blob/main/LICENSE).