H3 sessions
Cookie-based JWT session management for H3 applications. Sessions are stored as encrypted (JWE) or signed (JWS) tokens in chunked cookies, or read from an Authorization header.
Import paths (pick the one that matches your H3 version):
import { useJWESession, useJWSSession } from "unjwt/adapters/h3v2";
// or "unjwt/adapters/h3v1" for H3 v1 / Nuxt v4 / Nitro v2
Basic usage
Encrypted session (JWE)
import { useJWESession } from "unjwt/adapters/h3v2";
app.get("/", async (event) => {
const session = await useJWESession(event, {
key: process.env.SESSION_SECRET!, // password, symmetric JWK, or asymmetric keypair
maxAge: "7D",
});
if (!session.id) {
// No active session yet — create one
await session.update({ userId: "123", email: "user@example.com" });
}
return { user: session.data };
});
Signed session (JWS)
import { useJWSSession, generateJWK } from "unjwt/adapters/h3v2";
const keys = await generateJWK("RS256"); // persist these!
app.get("/", async (event) => {
const session = await useJWSSession(event, {
key: keys,
maxAge: "1h",
});
return { data: session.data };
});
Configuration
JWE — SessionConfigJWE
interface SessionConfigJWE<T, MaxAge, TEvent> {
key:
| string // password for PBES2
| JWEEncryptJWK // symmetric or public asymmetric JWK (alg in the JWE family)
| { privateKey: JWEAsymmetricPrivateJWK; publicKey?: JWEAsymmetricPublicJWK }; // asymmetric pair
maxAge?: MaxAge; // ExpiresIn duration
name?: string; // cookie name (default: "h3-jwe")
cookie?: false | (CookieSerializeOptions & { chunkMaxLength?: number });
sessionHeader?: false | string; // e.g. "Authorization"
generateId?: () => string; // default: crypto.randomUUID()
jwe?: {
encryptOptions?: Omit<JWEEncryptOptions, "expiresIn">;
decryptOptions?: JWTClaimValidationOptions;
};
hooks?: SessionHooksJWE<T, MaxAge, TEvent>;
}
JWS — SessionConfigJWS
interface SessionConfigJWS<T, MaxAge, TEvent> {
key:
| JWK_oct<JWK_HMAC> // symmetric (HMAC) JWK
| {
privateKey: JWSAsymmetricPrivateJWK;
publicKey: JWSAsymmetricPublicJWK | JWSAsymmetricPublicJWK[] | JWKSet;
};
maxAge?: MaxAge;
name?: string; // default: "h3-jws"
cookie?: false | (CookieSerializeOptions & { chunkMaxLength?: number });
sessionHeader?: false | string;
generateId?: () => string;
jws?: {
signOptions?: Omit<JWSSignOptions, "expiresIn">;
verifyOptions?: JWTClaimValidationOptions;
};
hooks?: SessionHooksJWS<T, MaxAge, TEvent>;
}
The session manager
Both adapters return the same shape:
interface SessionManager<T, ConfigMaxAge> {
readonly id: string | undefined;
readonly createdAt: number;
readonly expiresAt: ConfigMaxAge extends ExpiresIn ? number : number | undefined;
readonly data: SessionData<T>;
readonly token: string | undefined;
update: (update?: SessionUpdate<T>) => Promise<SessionManager<T, ConfigMaxAge>>;
clear: () => Promise<SessionManager<T, ConfigMaxAge>>;
}
type SessionUpdate<T> =
| Partial<SessionData<T>>
| ((oldData: SessionData<T>) => Partial<SessionData<T>> | undefined);
Lazy by design
session.id is undefined until session.update() is first called. This is intentional — it aligns with OAuth and spec-compliant flows where a session should only exist once a valid operation has created it. Calling useJWESession(event, config) alone doesn't set a cookie.
update() forms
// Partial merge
await session.update({ theme: "dark" });
// Updater function
await session.update((oldData) => ({ count: (oldData.count ?? 0) + 1 }));
// Token rotation (new jti/iat/exp) without changing data
await session.update();
clear() — explicit termination
await session.clear();
// Session removed from event context; cookie expired in response.
clear() is semantically different from "token expired" — they trigger different hooks (onClear vs. onExpire). See Hooks.
Cookies
By default:
- JWE:
path: "/", secure: true, httpOnly: true. - JWS:
path: "/", secure: true, httpOnly: false(the payload is meant to be readable).
Customize via cookie:
const session = await useJWSSession(event, {
key: keys,
maxAge: "1h",
cookie: {
sameSite: "strict",
domain: ".example.com",
path: "/app",
chunkMaxLength: 3000, // split into chunks if the token exceeds this many bytes
},
});
Pass cookie: false to disable cookie writing entirely (header-only mode — see below).
Chunking
JWE tokens can easily exceed the 4KB cookie limit when the session payload is large. unjwt uses H3's setChunkedCookie to split tokens across name.0, name.1, ..., transparently reassembled on read. Default chunk size is 4000 bytes. Override via cookie.chunkMaxLength.
Header-based tokens
For API clients (mobile, SPA, service-to-service) that can't use cookies, read the token from a header:
const session = await useJWESession(event, {
key: secret,
sessionHeader: "Authorization", // expects "Bearer <token>"
});
Custom header name:
sessionHeader: "X-Session-Token",
Pass sessionHeader: false to disable header reads (cookies only).
When both cookie and sessionHeader are enabled, the cookie takes precedence. Set cookie: false to force header-only behavior.
Typing session data
interface MyData {
userId: string;
role: "admin" | "user";
preferences?: { theme: "dark" | "light" };
}
app.get("/", async (event) => {
const session = await useJWESession<MyData>(event, config);
session.data.userId; // string
session.data.role; // "admin" | "user"
});
The generic threads through update() and all hook payloads, so type errors catch drift between your setter and getter.