H3 sessions
useJWSSession() and useJWESession() open the session of the current H3 request, for Nuxt, Nitro or a bare H3 app.
import { H3 } from "h3";
import { generateJWK, useJWESession } from "unjwt/adapters/h3v2";
const key = await generateJWK("A256GCM"); // load a persisted key in production
export const app = new H3()
.post("/login", async (event) => {
const session = await useJWESession(event, { key, maxAge: "7D" });
await session.update({ userId: "u_123" });
return { id: session.id }; // { id: "0f2c686c-…" }
})
.get("/me", async (event) => {
const session = await useJWESession(event, { key, maxAge: "7D" });
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 |
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 useJWSSession<
T extends Record<string, any> = SessionClaims,
MaxAge extends ExpiresIn | undefined = ExpiresIn | undefined,
>(event: H3Event, config: SessionConfigJWS<T, MaxAge>): Promise<SessionManager<T, MaxAge>>;
function useJWESession<
T extends Record<string, any> = SessionClaims,
MaxAge extends ExpiresIn | undefined = ExpiresIn | undefined,
>(event: H3Event, config: SessionConfigJWE<T, MaxAge>): Promise<SessionManager<T, MaxAge>>;T types session.data: useJWSSession<{ userId: string; role: "admin" | "user" }>(event, config). With maxAge in the config, session.expiresAt is typed number.
#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. Without it, tokens never expire. |
rolling | true | ExpiresIn | none | Reissues a session on read, so exp slides. true on every request, a duration once less than that remains. Needs maxAge. 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 | false | string | x-<name>-session | Header read when the cookie carries no token. Accepts a bare token or Bearer <token>. |
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, forceUint8Array, 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 adapter 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. 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 call (or every JWE call) 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, whatever key or maxAge they pass. 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
Put the config in one place and wrap the call. In Nuxt 4 that is server/utils/, which Nitro auto-imports:
// server/utils/session.ts
import type { H3Event } from "h3";
import { useJWESession, type SessionConfigJWE } from "unjwt/adapters/h3v1"; // h3v2 on Nuxt 5
const config = {
key: JSON.parse(process.env.NUXT_SESSION_KEY!), // a JWK saved from generateJWK("A256GCM")
name: "__Host-session",
maxAge: "7D",
} satisfies SessionConfigJWE;
export function useAppSession(event: H3Event) {
return useJWESession<{ userId?: string }>(event, config);
}// server/api/me.get.ts
export default defineEventHandler(async (event) => {
const session = await useAppSession(event);
if (!session.id) throw createError({ statusCode: 401 });
return session.data;
});Keep the 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 session = await useJWSSession(event, {
key,
sessionHeader: "authorization", // "Bearer <token>" or the bare token
cookie: false, // header only; update() writes no cookie
});With cookie: false the client needs the token some other way. Return session.token after update(), or mint one with signJWSSession().
#After the response is sent
A response that already left cannot take a Set-Cookie. update() and clear() then throw ERR_SESSION_READONLY_EVENT and leave the session as it was:
event.node.res.end("done");
await session.update({ x: 1 });
// JWTError ERR_SESSION_READONLY_EVENT: [unjwt/h3] Cannot update session on read-only event.h3 v1 checks this on every event. h3 v2 can only check it under Node (event.runtime.node.res). On other runtimes a write made after the response was built is accepted and never sent, which is why a session is written inside the handler, before it returns.
A read after the response was sent still works and 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.
#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, 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() or clear() 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 an expired one to onExpire. All codes are listed on Errors.
#See also
- Lower-level functions for reading, minting and verifying session tokens by hand.
- Authentication basics and the refresh token pattern.