Session storage
X511 stores three kinds of values during the verification flow:
| Key | Holds | Written when | TTL |
|---|---|---|---|
session:<sessionId> | Pending session marker, then the access token | verified() fires for an unknown user; provider calls permit() | pendingTtl → then 60s |
uid:<sessionId> / uid:<claimId> | Hashed unique identifier from a provider | provider passes a uniqueId to permit(); copied to claimId on claim | 60s, then mode.ttl (or 60s for one-shot) |
claim:<claimId> | Random claim token paired with the cookie | /claim exchange succeeds | mode.ttl (or 60s for one-shot) |
These records live in a SessionAdapter. The default is in-process memory; production deployments should swap in an external store.
Key prefixes
Keys passed to the adapter are already namespaced by X511 (e.g. session:abc123, claim:def456, uid:abc123). Use them as-is, or add a non-colliding prefix of your own (e.g. x511:).
SessionAdapter
interface SessionAdapter {
has(key: string): Promise<boolean>
get(key: string): Promise<string | undefined>
set(key: string, value?: string, ttlSeconds?: number): Promise<void>
consume(key: string): Promise<string | undefined>
}has(key)— returnstrueif the key exists and has not expired. Used by the SSE endpoint to detect a missing or expired session before attempting to read.get(key)— reads the current value (orundefinedif missing/expired). The value may beundefinedeven when the key exists — pending sessions are stored asset(key, undefined, pendingTtl).set(key, value?, ttlSeconds?)— stores or overwrites a value.valuemay beundefined(used to mark pending sessions). WhenttlSecondsisundefined, the entry must persist until something deletes it; X511 always passes an explicit TTL.consume(key)— atomic read-and-delete. Returns the value if present and removes the entry. Used at the moment of claim exchange and (inone-shotmode) on the first verified request.
Honor the TTL
X511 relies on the adapter to expire keys. If your backend ignores the ttlSeconds argument, pending sessions and verified claims will pile up indefinitely.
Atomicity of consume
For external storage backends (Redis, databases), implement consume as an atomic read-and-delete operation (e.g. Redis GETDEL, SQL DELETE … RETURNING) to prevent race conditions where two requests consume the same session.
MemorySessionAdapter
class MemorySessionAdapter implements SessionAdapterThe default adapter. Stores entries in a Map and lazily evicts expired ones on read. Suitable for development and single-instance deployments.
It is unsuitable when:
- Sessions need to survive a process restart.
- Multiple server instances need to share state.
- You need any kind of observability into pending claims.
To use it explicitly:
import { MemorySessionAdapter } from 'x511/session'
const { verify, verified } = x511({
// ...
sessionAdapter: new MemorySessionAdapter(),
})Building a Redis adapter
SessionAdapter is small enough to implement against any KV store. The Redis sketch below uses node-redis-style methods (exists, get, set, getDel):
import { x511, self } from 'x511/hono'
import type { SessionAdapter } from 'x511/session'
class RedisSessionAdapter implements SessionAdapter {
constructor(private redis: RedisClient) {}
async has(key: string) {
return (await this.redis.exists(key)) > 0
}
async get(key: string) {
return (await this.redis.get(key)) ?? undefined
}
async set(key: string, value?: string, ttlSeconds?: number) {
if (ttlSeconds !== undefined) {
await this.redis.set(key, value ?? '', 'EX', ttlSeconds)
} else {
await this.redis.set(key, value ?? '')
}
}
async consume(key: string) {
const value = await this.redis.getDel(key)
return value ?? undefined
}
}
const { verify, verified } = x511({
domain: 'https://your-app.example.com',
disclosures: { minAge: 18 },
mode: { type: 'session', ttl: 1800 },
providers: [self({ scope: 'your-app' })],
sessionAdapter: new RedisSessionAdapter(redis),
})The same pattern works for SQL backends — return the deleted row from consume() using DELETE … RETURNING value.