unjwt/session: Web standard sessions
defineJWSSession() and defineJWESession() over a web-standard request event: the { req, res, context } that h3 v2, Nitro 3 and Nuxt's nuxt/server hand a handler, or one you build from a Request and the Headers its response is sent with. A bare Request reads, and a token you already hold opens. No framework, no peer dependency.
import { defineJWESession, generateJWK } from "unjwt/session";
const key = await generateJWK("A256GCM"); // load a persisted key in production
const useSession = defineJWESession({ key, maxAge: "7D" });
export default {
async fetch(req: Request): Promise<Response> {
const headers = new Headers({ "content-type": "application/json" });
const session = await useSession.use({ req, res: { headers } });
if (new URL(req.url).pathname === "/login") {
await session.update({ userId: "u_123" });
}
return new Response(JSON.stringify({ id: session.id ?? null, data: session.data }), {
headers,
});
},
};Set-Cookie: unjwt-jwe=eyJraWQiOiJzME1mT1h5Zkh3…; Path=/; Expires=Fri, 23 Oct 2026 20:15:12 GMT; HttpOnly; Secure; SameSite=LaxThis shape fits every runtime whose handler receives a Request and returns a Response: Cloudflare Workers, Bun, Deno, and frameworks built on them. In Hono, pass c.req.raw as req and return new Response(body, { headers }), or copy each line of headers.getSetCookie() onto the response. An h3 v2 event is already this shape, and so is the event nuxt/server hands a Nuxt handler: see Frameworks for h3, Nitro and Nuxt.
The definition has four members: use(), token(), issue() and open(), described on the definition. open() resolves to { id, createdAt, expiresAt, data }. The manager, the cookie layout, chunking, lifetime and hooks are the same as on every other entry. See Frameworks.
#Signature
type SessionRequestEvent = { res?: { headers: Headers }; context?: Record<string, unknown> } & (
| { req: Request; headers?: Headers } // an h3 v2 event, Nuxt's RequestEvent
| { headers: Headers; req?: Request } // a Request itself, a crossws upgrade request
); // no res: read-only
interface SessionHandedToken {
token: string | undefined; // a token in hand
}
type WebSessionEvent = SessionRequestEvent | SessionHandedToken; // what use() takes
function defineJWSSession<
T extends Record<string, any> = SessionClaims,
MaxAge extends ExpiresIn | undefined = ExpiresIn | undefined,
TEvent extends WebSessionEvent = WebSessionEvent,
>(config: SessionConfigJWS<T, MaxAge, TEvent>): SessionDefinition<T, MaxAge, TEvent>;
function defineJWESession<
T extends Record<string, any> = SessionClaims,
MaxAge extends ExpiresIn | undefined = ExpiresIn | undefined,
TEvent extends WebSessionEvent = WebSessionEvent,
>(config: SessionConfigJWE<T, MaxAge, TEvent>): SessionDefinition<T, MaxAge, TEvent>;use() takes a WebSessionEvent. A request event reads req first, headers otherwise, so an h3 v2 event, a Request, and a crossws 0.3 or 0.4 upgrade request all pass as they are; one with neither throws ERR_SESSION_CONFIG_INVALID. A third generic narrows the event to a framework's own type, which use() then requires and every hook receives as event: defineJWESession<Data, "7D", H3Event>(config). Both functions take a second argument, { driver }, that runs the session on a framework of your own. See Write a driver.
#A request event
use({ req, res }) reads the token from the session header when req.headers carries it, else from the Cookie. update() and clear() write Set-Cookie lines onto res.headers, and onto res.errHeaders as well when res has one, the headers h3 v2 sends for a thrown handler. A second write of the same cookie (same name, domain and path) replaces its line instead of adding another, and lines for other cookies stay as they are:
const headers = new Headers();
headers.append("set-cookie", "theme=dark; Path=/");
const session = await useSession.use({ req, res: { headers } });
await session.update({ step: 1 });
await session.update({ step: 2 });
headers.getSetCookie();
// ["theme=dark; Path=/", "unjwt-jwe=eyJ…(step 2); Path=/; …"]A long token is split into unjwt-jwe.1 … unjwt-jwe.N behind a __chunked__N marker, as on every other entry, and a shorter re-issue in the same request expires the surplus chunks.
With a context, the session state lives on context.sessions, the slot h3 itself uses, so a middleware and a handler that receive the same event share one read. Without one it is keyed on the Request object.
#Read-only: a request without a response
A Request on its own, or any object with headers and no res, reads and takes no cookie. update() throws:
const session = await useSession.use(req);
session.data; // readable
await session.update({ a: 1 });
// JWTError ERR_SESSION_READONLY_EVENT: [unjwt/session] Cannot update session on read-only event.The same goes for { req } without res. clear() empties the session for the rest of the request and resolves, so an onRead revocation check clears the same way it does with a response; the cookie waits for the next request. An expired or unreadable token is reported to onExpire or onError without its cookie being dropped, and rolling does not reissue. A session read from the session header, or one with cookie: false, has nothing to write, so its update() still re-mints the token onto session.token.
This is the WebSocket shape, and the upgrade is the last moment a cookie can reach the client: the headers an upgrade hook returns travel on the 101 handshake, and a browser applies a Set-Cookie from it. Hand use() those headers as res, and a rolling reissue, an expired cookie's drop or a clear() made during the read go out with the upgrade:
export default defineWebSocketHandler({
async upgrade(request) {
const headers = new Headers();
const session = await useSession.use({
headers: request.headers,
context: request.context,
res: { headers },
});
if (!session.id) return new Response("Sign in first", { status: 401 });
request.context.userId = session.data.userId; // what the socket needs, for every later hook
return { headers }; // empty when nothing was written
},
open(peer) {
peer.send(`hello ${peer.context.userId}`);
},
});Nothing can be written once the socket is open, so the session is read once, in upgrade, and what the socket needs is copied onto request.context, the object every later hook receives as peer.context. What the upgrade request looks like, and how the refusal and the context are returned, differ by runtime: see Frameworks.
#A token in hand
When the token does not arrive in a cookie or a header you can name (a queue message, a Bearer you parsed yourself), hand it in:
export default {
async queue(batch: MessageBatch<{ session: string }>) {
for (const message of batch.messages) {
const session = await useSession.use({ token: message.body.session });
if (!session.id) continue; // missing, expired or invalid
await session.update({ processedAt: Date.now() });
await forward(session.token); // the re-issued token
}
},
};use({ token }) runs the whole read: onRead, onExpire and onError fire as they would for a cookie, and rolling reissues. update() mints a new token onto session.token and writes nothing: sending it is your job. clear() leaves an empty session whose token is undefined. token({ token }) returns the token you handed in. use({ token: undefined }) is an empty session.
The config's cookie and sessionHeader are not consulted, so a token in hand works on a cookie session and on one with cookie: false, sessionHeader: false alike.
#Header only
For clients that cannot keep cookies, read the token from Authorization:
const apiSession = defineJWSSession({
key,
sessionHeader: "authorization", // "Bearer <token>" or the bare token
cookie: false,
});
const session = await apiSession.use(req);
await session.update({ scope: "read" });
return Response.json({ token: session.token });sessionHeader: true reads x-<name>-session. With cookies on, a request that carries the header is read from it, and the cookie only when the header is absent. A session read from the header writes no cookie: update(), rolling and clear() change session.token alone.
#Rules for use()
Request, never an object you built or share between requests. Session state is kept per event.context, else per Request object.res whose headers the response is sent with, or get a read-only session.use() before the response starts. The library cannot tell when it is too late: a write after the response was built is accepted and never sent.use({ token }). Sending the new token is your job.ERR_SESSION_CONFIG_INVALID.#Options
| Option | Type | Default | What it does |
|---|---|---|---|
key | JWK, key pair or CryptoKey | required | The session key. See Keys. |
maxAge | ExpiresIn | none | Token lifetime: seconds or "15m", "1h", "7D". Sets exp and the cookie's Expires, and ends an incoming token at iat + maxAge at the latest. Without it, tokens never expire. |
rolling | true | ExpiresIn | none | Reissues a session on read, so exp slides. true on every request, a duration once the token is that old. Needs maxAge, and a duration shorter than it. See rolling expiry. |
name | string | "unjwt-jws" / "unjwt-jwe" | Cookie name, and the base of the default header. Prefer "__Host-…". |
cookie | false | CookieAttributes & { chunkMaxLength? } | { path: "/", secure: true, sameSite: "lax", httpOnly } | Cookie attributes. httpOnly defaults to false for JWS and true for JWE. chunkMaxLength defaults to 4000. false reads the header only. No encode: a token is base64url and needs none. |
sessionHeader | boolean | string | x-<name>-session | Header read before the cookie: a header name, or true for x-<name>-session. Accepts a bare token or Bearer <token>; any other value is a token failure. A request that carries it is never read from the cookie. Left unset, a session with cookies still reads x-<name>-session, and so does one with cookie: false. |
generateId | () => string | crypto.randomUUID | Mints the jti of each new token. |
jws.signOptions | SessionSignOptions | {} | sign() options except expiresIn, expiresAt, notBeforeIn, notBeforeAt. alg pins the algorithm. protectedHeader.typ replaces "session+jwt". |
jws.verifyOptions | SessionVerifyOptions | {} | verify() options except algorithms, typ, maxTokenAge, validateClaims, payload. So issuer, audience, clockTolerance, requiredClaims and allowSymmetricJWKSet stay settable. |
jwe.encryptOptions | SessionEncryptOptions | {} | encrypt() options except the four lifetime options. alg and enc pin the algorithms. |
jwe.decryptOptions | SessionDecryptOptions | {} | decrypt() options except the ones the session owns. On a PBES2 session maxIterations defaults to the p2c the session mints. |
hooks | SessionHooksJWS / SessionHooksJWE | none | See Hooks. Each hook receives what you passed to use() as event; the key lookups get none from open(). |
CookieAttributes is { domain?, expires?, httpOnly?, maxAge?, path?, priority?, sameSite?, secure?, partitioned? }, the shape Elysia's entry uses too.
#Errors
| Code | When |
|---|---|
ERR_SESSION_KEY_INVALID | key is missing, a password string, the wrong shape, or cannot be imported. |
ERR_SESSION_CONFIG_INVALID | A bad name or sessionHeader, a __Host-/__Secure- name whose cookie options break the prefix, an event whose req is not a web Request, use(event) on a session with cookie and sessionHeader both false, an unpinned CryptoKey, a token over 16 chunks, or a JWS and a JWE session under one name. |
ERR_SESSION_READONLY_EVENT | update() on a cookie session opened without a res. |
ERR_INVALID_OPTION | update() data that is not a plain object, or a __proto__-like key in it. |
An unreadable incoming token throws nothing. It goes to onError as ERR_SESSION_TOKEN_INVALID and its cookie is dropped; an expired one goes to onExpire, its cookie dropped too. Every error message starts with [unjwt/session]. All codes are listed on Errors.
#See also
- Write a driver to run these sessions on a framework unjwt does not ship.
- The definition for
token(),issue()andopen().