Nuxt
Since Nuxt 4.6 a handler defined through nuxt/server receives the web-standard RequestEvent ({ req, res, context }) on every server builder: Nitro 2 under Nuxt 4, Nitro 3 under Nuxt 5. unjwt/session takes it as it is, with no h3 import and no alias, and the same code on both.
#Handlers come from nuxt/server
// server/api/me.get.ts
import { defineEventHandler } from "nuxt/server";
export default defineEventHandler(async (event) => {
const session = await requireAppSession(event);
return { id: session.id, user: session.data.user };
});The import is the whole point. Nuxt's auto-imported defineEventHandler is still h3's, and on Nitro 2 its handler receives the h3 v1 event, whose req is Node's IncomingMessage. Every nuxt/server helper refuses that event, and so does unjwt/session, with ERR_SESSION_CONFIG_INVALID:
[unjwt/session] use() got an event whose "req" is not a web Request.readBody and createError come from nuxt/server for the same reason.
#One definition, keyed from appSecret
// server/utils/session.ts
import { type RequestEvent, createError, deriveSecret } from "nuxt/server";
import { type SessionDefinition, type WebSessionEvent, defineJWESession } from "unjwt/session";
import { base64UrlStringify } from "unjwt/utils";
type Definition = SessionDefinition<{ user: string }, "1h", WebSessionEvent>;
let definition: Promise<Definition> | undefined;
async function define(): Promise<Definition> {
const secret = await deriveSecret("app:session");
const bytes = Uint8Array.from(secret.match(/../g)!, (pair) => Number.parseInt(pair, 16));
return defineJWESession<{ user: string }, "1h">({
key: { kty: "oct", k: base64UrlStringify(bytes), alg: "A256GCM" },
maxAge: "1h",
rolling: "15m",
});
}
export async function useAppSession(event: WebSessionEvent) {
return (definition ??= define()).then((current) => current.use(event));
}
export async function requireAppSession(event: WebSessionEvent) {
const current = await useAppSession(event);
if (!current.id) throw createError({ statusCode: 401, message: "Sign in first" });
return current;
}Nuxt reads NUXT_APP_SECRET (or runtimeConfig.appSecret) and generates a development one when unset; builds do not. deriveSecret(purpose) returns 32 HKDF-SHA256 bytes as hex, stable while appSecret is and distinct per purpose, which the util turns into an A256GCM oct JWK. It needs the runtime config, so the definition is created on the first call and cached in the module. A server/utils/ export named useSession would clash with the useSession Nuxt auto-imports, hence useAppSession.
#A handler that throws after a write
On Nuxt 4 (Nitro 2) the prepared headers are the Node response's own, so a Set-Cookie written before a thrown error goes out with it: a clear() followed by createError({ statusCode: 401 }) signs the client out. On Nuxt 5 (Nitro 3) Nitro's error handler answers with a Response of its own and the cookie is lost; see Nitro.
#WebSockets
nuxt/server has no WebSocket API, so defineWebSocketHandler is h3's auto-import on both majors. The session is read once, in upgrade, the only hook that can refuse; the headers it returns travel on the 101 handshake, so a Headers passed as res lets the read roll the token or drop an expired cookie. What the socket needs for its lifetime goes on peer.context.
On Nuxt 4 the upgrade request is a plain { headers, url, context } (crossws 0.3), whose context is the object every later hook receives:
// server/routes/ws.ts
export default defineWebSocketHandler({
async upgrade(request) {
const headers = new Headers();
const session = await useAppSession({
headers: request.headers,
context: request.context,
res: { headers },
});
if (!session.id) return new Response("Sign in first", { status: 401 });
Object.assign(request.context, session); // a snapshot: peer.context later
return { headers }; // a roll or a drop made during the read rides on the 101
},
open(peer) {
peer.send(`hello ${(peer.context as { data: { user: string } }).data.user}`);
},
});On Nuxt 5 the upgrade request is a Request (crossws 0.4): the refusal is a thrown Response and the context goes back as a return value, return { headers, context: { ...session } }, as on Nitro. The fields are named on both, because a spread drops a Request's headers getter.
Nitro 2 resolves an upgrade straight to the route's hooks, past every middleware, so the upgrade hook is the only place a socket's cookie is read.
The playgrounds/nuxt-4 directory of the repository is this page as a running app.