Frameworks

Cookie sessions on h3, Nitro, Nuxt, Elysia and any Web standard runtime, where the session is a signed or encrypted JWT that the server issues and reads back.

Note

:

:

Web standard sessions

unjwt/session: the request event every modern runtime hands a handler, a Request, or a token in hand. :: :
i-unjs-h3

h3

An h3 v2 app: the event as it is, a thrown handler's cookies, the WebSocket upgrade. :: :

Nitro

Nitro 3: the imports, the event, a thrown handler, the WebSocket upgrade. :: :

Nuxt

Nuxt 4.6 and 5: handlers from nuxt/server, a key from appSecret, the WebSocket upgrade. :: :

Elysia

jwsSession() and jweSession() plugins with an ambient ctx.session and a guard macro. :: :

The definition

token(), issue() and open(): the raw token, a token without a request, a token opened by hand. :: :

Hooks

onRead, onUpdate, onClear, onExpire, onError and the key lookup hooks. :: :

Write a driver

Run the same sessions on a framework unjwt does not ship. :: :

h3 adapters

unjwt/adapters/h3 and h3v1, deprecated: what they are until 0.9 removes them. :: ::

RuntimeThe event use() takesHandlers and helpers come fromPage
h3 v2, a new H3() appthe H3Eventh3h3
Nitro 3the H3Eventnitro, nitro/h3Nitro
Nuxt 4.6+ on Nitro 2the RequestEvent a nuxt/server handler receivesnuxt/serverNuxt
Nuxt 5 on Nitro 3the same RequestEventnuxt/serverNuxt
Elysiathe handler contextelysiaElysia
Workers, Bun, Deno, Hono{ req, res: { headers } } you buildthe runtimeWeb standard sessions

#How they fit together

import { H3 } from "h3";
import { defineJWSSession, generateJWK } from "unjwt/session";

const key = await generateJWK("HS256"); // load a persisted key in production
const useSession = defineJWSSession({ key, maxAge: "1h" });

export const app = new H3()
  .post("/login", async (event) => {
    const session = await useSession.use(event);
    session.id; // undefined
    await session.update({ userId: "u_123", role: "admin", jti: "mine", exp: 1 });
    return { id: session.id }; // { id: "ea346d56-…" }
  })
  .get("/me", async (event) => {
    const session = await useSession.use(event);
    return { id: session.id, data: session.data };
  });

A session is defined once, at module scope, and use() opens it on each request. Every entry's definition has the same four methods: use() returns the session manager below, token() the raw token the request carries, issue(data) mints a token without a request, and open(token) reads one back. On Elysia the definition also has plugin(), which jwsSession() and jweSession() return. See the definition and, for plugin(), Elysia.

unjwt/session runs the same definitions on a web-standard request event: use({ req, res }) reads the Request and writes Set-Cookie onto res.headers, the shape h3 v2 and Nuxt's nuxt/server hand a handler; use(request) reads alone, and use({ token }) opens a token you already hold. Its defineSessionDriver() and { driver } option bind the session core to any other framework. See Web standard sessions and Write a driver.

POST /login answers with one cookie:

Set-Cookie: unjwt-jws=eyJraWQiOiJyczF1aDBx…; Path=/; Expires=Thu, 15 Oct 2026 13:47:02 GMT; Secure; SameSite=Lax

The cookie value is a compact JWS. Decoded, it carries:

// protected header
{ kid: "rs1uh0qjoT…", typ: "session+jwt", alg: "HS256" }
// payload
{ userId: "u_123", role: "admin", jti: "ea346d56-…", iat: 1792068422, exp: 1792072022 }

The jti: "mine" and exp: 1 passed to update() are gone: the session owns those claims. Sending that cookie to GET /me returns { id: "ea346d56-…", data: { userId: "u_123", role: "admin" } }.

#The session manager

Every entry hands out the same session manager.

MemberTypeWhat it holds
idstring | undefinedThe token's jti. undefined until a token exists.
createdAtnumberThe token's iat, in milliseconds.
expiresAtnumber | undefinedThe token's exp, or iat + maxAge when sooner, in milliseconds. Always a number when maxAge is set.
dataSessionData<T>The payload without jti, iat, exp and nbf.
tokenstring | undefinedThe compact token this request carries or issued.
update(data?, { maxAge? }?)Promise<SessionManager>Merges data, issues a new token, writes the cookie of a cookie session.
clear()Promise<SessionManager>Expires the cookie and empties the session for the rest of the request.

Sessions are lazy. Opening a session reads the incoming token if there is one and writes nothing, unless that token has expired. A visitor without a cookie gets an empty session whose id is undefined, and no Set-Cookie goes out until update() runs.

Every update() issues a new token with a fresh jti, iat and exp, so id changes on each update. maxAge counts from the latest update.

#Reserved claims

