
# Frameworks

> Cookie sessions on h3, Nitro, Nuxt, Elysia and any Web standard runtime, where the session is a signed or encrypted JWT that the server issues and reads back.

::note **ELI5.** A session is how a server remembers you between requests. Many servers keep that memory in a database and hand the browser a random ID. These sessions keep the memory inside the cookie itself. The server writes your data into a JWT, signs or encrypts it with a key only the server holds, and sets it as a cookie. On the next request the browser sends the cookie back, the server checks the signature (or decrypts it), and the data is there again. Nobody can change the data without the key. With an encrypted session, nobody can read it without the key either. ::

::card-group ::card{title="Web standard sessions" icon="i-lucide-globe" to="/frameworks/session"} `unjwt/session`: the request event every modern runtime hands a handler, a `Request`, or a token in hand. :: ::card{title="h3" icon="i-unjs-h3" to="/frameworks/h3"} An h3 v2 app: the event as it is, a thrown handler's cookies, the WebSocket upgrade. :: ::card{title="Nitro" icon="i-unjs-nitro" to="/frameworks/nitro"} Nitro 3: the imports, the event, a thrown handler, the WebSocket upgrade. :: ::card{title="Nuxt" icon="i-devicon-nuxt" to="/frameworks/nuxt"} Nuxt 4.6 and 5: handlers from `nuxt/server`, a key from `appSecret`, the WebSocket upgrade. :: ::card{title="Elysia" icon="i-skill-icons-elysia-dark" to="/frameworks/elysia"} `jwsSession()` and `jweSession()` plugins with an ambient `ctx.session` and a guard macro. :: ::card{title="The definition" icon="i-lucide-wrench" to="/frameworks/definition"} `token()`, `issue()` and `open()`: the raw token, a token without a request, a token opened by hand. :: ::card{title="Hooks" icon="i-lucide-webhook" to="/frameworks/hooks"} `onRead`, `onUpdate`, `onClear`, `onExpire`, `onError` and the key lookup hooks. :: ::card{title="Write a driver" icon="i-lucide-cable" to="/frameworks/drivers"} Run the same sessions on a framework unjwt does not ship. :: ::card{title="h3 adapters" icon="i-lucide-archive" to="/frameworks/adapters"} `unjwt/adapters/h3` and `h3v1`, deprecated: what they are until 0.9 removes them. :: ::

| Runtime | The event `use()` takes | Handlers and helpers come from | Page |
| --- | --- | --- | --- |
| h3 v2, a `new H3()` app | the `H3Event` | `h3` | [h3](/frameworks/h3) |
| Nitro 3 | the `H3Event` | `nitro`, `nitro/h3` | [Nitro](/frameworks/nitro) |
| Nuxt 4.6+ on Nitro 2 | the `RequestEvent` a `nuxt/server` handler receives | `nuxt/server` | [Nuxt](/frameworks/nuxt) |
| Nuxt 5 on Nitro 3 | the same `RequestEvent` | `nuxt/server` | [Nuxt](/frameworks/nuxt) |
| Elysia | the handler context | `elysia` | [Elysia](/frameworks/elysia) |
| Workers, Bun, Deno, Hono | `{ req, res: { headers } }` you build | the runtime | [Web standard sessions](/frameworks/session) |

## How they fit together

```ts
import { H3 } from "h3";
import { defineJWSSession, generateJWK } from "unjwt/session";

const key = await generateJWK("HS256"); // load a persisted key in production
const useSession = defineJWSSession({ key, maxAge: "1h" });

export const app = new H3()
  .post("/login", async (event) => {
    const session = await useSession.use(event);
    session.id; // undefined
    await session.update({ userId: "u_123", role: "admin", jti: "mine", exp: 1 });
    return { id: session.id }; // { id: "ea346d56-…" }
  })
  .get("/me", async (event) => {
    const session = await useSession.use(event);
    return { id: session.id, data: session.data };
  });
```

A session is defined once, at module scope, and `use()` opens it on each request. Every entry's definition has the same four methods: `use()` returns the session manager below, `token()` the raw token the request carries, `issue(data)` mints a token without a request, and `open(token)` reads one back. On Elysia the definition also has `plugin()`, which `jwsSession()` and `jweSession()` return. See [the definition](/frameworks/definition) and, for `plugin()`, [Elysia](/frameworks/elysia/lower-level).

