
# `unjwt/session`: Web standard sessions

> `defineJWSSession()` and `defineJWESession()` over a web-standard request event: the `{ req, res, context }` that h3 v2, Nitro 3 and Nuxt's `nuxt/server` hand a handler, or one you build from a `Request` and the `Headers` its response is sent with. A bare `Request` reads, and a token you already hold opens. No framework, no peer dependency.

```ts
import { defineJWESession, generateJWK } from "unjwt/session";

const key = await generateJWK("A256GCM"); // load a persisted key in production

const useSession = defineJWESession({ key, maxAge: "7D" });

export default {
  async fetch(req: Request): Promise<Response> {
    const headers = new Headers({ "content-type": "application/json" });
    const session = await useSession.use({ req, res: { headers } });

    if (new URL(req.url).pathname === "/login") {
      await session.update({ userId: "u_123" });
    }

    return new Response(JSON.stringify({ id: session.id ?? null, data: session.data }), {
      headers,
    });
  },
};
```

```http
Set-Cookie: unjwt-jwe=eyJraWQiOiJzME1mT1h5Zkh3…; Path=/; Expires=Fri, 23 Oct 2026 20:15:12 GMT; HttpOnly; Secure; SameSite=Lax
```

This shape fits every runtime whose handler receives a `Request` and returns a `Response`: Cloudflare Workers, Bun, Deno, and frameworks built on them. In Hono, pass `c.req.raw` as `req` and return `new Response(body, { headers })`, or copy each line of `headers.getSetCookie()` onto the response. An h3 v2 event is already this shape, and so is the event `nuxt/server` hands a Nuxt handler: see [Frameworks](/frameworks) for h3, Nitro and Nuxt.

The definition has four members: `use()`, `token()`, `issue()` and `open()`, described on [the definition](/frameworks/definition). `open()` resolves to `{ id, createdAt, expiresAt, data }`. The manager, the cookie layout, chunking, lifetime and hooks are the same as on every other entry. See [Frameworks](/frameworks).

## Signature

```ts
type SessionRequestEvent = { res?: { headers: Headers }; context?: Record<string, unknown> } & (
  | { req: Request; headers?: Headers } // an h3 v2 event, Nuxt's RequestEvent
  | { headers: Headers; req?: Request } // a Request itself, a crossws upgrade request
); // no res: read-only

interface SessionHandedToken {
  token: string | undefined; // a token in hand
}

type WebSessionEvent = SessionRequestEvent | SessionHandedToken; // what use() takes

function defineJWSSession<
  T extends Record<string, any> = SessionClaims,
  MaxAge extends ExpiresIn | undefined = ExpiresIn | undefined,
  TEvent extends WebSessionEvent = WebSessionEvent,
>(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 WebSessionEvent = WebSessionEvent,
>(config: SessionConfigJWE<T, MaxAge, TEvent>): SessionDefinition<T, MaxAge, TEvent>;
```

`use()` takes a `WebSessionEvent`. A request event reads `req` first, `headers` otherwise, so an h3 v2 event, a `Request`, and a crossws 0.3 or 0.4 upgrade request all pass as they are; one with neither throws `ERR_SESSION_CONFIG_INVALID`. A third generic narrows the event to a framework's own type, which `use()` then requires and every hook receives as `event`: `defineJWESession<Data, "7D", H3Event>(config)`. Both functions take a second argument, `{ driver }`, that runs the session on a framework of your own. See [Write a driver](/frameworks/drivers).

## A request event

`use({ req, res })` reads the token from the session header when `req.headers` carries it, else from the `Cookie`. `update()` and `clear()` write `Set-Cookie` lines onto `res.headers`, and onto `res.errHeaders` as well when `res` has one, the headers h3 v2 sends for a thrown handler. A second write of the same cookie (same name, domain and path) replaces its line instead of adding another, and lines for other cookies stay as they are:

```ts
const headers = new Headers();
headers.append("set-cookie", "theme=dark; Path=/");

const session = await useSession.use({ req, res: { headers } });
await session.update({ step: 1 });
await session.update({ step: 2 });

headers.getSetCookie();
// ["theme=dark; Path=/", "unjwt-jwe=eyJ…(step 2); Path=/; …"]
```

A long token is split into `unjwt-jwe.1` … `unjwt-jwe.N` behind a `__chunked__N` marker, as on every other entry, and a shorter re-issue in the same request expires the surplus chunks.

With a `context`, the session state lives on `context.sessions`, the slot h3 itself uses, so a middleware and a handler that receive the same event share one read. Without one it is keyed on the `Request` object.

## Read-only: a request without a response