jti, iat, exp and nbf belong to the session. It mints jti (with crypto.randomUUID() or your generateId), iat and exp on every issue and never writes nbf. The four are stripped from every update() payload, dropped from session.data on every issue even when written there directly, and never read back out of a token into session.data.

Update data must be a plain object. An array, a string, or a __proto__, constructor or prototype key throws ERR_INVALID_OPTION. Data travels as JSON, so the next request sees the JSON round trip of what you stored: a Date comes back as an ISO string, NaN as null, and undefined members are dropped.

#Explicit typing

Every session token carries typ: "session+jwt" in its protected header, and an incoming token must carry it too. This is the explicit typing that RFC 8725 §3.11 recommends. Without it, any other JWT signed or encrypted with the same key (an API token, an email link) would read as a valid session. The comparison is case-insensitive and accepts an application/ prefix. Set another value through jws.signOptions.protectedHeader.typ or jwe.encryptOptions.protectedHeader.typ.

A token with another typ is refused as ERR_SESSION_TOKEN_INVALID and reaches onError, whose cause reads Invalid JWS: "typ" mismatch, expected "session+jwt".

#Lifetime

maxAge (a number of seconds or a string such as "15m", "1h", "7D") sets exp = iat + maxAge on every issue. With maxAge set, an incoming token must carry exp, and it expires at its exp or at iat + maxAge, whichever comes first: the reading side's maxAge bounds a token whose exp lies further out, however it was minted. expiresAt holds that instant. session.update(data, { maxAge: "5m" }) gives one token a shorter lifetime of its own; a longer one still ends at iat + maxAge when read back. Without any maxAge the token's exp, if any, decides alone.

The cookie follows the token. A token with an exp sets the cookie's Expires to that instant and drops a configured cookie.maxAge, because Max-Age would win over Expires (RFC 6265 §5.3). A token without exp writes the cookie options as configured.

rolling moves exp forward with activity: rolling: true reissues the token on every request, rolling: "15m" once the token is at least that old. See rolling expiry.

An expired token fires onExpire, expires the cookie, and leaves an empty session. A token issued in the future, an iat later than now plus clockTolerance, is refused whether or not a maxAge is set (RFC 7519 §4.1.6); it reaches onError as ERR_SESSION_TOKEN_INVALID with the message Invalid session token: issued in the future (iat: …).

#Where the token comes from

The session header comes first. sessionHeader names it: a header name, or true for x-<name>-session with any __Host- or __Secure- prefix removed from the name ("__Host-app" reads x-app-session). Left unset, a session with cookies still reads x-<name>-session. When the request carries that header, its token is the credential and the cookie is not read; the cookie is read only when the header is absent. Set sessionHeader: false to read cookies only. Set cookie: false to read the header only; sessionHeader unset then reads x-<name>-session too. With both disabled the session has no token source: use() and token() throw ERR_SESSION_CONFIG_INVALID, while issue() and open() still work.

A session read from the header keeps its token there and never writes a cookie. update() and a rolling reissue mint the new token onto session.token, clear() empties the session, and no Set-Cookie goes out, an existing session cookie included; returning the new token to the client is the handler's job. Such a session updates on a request without response headers too.

The header holds the bare token or Bearer <token>, with the scheme matched case-insensitively. Any other value (Basic …, a lone Bearer, a blank value) is a token failure: it reaches onError as ERR_SESSION_TOKEN_INVALID, the session is empty, and the cookie is neither read nor expired. Name a header no other layer of the stack sets: with sessionHeader: "authorization", a request carrying HTTP Basic credentials has no session, whatever its cookie holds.

Prefer a __Host- cookie name such as "__Host-session". Every entry reads the first cookie of a name, and the prefix stops a sibling subdomain from planting a cookie that shadows the session (RFC 6265bis §4.1.3). The default cookie options (Secure, Path=/, no Domain) already satisfy it, and the rules are enforced when the session is opened: a __Host- name with a domain, a path other than / or secure: false, or a __Secure- name with secure: false, throws ERR_SESSION_CONFIG_INVALID on every adapter instead of emitting a cookie browsers drop.

await defineJWSSession({ key, name: "__Host-app", cookie: { domain: "example.com" } }).use(event);
// JWTError ERR_SESSION_CONFIG_INVALID: [unjwt/session] Session "__Host-app": a "__Host-" cookie must be Secure, with Path=/ and no Domain (RFC 6265bis §4.1.3).

#Chunked cookies

Browsers cap a cookie at about 4 KB. A token longer than cookie.chunkMaxLength (default 4000) is split. The base cookie holds __chunked__N and the parts go to <name>.1 through <name>.N:

