
# 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`](/frameworks/session) now.

```ts
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" } }
  });
```

```http
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 |

::card{title="The h3 adapters go away in 0.9" icon="i-lucide-archive"} `unjwt/adapters/h3`, `h3v2` and `h3v1` are deprecated and removed in 0.9.0. [`unjwt/session`](/frameworks/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](/frameworks) for each runtime and [Migrating to 0.9](/getting-started/migration/to-0.9#the-h3-adapters) 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`](/jwk/generate), `thumbprint`, `importPEM`, `exportPEM` and [`deriveJWKFromPassword`](/jwk/password).

## Signature

```ts
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](#the-session-manager). `token()`, `issue()` and `open()` work with the token alone: see [the definition](/frameworks/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](#request-like-events-on-h3-v1)), 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](/frameworks#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](/frameworks/hooks#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()`](/jwt/jws/sign) options except `expiresIn`, `expiresAt`, `notBeforeIn`, `notBeforeAt`. `alg` pins the algorithm. `protectedHeader.typ` replaces `"session+jwt"`. |
| `jws.verifyOptions` | `SessionVerifyOptions` | `{}` | [`verify()`](/jwt/jws/verify) options except `algorithms`, `typ`, `maxTokenAge`, `validateClaims`, `payload`. So `issuer`, `audience`, `clockTolerance`, `requiredClaims` and `allowSymmetricJWKSet` stay settable. |
| `jwe.encryptOptions` | `SessionEncryptOptions` | `{}` | [`encrypt()`](/jwt/jwe/encrypt) options except the four lifetime options. `alg` and `enc` pin the algorithms. |
| `jwe.decryptOptions` | `SessionDecryptOptions` | `{}` | [`decrypt()`](/jwt/jwe/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](/frameworks/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:

```ts
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 minutes
```

If 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](/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:

```ts
// 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](#websockets) 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:

```ts
// 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`:

```ts
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()`](/frameworks/definition#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`](#request-like-events-on-h3-v1). | `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()` throws `ERR_SESSION_READONLY_EVENT` on it, while `clear()` empties the session for the rest of the connection and leaves the cookie to the next HTTP request. A revocation check in `onRead` clears the same way on every event:

  ```ts
  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.node` or `event.res` gates on `isEvent(event)` from `h3` (both majors export it).

- **`upgrade` answers the refusal itself.** Wrap `use()` in `try/catch` and return a `Response` with status `401`. crossws rethrows anything that is not a `Response`, so the client gets the runtime's error answer instead of a `401`, and on Node with h3 v1 the socket is never answered at all.
- **`message` reuses the upgrade's read.** On h3 v1 `peer.context` is the same object the upgrade saw, so `use({ headers: peer.request.headers, context: peer.context })` is a cache hit and `onRead` does not fire again. Pass `context: {}` to re-read the token on every message. On h3 v2 `peer.request` is the same `Request` as at upgrade, so `use({ req: peer.request })` is a cache hit too. The `defineWebSocketHandler((event) => hooks)` factory hands the upgrade a full `H3Event` as well, on which `isEvent(event)` is true.
- **A token-only session is not read by `use()`.** A session with `cookie: false, sessionHeader: false` has no request to read. Over a socket, exchange its tokens with `open()` and `issue()` in `message`, with no event involved.

On h3 v1:

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
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`](/frameworks/hooks#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](/errors).

## See also

- [The definition](/frameworks/definition) for reading, minting and opening session tokens by hand.
- [Authentication basics](/examples/authentication-basics) and the [refresh token pattern](/examples/refresh-token-pattern).
