seshcookie 0.1.0

Stateless, encrypted, type-safe session cookies for Rust web applications.
# seshcookie

[![crates.io](https://img.shields.io/crates/v/seshcookie)](https://crates.io/crates/seshcookie)
[![docs.rs](https://img.shields.io/docsrs/seshcookie)](https://docs.rs/seshcookie)
[![CI](https://github.com/bpowers/seshcookie-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/bpowers/seshcookie-rs/actions/workflows/ci.yml)
[![License](https://img.shields.io/crates/l/seshcookie)](https://github.com/bpowers/seshcookie-rs/blob/main/LICENSE)
[![MSRV](https://img.shields.io/badge/MSRV-1.95-informational)](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

| 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:

```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

| 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](https://github.com/bpowers/seshcookie-rs/blob/main/LICENSE).