h3 adapters
The unjwt/adapters/h3 and unjwt/adapters/h3v1 entries: defineJWSSession() and defineJWESession() over an h3 event through h3's own helpers. Deprecated; every runtime they served is on unjwt/session now.
import { H3 } from "h3";
import { defineJWESession, generateJWK } from "unjwt/adapters/h3v2";
const key = await generateJWK("A256GCM"); // load a persisted key in production
const useSession = defineJWESession({ key, maxAge: "7D" });
export const app = new H3()
.post("/login", async (event) => {
const session = await useSession.use(event);
await session.update({ userId: "u_123" });
return { id: session.id }; // { id: "0f2c686c-…" }
})
.get("/me", async (event) => {
const session = await useSession.use(event);
return { id: session.id ?? null, data: session.data }; // { id: "0f2c686c-…", data: { userId: "u_123" } }
});Set-Cookie: h3-jwe=eyJraWQiOiJfNnlmWXJwNFc…; Path=/; Expires=Thu, 22 Oct 2026 12:49:03 GMT; HttpOnly; Secure; SameSite=Lax#Which entry
| Import | h3 | Runs under |
|---|---|---|
unjwt/adapters/h3v1 | ^1.15.4 | Nuxt 4, Nitro 2 |
unjwt/adapters/h3v2 | ^2.0.1 | Nuxt 5, Nitro 3 |
:
The h3 adapters go away in 0.9
unjwt/adapters/h3, h3v2 and h3v1 are deprecated and removed in 0.9.0. unjwt/session takes the h3 v2 event as it is, and the event nuxt/server hands a Nuxt 4.6+ handler on Nitro 2 and 3 alike. See Frameworks for each runtime and Migrating to 0.9 for the edit. ::unjwt/adapters/h3 resolves to unjwt/adapters/h3v2. Both entries export the same functions and types. h3 is an optional peer dependency, so install it only if your framework does not already. Each entry also re-exports generateJWK, thumbprint, importPEM, exportPEM and deriveJWKFromPassword.
#Signature
function defineJWSSession<
T extends Record<string, any> = SessionClaims,
MaxAge extends ExpiresIn | undefined = ExpiresIn | undefined,
TEvent extends HTTPEvent = HTTPEvent,
>(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 HTTPEvent = HTTPEvent,
>(config: SessionConfigJWE<T, MaxAge, TEvent>): SessionDefinition<T, MaxAge, TEvent>;
interface SessionDefinition<T, MaxAge, TEvent> {
use(event: TEvent): Promise<SessionManager<T, MaxAge>>;
token(event: TEvent): string | undefined;
issue(data?: Partial<SessionData<T>>, options?: { maxAge?: ExpiresIn }): Promise<string>;
open(token: string): Promise<OpenedSessionState<T, MaxAge>>; // { id, createdAt, expiresAt, data }
}Define the session once, at module scope. The definition validates and reads nothing until one of its methods runs. use(event) reads the request's token and returns the session manager. token(), issue() and open() work with the token alone: see the definition.
T types session.data: defineJWSSession<{ userId: string; role: "admin" | "user" }>(config). With maxAge in the config, session.expiresAt is typed number. TEvent is the event use() accepts and every hook receives. It defaults to the bound, HTTPEvent on h3 v2 and CompatEvent | H3Event on h3 v1 (a request-like event), because a definition is written once and cannot know which handlers will call use(). Narrow it to H3Event only in a project that never passes anything else: defineJWSSession<T, MaxAge, H3Event>(config).
#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 | "h3-jws" / "h3-jwe" | Cookie name, and the base of the default header. Prefer "__Host-…". |
cookie | false | CookieSerializeOptions & { 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. |
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. |
A jws option only exists on SessionConfigJWS and a jwe option only on SessionConfigJWE.
#The session manager
| Member | What it does |
|---|---|
session.id | The token's jti, or undefined before the first update(). |
session.data | The payload, without jti, iat, exp and nbf. |
session.createdAt / session.expiresAt | The token's iat and exp, in milliseconds. |
session.token | The compact token of this request. |
session.update(data?, { maxAge? }?) | Issues a new token and writes the cookie, unless the token came from the session header. Resolves to the same manager. |
session.clear() | Expires the cookie (and its chunks) and empties the session. |
The members are live getters, so a manager opened before update() reads the new values afterwards. update() takes three forms:
await session.update({ role: "admin" }); // merge into data
await session.update((data) => ({ visits: (data.visits ?? 0) + 1 })); // derive from the current data
await session.update(); // rotate: new jti, iat and exp, same data
await session.update({ step: "mfa" }, { maxAge: "5m" }); // this token lives 5 minutesIf issuing or the cookie write fails, update() throws and the session rolls back to its previous state. Every JWS use() (or every JWE use()) with the same name in one request shares one session: the first call reads the token with its config, and later calls get that state back, from the same definition or another one, whatever key or maxAge it carries. Two sessions with different keys need different names. A JWS and a JWE session may not share a name on the same request (ERR_SESSION_CONFIG_INVALID).
#In Nuxt and Nitro
Frameworks shows Nuxt, Nitro and bare h3 on unjwt/session. With this entry, define the session once in server/utils/ behind a wrapper that takes the event:
// server/utils/session.ts
import type { H3Event } from "h3";
import { defineJWESession } from "unjwt/adapters/h3";
const session = defineJWESession<{ userId: string }, "7D">({
key: JSON.parse(process.env.NUXT_SESSION_KEY!),
maxAge: "7D",
});
export function useSession(event: H3Event) {
return session.use(event);
}The wrapper's parameter is where a project narrows the event; widen it to HTTPEvent (v2) or CompatEvent | H3Event (v1) when WebSocket hooks call it too. NUXT_SESSION_KEY holds a JWK saved from generateJWK("A256GCM").
This is the Nuxt 5 (h3 v2) variant, where a route imports defineHandler and HTTPError from "h3" instead of relying on auto-imports:
// server/api/me.get.ts
import { defineHandler, HTTPError } from "h3";
export default defineHandler(async (event) => {
const session = await useSession(event);
if (!session.id) throw new HTTPError({ status: 401 });
return session.data;
});Keep the definition, and its key object, created once at module scope. The adapter imports a JWK once per key object, so a JWK rebuilt on each request is imported on each request.
#Reading the token from a header
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, // header only; update() writes no cookie
});
const session = await apiSession.use(event);With cookie: false the client needs the token some other way. Return session.token after update(), or mint one with issue().
#WebSockets
A WebSocket route reads its session twice: once in upgrade, before the socket opens, and again in message. Nitro hands those hooks a request without a response:
| h3 | Nitro (crossws) | upgrade(request) | message(peer, message) |
|---|---|---|---|
| v1 | Nitro 2 (0.3) | A request-like object with headers and context: pass it to use(request) as a CompatEvent. | peer.request.headers, and peer.context, the same object the upgrade saw. |
| v2 | Nitro 3 (0.4) | The Request: pass use({ req: request }). | peer.request, the same Request as at upgrade. |
The upgrade object cannot take a cookie.
update()throwsERR_SESSION_READONLY_EVENTon it, whileclear()empties the session for the rest of the connection and leaves the cookie to the next HTTP request. A revocation check inonReadclears the same way on every event:const chat = defineJWSSession<{ userId: string }>({ key, maxAge: "1h", hooks: { async onRead({ session }) { if (await revoked.has(session.id)) await session.clear(); }, }, });A hook that needs
event.nodeorevent.resgates onisEvent(event)fromh3(both majors export it).upgradeanswers the refusal itself. Wrapuse()intry/catchand return aResponsewith status401. crossws rethrows anything that is not aResponse, so the client gets the runtime's error answer instead of a401, and on Node with h3 v1 the socket is never answered at all.messagereuses the upgrade's read. On h3 v1peer.contextis the same object the upgrade saw, souse({ headers: peer.request.headers, context: peer.context })is a cache hit andonReaddoes not fire again. Passcontext: {}to re-read the token on every message. On h3 v2peer.requestis the sameRequestas at upgrade, souse({ req: peer.request })is a cache hit too. ThedefineWebSocketHandler((event) => hooks)factory hands the upgrade a fullH3Eventas well, on whichisEvent(event)is true.A token-only session is not read by
use(). A session withcookie: false, sessionHeader: falsehas no request to read. Over a socket, exchange its tokens withopen()andissue()inmessage, with no event involved.
On h3 v1:
import { defineWebSocketHandler } from "h3";
export default defineWebSocketHandler({
async upgrade(request) {
try {
const session = await chat.use(request);
if (!session.id) return new Response("Unauthorized", { status: 401 });
} catch {
return new Response("Unauthorized", { status: 401 });
}
},
async message(peer, message) {
const session = await chat.use({ headers: peer.request.headers, context: peer.context });
peer.send(`${session.data.userId}: ${message.text()}`);
},
});On h3 v2:
import { defineWebSocketHandler } from "h3";
export default defineWebSocketHandler({
async upgrade(request) {
try {
const session = await chat.use({ req: request });
if (!session.id) return new Response("Unauthorized", { status: 401 });
} catch {
return new Response("Unauthorized", { status: 401 });
}
},
async message(peer, message) {
const session = await chat.use({ req: peer.request });
peer.send(`${session.data.userId}: ${message.text()}`);
},
});#Events without a response
Only an h3 event can take a Set-Cookie: on h3 v1 any object isEvent() accepts, on h3 v2 one that carries res, as in h3's own useSession. On any other object update() throws ERR_SESSION_READONLY_EVENT and clear() only empties the session for the request. On h3 v1 a response that was already sent (event.handled) refuses both and leaves the session as it was:
event.node.res.end("done"); // h3 v1
await session.clear();
// JWTError ERR_SESSION_READONLY_EVENT: [unjwt/h3] Cannot update/clear session: the response was already sent.h3 v1 refuses a write once the response was sent. h3 v2 has no such signal: a write made after the handler returned is accepted and never sent, which is why a session is written inside the handler, before it returns. A read on such an event only observes: an expired token is reported to onExpire without its cookie being dropped, and rolling does not reissue. Both happen on the next request, which carries the same token.
On h3 v2, a handler that throws after update() or clear() still sends the session cookie. The adapter copies it into res.errHeaders, the headers h3 sends with an error response:
await session.update({ a: 1 });
throw new HTTPError({ status: 409, message: "conflict" });
// 409, Set-Cookie: h3-jws=eyJraWQiOiIxT…An app-level onError that returns its own Response replaces those headers.
#Request-like events on h3 v1
The h3 v1 entry also accepts an object that is not an h3 event, as long as it carries context and either headers or request.headers. The default TEvent, CompatEvent | H3Event, already takes it. Reading works. Such an object has no response, so update() throws and clear() empties the session without a cookie:
import { defineJWSSession } from "unjwt/adapters/h3v1";
const useSession = defineJWSSession(config);
const event = { headers: new Headers({ cookie: `h3-jws=${token}` }), context: {} };
const session = await useSession.use(event);
session.id; // "76755fff-…"
await session.update({ a: 1 });
// JWTError ERR_SESSION_READONLY_EVENT: [unjwt/h3] Cannot update session on read-only event.
await session.clear();
session.id; // undefined#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, use() 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 an event that cannot take a cookie, or update()/clear() on h3 v1 after the response was sent. |
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. All codes are listed on Errors.
#See also
- The definition for reading, minting and opening session tokens by hand.
- Authentication basics and the refresh token pattern.