`unjwt/session` runs the same definitions on a web-standard request event: `use({ req, res })` reads the `Request` and writes `Set-Cookie` onto `res.headers`, the shape h3 v2 and Nuxt's `nuxt/server` hand a handler; `use(request)` reads alone, and `use({ token })` opens a token you already hold. Its `defineSessionDriver()` and `{ driver }` option bind the session core to any other framework. See [Web standard sessions](/frameworks/session) and [Write a driver](/frameworks/drivers).

`POST /login` answers with one cookie:

```http
Set-Cookie: unjwt-jws=eyJraWQiOiJyczF1aDBx…; Path=/; Expires=Thu, 15 Oct 2026 13:47:02 GMT; Secure; SameSite=Lax
```

The cookie value is a compact JWS. Decoded, it carries:

```ts
// protected header
{ kid: "rs1uh0qjoT…", typ: "session+jwt", alg: "HS256" }
// payload
{ userId: "u_123", role: "admin", jti: "ea346d56-…", iat: 1792068422, exp: 1792072022 }
```

The `jti: "mine"` and `exp: 1` passed to `update()` are gone: the session owns those claims. Sending that cookie to `GET /me` returns `{ id: "ea346d56-…", data: { userId: "u_123", role: "admin" } }`.

## The session manager

Every entry hands out the same session manager.

| Member | Type | What it holds |
| --- | --- | --- |
| `id` | `string \| undefined` | The token's `jti`. `undefined` until a token exists. |
| `createdAt` | `number` | The token's `iat`, in milliseconds. |
| `expiresAt` | `number \| undefined` | The token's `exp`, or `iat + maxAge` when sooner, in milliseconds. Always a number when `maxAge` is set. |
| `data` | `SessionData<T>` | The payload without `jti`, `iat`, `exp` and `nbf`. |
| `token` | `string \| undefined` | The compact token this request carries or issued. |
| `update(data?, { maxAge? }?)` | `Promise<SessionManager>` | Merges `data`, issues a new token, writes the cookie of a cookie session. |
| `clear()` | `Promise<SessionManager>` | Expires the cookie and empties the session for the rest of the request. |

Sessions are lazy. Opening a session reads the incoming token if there is one and writes nothing, unless that token has expired. A visitor without a cookie gets an empty session whose `id` is `undefined`, and no `Set-Cookie` goes out until `update()` runs.

Every `update()` issues a new token with a fresh `jti`, `iat` and `exp`, so `id` changes on each update. `maxAge` counts from the latest update.

## Reserved claims

`jti`, `iat`, `exp` and `nbf` belong to the session. It mints `jti` (with `crypto.randomUUID()` or your `generateId`), `iat` and `exp` on every issue and never writes `nbf`. The four are stripped from every `update()` payload, dropped from `session.data` on every issue even when written there directly, and never read back out of a token into `session.data`.

Update data must be a plain object. An array, a string, or a `__proto__`, `constructor` or `prototype` key throws `ERR_INVALID_OPTION`. Data travels as JSON, so the next request sees the JSON round trip of what you stored: a `Date` comes back as an ISO string, `NaN` as `null`, and `undefined` members are dropped.

## Explicit typing

