
# Elysia sessions

> `jwsSession()` and `jweSession()` are Elysia plugins that put a session manager on every request's context and add a guard macro for protected routes.

```ts
import { Elysia, t } from "elysia";
import { generateJWK, jweSession } from "unjwt/adapters/elysia";

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

const app = new Elysia()
  .use(jweSession({ key, maxAge: "7D" }))
  .post(
    "/login",
    async ({ session, body }) => {
      await session.update({ userId: body.userId });
      return { id: session.id }; // { id: "d9a78052-…" }
    },
    { body: t.Object({ userId: t.String() }) },
  )
  .get("/me", ({ session }) => ({ id: session.id ?? null, data: session.data }))
  .post("/logout", async ({ session }) => {
    await session.clear();
    return { ok: true };
  })
  .guard({ requireSession: true }, (app) =>
    app.get("/admin", ({ session }) => ({ admin: session.data.userId })),
  );
```

```http
POST /login    Set-Cookie: elysia-jwe=eyJraWQiOiJzeTRmZXQ…; Path=/; Expires=Thu, 22 Oct 2026 12:49:31 GMT; HttpOnly; Secure; SameSite=Lax
GET /admin     401 Unauthorized                        (no cookie)
GET /admin     200 { "admin": "u_123" }                (with the cookie)
POST /logout   Set-Cookie: elysia-jwe=; Max-Age=0; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax
```

The adapter needs `elysia` `^1.4.29` as a peer dependency. It runs wherever `app.handle(request)` does: Bun, Node, Deno and Workers. The entry also re-exports [`generateJWK`](/jwk/generate), `thumbprint`, `importPEM`, `exportPEM` and [`deriveJWKFromPassword`](/jwk/password).

## Signature

```ts
function jwsSession<
  T extends Record<string, any> = SessionClaims,
  MaxAge extends ExpiresIn | undefined = ExpiresIn | undefined,
  K extends string = "session",
>(
  config: SessionConfigJWS<T, MaxAge> & { contextKey?: K },
): SessionPlugin<{ [P in K]: SessionManager<T, MaxAge> }, `require${Capitalize<K>}`>;

function jweSession<
  T extends Record<string, any> = SessionClaims,
  MaxAge extends ExpiresIn | undefined = ExpiresIn | undefined,
  K extends string = "session",
>(
  config: SessionConfigJWE<T, MaxAge> & { contextKey?: K },
): SessionPlugin<{ [P in K]: SessionManager<T, MaxAge> }, `require${Capitalize<K>}`>;
```

`contextKey` is required in the type whenever `K` is anything other than `"session"`.

## Options