Set-Cookie: __Host-app=__chunked__4; Path=/; Secure; SameSite=Lax
Set-Cookie: __Host-app.1=eyJraWQiOiJkd0VnVzRO…; Path=/; Secure; SameSite=Lax
Set-Cookie: __Host-app.2=4eHh4eHh4eHh4eHh4eHh…; Path=/; Secure; SameSite=Lax
Set-Cookie: __Host-app.3=4eHh4eHh4eHh4eHh4eHh…; Path=/; Secure; SameSite=Lax
Set-Cookie: __Host-app.4=xNzkyMDY4NDU5fQ.yFfD…; Path=/; Secure; SameSite=Lax

A token is split into at most 16 chunks. A longer one throws ERR_SESSION_CONFIG_INVALID (Session token requires 93 cookie chunks, exceeding the limit of 16.). When a new token needs fewer chunks than the old one, the surplus chunks are expired in the same response. Because a session name ending in a dot and digits (app.1) would spell another session's chunk, such a name throws ERR_SESSION_CONFIG_INVALID. Every entry writes the same layout, so a cookie written by one reads in the others.

#Clearing

clear() and an expired read write Max-Age=0 plus an epoch Expires on the base cookie and on every chunk. clear() expires the cookie and does not invalidate the token: a copy of the token is still valid until its exp, and revocation is onRead plus your own store. Write a session inside the handler, before the response is returned: a response that already left takes no cookie, and a read made after it observes without writing. A context that cannot take a cookie at all (a WebSocket upgrade, a request without response headers) refuses update(), while clear() empties the session for the rest of that context and leaves the cookie to the next request.

Set-Cookie: unjwt-jws=; Max-Age=0; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; SameSite=Lax

#JWS or JWE sessions

JWS sessionJWE session
TokenSigned. Anyone holding the cookie can read the payload.Encrypted. Only the server can read the payload.
Default cookiePath=/, Secure, SameSite=Lax, readable by scriptsPath=/, Secure, SameSite=Lax, HttpOnly
Use it forData the browser may read: display name, role, theme.Anything private: user IDs, emails, refresh grants.

A JWS session cookie is readable by scripts on purpose, because its payload is public anyway. Set cookie: { httpOnly: true } if your front end has no use for it.

#Keys

The key option takes key material, never a password. A string throws ERR_SESSION_KEY_INVALID:

[unjwt/session] Session "key" cannot be a string, use deriveJWKFromPassword().
SessionAccepted key
JWSAn HMAC oct JWK (HS256, HS384, HS512). A { privateKey, publicKey } pair (RS*, PS*, ES*, Ed25519), where publicKey may be one public JWK, a list, or a JWK Set. A CryptoKey.
JWEA symmetric oct JWK, such as one from generateJWK("A256GCM") (sealed with dir), generateJWK("A256KW") or deriveJWKFromPassword(), or a symmetric CryptoKey. The one key seals and opens, and only the server holds it.

::warning A JWE session also takes a { privateKey, publicKey } pair (RSA-OAEP-*, ECDH-ES*), or a private JWK alone as { privateKey }, which seals with its own public half. A pair seals with its public half, so anyone who holds the public key can mint a session the server accepts: the public half proves nothing about who sealed the token. Give a JWE session an oct JWK, and use a JWS session for a key pair, whose public half only verifies. ::

Use generateJWK() for a new key or deriveJWKFromPassword() to turn a secret into one, once, at startup. Each entry entry re-exports both. A CryptoKey carries no algorithm, so it also needs jws.signOptions.alg (or jwe.encryptOptions.alg and enc), else ERR_SESSION_CONFIG_INVALID. A configured JWK is imported once per key object when the first session opens. One the session cannot use, such as a k that does not decode or a pair half on the wrong side, throws ERR_SESSION_KEY_INVALID from that call instead of turning every request into an onError.

Pin the algorithm in production with jws.signOptions.alg or jwe.encryptOptions.alg and enc. Issuing then uses it and reading accepts nothing else.

#Errors

Every error a session throws is a JWTError. Check it with isJWTError(err, code), imported from unjwt (the session entries do not re-export it).

CodeWhen
ERR_SESSION_KEY_INVALIDkey is missing, a string, the wrong shape, or cannot be imported.
ERR_SESSION_CONFIG_INVALIDA bad name or sessionHeader, a __Host- or __Secure- name whose cookie options break the prefix, no token source, an unpinned CryptoKey, more than 16 chunks, or one name used by a JWS and a JWE session on the same request.
ERR_SESSION_TOKEN_INVALIDAn incoming token failed to verify or decrypt. Delivered to onError with the failure as cause, never thrown on read.
ERR_SESSION_READONLY_EVENTupdate() on a context that cannot take a cookie, or update()/clear() on h3 v1 after the response was sent.
ERR_INVALID_OPTIONupdate() data that is not a plain object, or a __proto__-like key.
ERR_JWT_EXPIREDA session token past its exp, or past iat + maxAge. Delivered to onExpire, never thrown.