Every session token carries `typ: "session+jwt"` in its protected header, and an incoming token must carry it too. This is the explicit typing that [RFC 8725 §3.11](https://www.rfc-editor.org/rfc/rfc8725#section-3.11) recommends. Without it, any other JWT signed or encrypted with the same key (an API token, an email link) would read as a valid session. The comparison is case-insensitive and accepts an `application/` prefix. Set another value through `jws.signOptions.protectedHeader.typ` or `jwe.encryptOptions.protectedHeader.typ`.

A token with another `typ` is refused as `ERR_SESSION_TOKEN_INVALID` and reaches [`onError`](/frameworks/hooks), whose `cause` reads `Invalid JWS: "typ" mismatch, expected "session+jwt".`

## Lifetime

`maxAge` (a number of seconds or a string such as `"15m"`, `"1h"`, `"7D"`) sets `exp = iat + maxAge` on every issue. With `maxAge` set, an incoming token must carry `exp`, and it expires at its `exp` or at `iat + maxAge`, whichever comes first: the reading side's `maxAge` bounds a token whose `exp` lies further out, however it was minted. `expiresAt` holds that instant. `session.update(data, { maxAge: "5m" })` gives one token a shorter lifetime of its own; a longer one still ends at `iat + maxAge` when read back. Without any `maxAge` the token's `exp`, if any, decides alone.

The cookie follows the token. A token with an `exp` sets the cookie's `Expires` to that instant and drops a configured `cookie.maxAge`, because `Max-Age` would win over `Expires` ([RFC 6265 §5.3](https://www.rfc-editor.org/rfc/rfc6265#section-5.3)). A token without `exp` writes the cookie options as configured.

`rolling` moves `exp` forward with activity: `rolling: true` reissues the token on every request, `rolling: "15m"` once the token is at least that old. See [rolling expiry](/frameworks/hooks#rolling-expiry).

An expired token fires [`onExpire`](/frameworks/hooks#onexpire), expires the cookie, and leaves an empty session. A token issued in the future, an `iat` later than now plus `clockTolerance`, is refused whether or not a `maxAge` is set ([RFC 7519 §4.1.6](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.6)); it reaches `onError` as `ERR_SESSION_TOKEN_INVALID` with the message `Invalid session token: issued in the future (iat: …)`.

## Where the token comes from

The session header comes first. `sessionHeader` names it: a header name, or `true` for `x-<name>-session` with any `__Host-` or `__Secure-` prefix removed from the name (`"__Host-app"` reads `x-app-session`). Left unset, a session with cookies still reads `x-<name>-session`. When the request carries that header, its token is the credential and the cookie is not read; the cookie is read only when the header is absent. Set `sessionHeader: false` to read cookies only. Set `cookie: false` to read the header only; `sessionHeader` unset then reads `x-<name>-session` too. With both disabled the session has no token source: `use()` and `token()` throw `ERR_SESSION_CONFIG_INVALID`, while `issue()` and `open()` still work.

A session read from the header keeps its token there and never writes a cookie. `update()` and a `rolling` reissue mint the new token onto `session.token`, `clear()` empties the session, and no `Set-Cookie` goes out, an existing session cookie included; returning the new token to the client is the handler's job. Such a session updates on a request without response headers too.

The header holds the bare token or `Bearer <token>`, with the scheme matched case-insensitively. Any other value (`Basic …`, a lone `Bearer`, a blank value) is a token failure: it reaches [`onError`](/frameworks/hooks#onerror) as `ERR_SESSION_TOKEN_INVALID`, the session is empty, and the cookie is neither read nor expired. Name a header no other layer of the stack sets: with `sessionHeader: "authorization"`, a request carrying HTTP Basic credentials has no session, whatever its cookie holds.

Prefer a `__Host-` cookie name such as `"__Host-session"`. Every entry reads the first cookie of a name, and the prefix stops a sibling subdomain from planting a cookie that shadows the session ([RFC 6265bis §4.1.3](https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-4.1.3)). The default cookie options (`Secure`, `Path=/`, no `Domain`) already satisfy it, and the rules are enforced when the session is opened: a `__Host-` name with a `domain`, a `path` other than `/` or `secure: false`, or a `__Secure-` name with `secure: false`, throws `ERR_SESSION_CONFIG_INVALID` on every adapter instead of emitting a cookie browsers drop.

```ts
await defineJWSSession({ key, name: "__Host-app", cookie: { domain: "example.com" } }).use(event);
// JWTError ERR_SESSION_CONFIG_INVALID: [unjwt/session] Session "__Host-app": a "__Host-" cookie must be Secure, with Path=/ and no Domain (RFC 6265bis §4.1.3).
```

## Chunked cookies

Browsers cap a cookie at about 4 KB. A token longer than `cookie.chunkMaxLength` (default 4000) is split. The base cookie holds `__chunked__N` and the parts go to `<name>.1` through `<name>.N`:

```http
Set-Cookie: __Host-app=__chunked__4; Path=/; Secure; SameSite=Lax
Set-Cookie: __Host-app.1=eyJraWQiOiJkd0VnVzRO…; Path=/; Secure; SameSite=Lax
Set-Cookie: __Host-app.2=4eHh4eHh4eHh4eHh4eHh…; Path=/; Secure; SameSite=Lax
Set-Cookie: __Host-app.3=4eHh4eHh4eHh4eHh4eHh…; Path=/; Secure; SameSite=Lax
Set-Cookie: __Host-app.4=xNzkyMDY4NDU5fQ.yFfD…; Path=/; Secure; SameSite=Lax
```

A token is split into at most 16 chunks. A longer one throws `ERR_SESSION_CONFIG_INVALID` (`Session token requires 93 cookie chunks, exceeding the limit of 16.`). When a new token needs fewer chunks than the old one, the surplus chunks are expired in the same response. Because a session name ending in a dot and digits (`app.1`) would spell another session's chunk, such a name throws `ERR_SESSION_CONFIG_INVALID`. Every entry writes the same layout, so a cookie written by one reads in the others.

## Clearing

`clear()` and an expired read write `Max-Age=0` plus an epoch `Expires` on the base cookie and on every chunk. `clear()` expires the cookie and does not invalidate the token: a copy of the token is still valid until its `exp`, and revocation is [`onRead`](/frameworks/hooks#onread) plus your own store. Write a session inside the handler, before the response is returned: a response that already left takes no cookie, and a read made after it observes without writing. A context that cannot take a cookie at all (a WebSocket upgrade, a request without response headers) refuses `update()`, while `clear()` empties the session for the rest of that context and leaves the cookie to the next request.

```http
Set-Cookie: unjwt-jws=; Max-Age=0; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; SameSite=Lax
```

## JWS or JWE sessions

|  | JWS session | JWE session |
| --- | --- | --- |
| Token | Signed. Anyone holding the cookie can read the payload. | Encrypted. Only the server can read the payload. |
| Default cookie | `Path=/`, `Secure`, `SameSite=Lax`, readable by scripts | `Path=/`, `Secure`, `SameSite=Lax`, `HttpOnly` |
| Use it for | Data the browser may read: display name, role, theme. | Anything private: user IDs, emails, refresh grants. |

A JWS session cookie is readable by scripts on purpose, because its payload is public anyway. Set `cookie: { httpOnly: true }` if your front end has no use for it.

## Keys

The `key` option takes key material, never a password. A string throws `ERR_SESSION_KEY_INVALID`:

```text
[unjwt/session] Session "key" cannot be a string, use deriveJWKFromPassword().
```

| Session | Accepted `key` |
| --- | --- |
| JWS | An HMAC `oct` JWK (`HS256`, `HS384`, `HS512`). A `{ privateKey, publicKey }` pair (`RS*`, `PS*`, `ES*`, `Ed25519`), where `publicKey` may be one public JWK, a list, or a JWK Set. A `CryptoKey`. |
| JWE | A symmetric `oct` JWK, such as one from `generateJWK("A256GCM")` (sealed with `dir`), `generateJWK("A256KW")` or `deriveJWKFromPassword()`, or a symmetric `CryptoKey`. The one key seals and opens, and only the server holds it. |

::warning A JWE session also takes a `{ privateKey, publicKey }` pair (`RSA-OAEP-*`, `ECDH-ES*`), or a private JWK alone as `{ privateKey }`, which seals with its own public half. A pair seals with its public half, so anyone who holds the public key can mint a session the server accepts: the public half proves nothing about who sealed the token. Give a JWE session an `oct` JWK, and use a JWS session for a key pair, whose public half only verifies. ::

Use [`generateJWK()`](/jwk/generate) for a new key or [`deriveJWKFromPassword()`](/jwk/password) to turn a secret into one, once, at startup. Each entry entry re-exports both. A `CryptoKey` carries no algorithm, so it also needs `jws.signOptions.alg` (or `jwe.encryptOptions.alg` and `enc`), else `ERR_SESSION_CONFIG_INVALID`. A configured JWK is imported once per key object when the first session opens. One the session cannot use, such as a `k` that does not decode or a pair half on the wrong side, throws `ERR_SESSION_KEY_INVALID` from that call instead of turning every request into an `onError`.

Pin the algorithm in production with `jws.signOptions.alg` or `jwe.encryptOptions.alg` and `enc`. Issuing then uses it and reading accepts nothing else.

## Errors

Every error a session throws is a [`JWTError`](/errors). Check it with `isJWTError(err, code)`, imported from `unjwt` (the session entries do not re-export it).

| Code | When |
| --- | --- |
| `ERR_SESSION_KEY_INVALID` | `key` is missing, a string, the wrong shape, or cannot be imported. |
| `ERR_SESSION_CONFIG_INVALID` | A bad `name` or `sessionHeader`, a `__Host-` or `__Secure-` name whose cookie options break the prefix, no token source, an unpinned `CryptoKey`, more than 16 chunks, or one name used by a JWS and a JWE session on the same request. |
| `ERR_SESSION_TOKEN_INVALID` | An incoming token failed to verify or decrypt. Delivered to `onError` with the failure as `cause`, never thrown on read. |
| `ERR_SESSION_READONLY_EVENT` | `update()` on a context 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. |
| `ERR_JWT_EXPIRED` | A session token past its `exp`, or past `iat + maxAge`. Delivered to `onExpire`, never thrown. |