The config is the [H3 config](/adapters/h3#options) with Elysia cookie attributes and one more key.

| Option          | Type                                              | Default                                                  | What it does                                                                                                                                                                        |
| --------------- | ------------------------------------------------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `key`           | JWK, key pair or `CryptoKey`                      | required                                                 | The session key. See [Keys](/adapters#keys).                                                                                                                                        |
| `maxAge`        | `ExpiresIn`                                       | none                                                     | Token lifetime. Sets `exp` and the cookie's `Expires`.                                                                                                                              |
| `rolling`       | `true \| ExpiresIn`                               | none                                                     | Reissues a session on read, so `exp` slides. `true` on every request, a duration once less than that remains. Needs `maxAge`. See [rolling expiry](/adapters/hooks#rolling-expiry). |
| `name`          | `string`                                          | `"elysia-jws"` / `"elysia-jwe"`                          | Cookie name, and the base of the default header.                                                                                                                                    |
| `contextKey`    | `string`                                          | `"session"`                                              | The context property the manager is exposed under. Also names the guard macro.                                                                                                      |
| `cookie`        | `false \| CookieAttributes & { chunkMaxLength? }` | `{ path: "/", secure: true, sameSite: "lax", httpOnly }` | `domain`, `expires`, `httpOnly`, `maxAge`, `path`, `priority`, `sameSite`, `secure`, `partitioned`. `httpOnly` defaults to `false` for JWS and `true` for JWE.                      |
| `sessionHeader` | `false \| string`                                 | `x-<name>-session`                                       | Header read when the cookie carries no token.                                                                                                                                       |
| `generateId`    | `() => string`                                    | `crypto.randomUUID`                                      | Mints the `jti` of each new token.                                                                                                                                                  |
| `jws` / `jwe`   | sign/verify or encrypt/decrypt overrides          | `{}`                                                     | As on [H3](/adapters/h3#options). Pin `alg` (and `enc`) here.                                                                                                                       |
| `hooks`         | `SessionHooksJWS` / `SessionHooksJWE`             | none                                                     | See [Hooks](/adapters/hooks). Hooks receive `context` instead of `event`.                                                                                                           |

## ctx.session

The plugin resolves the session before the handler runs, so `ctx.session` is the same manager as on H3: `id`, `data`, `createdAt`, `expiresAt`, `token`, `update()` and `clear()`. See [the session object](/adapters#the-session-object). Reading it sets no cookie. `session.id` stays `undefined` until `update()`.

The resolve is scoped, so `ctx.session` exists in the app that calls `.use()` and in its parent, and the guard macro works in both.

## The guard macro

Each plugin registers a macro named `require` plus the capitalized `contextKey`, so the default is `requireSession`. A route that opts in answers `401 Unauthorized` while the session has no `id`, before the handler runs. It returns Elysia's `status(401)`, so Eden and OpenAPI see the status. It does not throw or redirect.

```ts
app.guard({ requireSession: true }, (app) => app.get("/admin", ({ session }) => session.data));
app.get("/billing", ({ session }) => session.data, { requireSession: true });
```

## Several sessions on one app

Give each plugin its own `contextKey` and its own `name`. The guard names follow the context keys:

```ts
const app = new Elysia()
  .use(jwsSession({ key: accessKey, contextKey: "access", name: "at", maxAge: "10m" }))
  .use(jweSession({ key: refreshKey, contextKey: "refresh", name: "rt", maxAge: "7D" }))
  .post("/login", async ({ access, refresh }) => {
    await refresh.update({ sub: "u_1" });
    await access.update({ sub: "u_1", role: "user" });
    return { ok: true };
  })
  .guard({ requireAccess: true }, (app) => app.get("/api", ({ access }) => access.data));
```

```http
POST /login   Set-Cookie: rt=eyJraWQiOiJzeTRmZ…; Path=/; Expires=Thu, 22 Oct 2026 12:49:31 GMT; HttpOnly; Secure; SameSite=Lax
              Set-Cookie: at=eyJraWQiOiJublBFW…; Path=/; Expires=Thu, 15 Oct 2026 12:59:31 GMT; Secure; SameSite=Lax
GET /api      200 { "sub": "u_1", "role": "user" }   (with both cookies)
GET /api      401 Unauthorized                       (without)
```

With the defaults, two plugins would both claim `ctx.session` and the later one wins. The same goes for a `contextKey` that names a property Elysia already puts on the context, such as `"store"` or `"cookie"`: the session replaces it for every route behind the plugin, so pick a key of your own. Each plugin's Elysia identity is seeded from its `name` and `contextKey`, so two differently named sessions register side by side. A JWS and a JWE session under the same `name` on one request throw `ERR_SESSION_CONFIG_INVALID`, because one name is one cookie.

## The cookie jar

The adapter writes through Elysia's reactive `cookie` jar, so cookies leave with the response Elysia builds. It does not use Elysia's cookie signing. The token is already signed or encrypted, and the raw token is stored, split into chunks when it is long. The layout is the same as on H3, so a cookie written by one adapter reads in the other.

Elysia exposes no signal that the response has been sent, so `update()` and `clear()` never throw `ERR_SESSION_READONLY_EVENT` here. A write made after the response was built, for example in `onAfterResponse`, resolves and never reaches the client. Update the session in the handler, a `beforeHandle`, or an `afterHandle`.

## 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, no token source, an unpinned `CryptoKey`, a token over 16 chunks, one `name` for a JWS and a JWE session, or a cookie jar without reactive cookies. |
| `ERR_INVALID_OPTION`         | `update()` data that is not a plain object, or a `__proto__`-like key in it.                                                                                                                                                                             |

Each is a [`JWTError`](/errors). Errors from opening the session are thrown from the plugin's resolve, and errors from `update()` or `clear()` where you call them. Uncaught, both reach Elysia's own `onError`. Elysia's default error handler writes the error message into the `500` body, so a configuration mistake or a throw from one of your hooks is readable by the client unless you handle it:

```ts
import { isJWTError } from "unjwt";

app.onError(({ error, set }) => {
  if (isJWTError(error)) {
    console.error(error.code, error.message);
    set.status = 500;
    return "Internal error";
  }
});
```

The adapter's messages name the option at fault, not the key material or the token. An unreadable incoming token throws nothing and goes to [`onError`](/adapters/hooks#onerror) as `ERR_SESSION_TOKEN_INVALID`.

## See also

- [`createJWSSession()`](/adapters/elysia/lower-level) to wire the session into your own `resolve` or `derive`.
- [Hooks](/adapters/hooks) for audit, revocation and key rotation.
