Session
The Session capability answers one question: where does the proof of a request's identity live, and how do we read it back on the next request? A SessionPlugin is a store — create a session, read it by id, update it, delete it, touch it to keep it alive. The trait is deliberately storage-agnostic: the same shape covers a stateless encrypted cookie and a Redis cluster sitting behind a connection pool. What it does not do is decide who the user is — that is the Auth capability's job, and the split is deliberate.
This page covers the trait surface, the two families of backend it accommodates, why mutating a session sometimes changes its id, and how the two reference plugins — @bext/session-cookie and @bext/session-redis — exercise the shape.
The trait
Every Session plugin implements one trait, SessionPlugin, defined in bext-plugin-api::session:
pub trait SessionPlugin: Send + Sync {
fn name(&self) -> &str;
fn create(&self, opts: CreateOptions) -> Result<SessionId, SessionError>;
fn read(&self, id: &SessionId) -> Result<SessionRecord, SessionError>;
fn update(&self, id: &SessionId, data_json: &str) -> Result<SessionId, SessionError>;
fn touch(&self, id: &SessionId, ttl_ms: u64) -> Result<SessionId, SessionError>;
fn delete(&self, id: &SessionId) -> Result<(), SessionError>;
fn is_healthy(&self) -> bool { true }
}
The five data methods are all sync — matching every other trait in bext-plugin-api — so the shape travels cleanly across the WASM, QuickJS, and nsjail sandboxes that plugins can run inside. Backends that need async I/O (a network round-trip to Redis, for example) either use a blocking client or block on a runtime handle the same way the rest of the plugin system does.
SessionId, SessionRecord, CreateOptions
The supporting data types are intentionally small:
pub struct SessionId(pub String);
pub struct SessionRecord {
pub id: SessionId,
pub user_id: Option,
pub data_json: String,
pub created_at_ms: u64,
pub expires_at_ms: u64,
}
pub struct CreateOptions {
pub user_id: Option,
pub data_json: String,
pub ttl_ms: u64,
}
A few deliberate choices:
- SessionId is opaque. The host treats it as a black-box string that gets written into the session cookie. Backends decide what the string actually is — see the next section on the two families.
- user_id is optional. Anonymous sessions are first-class. A guest shopping cart, a pre-login CSRF token, a newsletter preference page — all of these need session state without a logged-in user, and an AuthPlugin that requires a user enforces that separately at its own layer.
- data_json is a JSON string, not a struct. The schema inside the blob is whatever the caller decides. An Auth plugin typically stores a user id plus provider-specific claims; an app might store cart state; a plugin might store feature-flag overrides. The trait surface does not force a shape on any of them, and keeping the ABI flat means the same data round-trips through the WASM boundary without any serde glue.
SessionError
Errors are classified into three cases:
pub enum SessionError {
NotFound,
Invalid(String),
Backend(String),
}
- NotFound is "the id was valid in shape but the record is gone or expired."
- Invalid is "the id failed integrity checks" — a tampered cookie, a malformed stored record, an unparseable envelope. Surfacing this as its own variant matters because it is the one case where the runtime should log loudly and then treat the request as anonymous, rather than silently re-issuing a session on top of a bad one.
- Backend is everything else: a redis connection refused, a disk-full error, a serialization failure inside the store. The wrapped string is for operator-facing logs, not end users.
Two backend families
The trait shape covers two quite different storage models, and the distinction matters for understanding why the API looks the way it does.
Server-backed stores
Examples: @bext/session-redis, @bext/session-pg, @bext/session-dynamodb.
The SessionId is a short random token — a ULID, a UUID, a 128-bit Base32 string — that serves as a lookup key into an external store. The cookie that ships to the browser carries only this id; the actual session data lives server-side. Properties:
- create generates a fresh id and writes a record; update overwrites that record and returns the same id.
- touch is a cheap TTL bump — on Redis, a single EXPIRE call.
- delete removes the record from the store. The next request that presents the id gets NotFound.
- The id is stable across the session's whole lifetime. The cookie gets set exactly once (or rotated on auth-event boundaries, which is an app-layer decision).
Server-backed stores trade off the network round-trip on every request against the ability to revoke sessions instantly, to store more data than a cookie can hold, and to inspect session state operationally.
Client-backed stores
Examples: @bext/session-cookie.
The SessionId is the session — specifically, it is the encrypted, authenticated session payload, base64-encoded, ready to drop straight into a cookie. There is no server state. Properties:
- create encrypts an envelope containing the user id, data, and expiry, and returns the ciphertext as the id.
- update re-encrypts the envelope with the new data and returns a new id. The ciphertext has changed, so the cookie must be re-set.
- touch re-encrypts with a refreshed expiry — again, a new id.
- delete is a no-op from the store's perspective. The host is expected to clear the cookie; the store has no state to clean up.
- Integrity is enforced by AEAD (AES-GCM or ChaCha20-Poly1305). A tampered payload fails decryption and surfaces as SessionError::Invalid.
Client-backed stores trade off revocation (you cannot invalidate a session before it expires, short of rotating the signing key) and size (every request carries the entire session payload in the cookie) against zero round-trips and no external dependency.
Why update and touch return a new id
This is the single most important part of the trait contract: callers MUST use the id returned by create, update, and touch, not the one they passed in. For server-backed stores the returned id is the same as the input, so the rule is a no-op. For cookie-backed stores the returned id is a freshly-encrypted ciphertext, and assuming the old id is still valid breaks the store.
A trait that baked in "the id is stable across updates" would exclude cookie-only stores. A trait that baked in "the session is always a self-contained encrypted blob in a cookie" would exclude Redis. Making update and touch return SessionId is the one bit of API surface that lets both families share the same shape.
Reference plugins
Two reference implementations ship with the foundational wave, and between them they cover both families end to end.
@bext/session-cookie
CookieSessionPlugin takes a 32-byte key at construction time and uses it to drive AES-256-GCM over the session envelope. The constructor is intentionally blunt:
use bext_session_cookie::CookieSessionPlugin;
let key: [u8; 32] = derive_from_configured_secret();
let plugin = CookieSessionPlugin::new(key);
Key derivation (typically HKDF over a configured secret loaded from the secret store) is the caller's responsibility — the plugin itself just wants raw key material. Every mutation generates a fresh random nonce; the wire format is base64url(nonce || ciphertext || tag). Tampering any byte fails AEAD verification and surfaces as SessionError::Invalid. Expired envelopes surface as SessionError::NotFound, even though the ciphertext is still technically valid — the plugin checks the embedded expires_at_ms on every read, update, and touch.
@bext/session-redis
RedisSessionPlugin opens a redis::Client connection on each call (production deployments should put a pool in front of this; the reference impl is intentionally straightforward). Session ids are UUIDv4 strings; records are stored under session:<id> with an expiring TTL that matches the session lifetime.
use bext_session_redis::RedisSessionPlugin;
let plugin = RedisSessionPlugin::connect("redis://127.0.0.1:6379")?;
create does a SET key value EX ttl; read does a GET; update preserves the id, re-serializes the record, and re-writes it; touch bumps the expiry by re-writing with a new expires_at_ms; delete does a DEL. is_healthy pings the connection, so the runtime can drop the plugin out of rotation if Redis goes away.
The integration tests for the Redis plugin are marked #[ignore] by default because they need a running Redis. Run them with:
REDIS_URL=redis://127.0.0.1:6379 cargo test -p bext-session-redis -- --ignored
Configuring Session in bext.config.toml
You never call a session plugin directly from application code. You install it, point [session] at it in config, and the runtime does the wiring:
[session]
backend = "cookie"
[session.cookie]
# 32-byte key, base64-encoded. Derive in production; this is the dev shape.
key = "env:BEXT_SESSION_COOKIE_KEY"
# Optional: override the cookie name
cookie_name = "bext.session"
ttl_ms = 604_800_000 # 7 days
# Or, for the Redis backend:
# [session]
# backend = "redis"
#
# [session.redis]
# url = "env:REDIS_URL"
# ttl_ms = 604_800_000
[session] picks the active backend; [session.<name>] carries the plugin-specific settings. Other plugins declare requires_capabilities = ["session"] in their manifest and read session state through the host-function API — no plugin ever touches a cookie or a Redis connection directly.
Session and Auth ship together
The Auth capability decides who the caller is; Session decides where to put the proof. They are two capabilities instead of one because the combinations matter: JWT auth with a Redis session store, magic-link auth with cookie-only sessions, passkey auth with Postgres. An Auth plugin that bundled its own session store would preclude all of those.
At runtime an interactive login flow looks like this:
1. The user hits a protected route without a session. The Auth plugin's begin_login kicks off the flow.
2. The user comes back via the callback route. Auth's complete_login returns an AuthUser.
3. The runtime hands the user to the active SessionPlugin via create, which returns a SessionId. The id goes in the session cookie.
4. On subsequent requests the runtime reads session_id from the request, calls SessionPlugin::read, and passes the record into Auth's resolve.
5. On logout the runtime calls SessionPlugin::delete and Auth's logout for any provider-side cleanup (OAuth token revoke, magic-link one-time-use marking).
The Auth plugin never persists session state directly, and the Session plugin never knows what an AuthUser looks like. That is what the phrase "capabilities compose" actually means in practice.
Where to go next
- Capabilities overview — how the capability promotion ladder works, and why the foundational five ship together.
- Auth capability — the other half of the login story, and the reason Session exists as its own shape.
- Plugin system overview — the sandbox tiers (WASM, QuickJS, nsjail) a Session plugin can run inside.
- Building a plugin — scaffolding a plugin and declaring provides_capabilities = ["session"] in the manifest.