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)

basic-jwe.ts
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)

basic-jws.ts
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

update.ts
// 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

clear.ts
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:

cookie-opts.ts
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:

header.ts
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

typed.ts
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.

See also