A `Request` on its own, or any object with `headers` and no `res`, reads and takes no cookie. `update()` throws:

```ts
const session = await useSession.use(req);
session.data; // readable
await session.update({ a: 1 });
// JWTError ERR_SESSION_READONLY_EVENT: [unjwt/session] Cannot update session on read-only event.
```

The same goes for `{ req }` without `res`. `clear()` empties the session for the rest of the request and resolves, so an `onRead` revocation check clears the same way it does with a response; the cookie waits for the next request. An expired or unreadable token is reported to `onExpire` or `onError` without its cookie being dropped, and `rolling` does not reissue. A session read from the session header, or one with `cookie: false`, has nothing to write, so its `update()` still re-mints the token onto `session.token`.

This is the WebSocket shape, and the upgrade is the last moment a cookie can reach the client: the headers an `upgrade` hook returns travel on the `101` handshake, and a browser applies a `Set-Cookie` from it. Hand `use()` those headers as `res`, and a `rolling` reissue, an expired cookie's drop or a `clear()` made during the read go out with the upgrade:

```ts
export default defineWebSocketHandler({
  async upgrade(request) {
    const headers = new Headers();
    const session = await useSession.use({
      headers: request.headers,
      context: request.context,
      res: { headers },
    });
    if (!session.id) return new Response("Sign in first", { status: 401 });
    request.context.userId = session.data.userId; // what the socket needs, for every later hook
    return { headers }; // empty when nothing was written
  },
  open(peer) {
    peer.send(`hello ${peer.context.userId}`);
  },
});
```

Nothing can be written once the socket is open, so the session is read once, in `upgrade`, and what the socket needs is copied onto `request.context`, the object every later hook receives as `peer.context`. What the upgrade request looks like, and how the refusal and the context are returned, differ by runtime: see [Frameworks](/frameworks).

## A token in hand

When the token does not arrive in a cookie or a header you can name (a queue message, a `Bearer` you parsed yourself), hand it in:

```ts
export default {
  async queue(batch: MessageBatch<{ session: string }>) {
    for (const message of batch.messages) {
      const session = await useSession.use({ token: message.body.session });
      if (!session.id) continue; // missing, expired or invalid
      await session.update({ processedAt: Date.now() });
      await forward(session.token); // the re-issued token
    }
  },
};
```

`use({ token })` runs the whole read: `onRead`, `onExpire` and `onError` fire as they would for a cookie, and `rolling` reissues. `update()` mints a new token onto `session.token` and writes nothing: sending it is your job. `clear()` leaves an empty session whose `token` is `undefined`. `token({ token })` returns the token you handed in. `use({ token: undefined })` is an empty session.

The config's `cookie` and `sessionHeader` are not consulted, so a token in hand works on a cookie session and on one with `cookie: false, sessionHeader: false` alike.

## Header only

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,
});

const session = await apiSession.use(req);
await session.update({ scope: "read" });
return Response.json({ token: session.token });
```

`sessionHeader: true` reads `x-<name>-session`. With cookies on, a request that carries the header is read from it, and the cookie only when the header is absent. A session read from the header writes no cookie: `update()`, `rolling` and `clear()` change `session.token` alone.

## Rules for `use()`

1. Pass the framework's own event or the incoming `Request`, never an object you built or share between requests. Session state is kept per `event.context`, else per `Request` object.
2. Pass the `res` whose `headers` the response is sent with, or get a read-only session.
3. Call `use()` before the response starts. The library cannot tell when it is too late: a write after the response was built is accepted and never sent.
4. Hold a token yourself? Use `use({ token })`. Sending the new token is your job.
5. One name is one cookie. Two definitions with one name on one request share state, and a JWS and a JWE definition under one name throw `ERR_SESSION_CONFIG_INVALID`.

## 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` | `"unjwt-jws"` / `"unjwt-jwe"` | Cookie name, and the base of the default header. Prefer `"__Host-…"`. |
| `cookie` | `false \| CookieAttributes & { 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. No `encode`: a token is base64url and needs none. |
| `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). Each hook receives what you passed to `use()` as `event`; the key lookups get none from `open()`. |

`CookieAttributes` is `{ domain?, expires?, httpOnly?, maxAge?, path?, priority?, sameSite?, secure?, partitioned? }`, the shape Elysia's entry uses too.

## 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, an event whose `req` is not a web `Request`, `use(event)` 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 a cookie session opened without a `res`. |
| `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. Every error message starts with `[unjwt/session]`. All codes are listed on [Errors](/errors).

## See also

- [Write a driver](/frameworks/drivers) to run these sessions on a framework unjwt does not ship.
- [The definition](/frameworks/definition) for `token()`, `issue()` and `open()`.
