# Elysia sessions

> 

Cookie-based JWT session management for [Elysia](https://elysiajs.com). The adapter ships as two
plugins — `jwsSession` (signed) and `jweSession` (encrypted) — that attach a ready-to-use session
manager to the request context, plus a guard macro for protected routes.

```ts
import { jwsSession, jweSession } from "unjwt/adapters/elysia";
```

<note>

Peer dep: `elysia` (`>=1.4.0`). The adapter is Web-Standard and runs anywhere Elysia's
`app.handle(Request)` does — Bun, Node (via `@elysiajs/node` for the server, not needed for
`handle`), Deno, Workers.

</note>

## Basic usage

`.use()` the plugin, then read `ctx.session` in any route below it:

```ts [basic.ts]
import { Elysia, t } from "elysia";
import { jweSession } from "unjwt/adapters/elysia";

const app = new Elysia()
  .use(jweSession({ key: process.env.SESSION_SECRET!, maxAge: "7D" }))
  .post(
    "/login",
    async ({ session, body }) => {
      await session.update({ userId: body.userId });
      return { id: session.id };
    },
    { body: t.Object({ userId: t.String() }) },
  )
  .get("/me", ({ session }) => session.data);
```

`session` is fully typed — `session.data`, `session.update()`, `session.clear()`, `session.id`, etc.
No need to call anything to obtain it; the plugin's scoped `resolve` populates it per request.

### Signed session (JWS)

```ts [jws.ts]
import { Elysia } from "elysia";
import { jwsSession, generateJWK } from "unjwt/adapters/elysia";

const keys = await generateJWK("RS256"); // persist these!

const app = new Elysia()
  .use(jwsSession({ key: keys, maxAge: "1h" }))
  .get("/me", ({ session }) => session.data); // JWS payload is client-readable
```

See [JWE vs JWS](/adapters) for when to choose which. Cookie defaults match the rest of the library:
JWE `httpOnly: true`, JWS `httpOnly: false` (the signed payload is meant to be readable).

## Protecting routes — the guard macro

Each plugin registers a guard macro named after its context key. The default key `"session"` yields
`requireSession`. Opt a route (or a `.guard` group) in, and unauthenticated requests get `401`
before the handler runs:

```ts [guard.ts]
const app = new Elysia()
  .use(jwsSession({ key: keys, maxAge: "1h" }))
  .get("/me", ({ session }) => session.data) // open
  .guard(
    { requireSession: true },
    (app) => app.get("/admin", ({ session }) => session.data), // 401 without a valid session
  );
```

The guard returns Elysia's `status(401)` (idiomatic — correct HTTP status and Eden/OpenAPI types),
it does not throw or redirect.

## Multiple sessions on one app

Run several sessions side by side — e.g. a short-lived **access** token (JWS, client-readable) and a
long-lived **refresh** token (JWE, opaque). Give each plugin a distinct `contextKey` **and** `name`:

```ts [access-refresh.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" }))
  .get("/me", ({ access, refresh }) => ({ user: access.data, sub: refresh.data.sub }));
```

- **contextKey** — the context property the session is exposed under (`ctx.access`, `ctx.refresh`).
Defaults to `"session"`.
- **name** — the cookie name (and the default `x-<name>-session` header). Distinct names keep the
cookies separate.

The guard macro is named per context key, so guards don't collide:

```ts
.guard({ requireAccess: true },  (app) => app.get("/api", ...))   // checks ctx.access
.guard({ requireRefresh: true }, (app) => app.get("/renew", ...)) // checks ctx.refresh
```

The macro name is `require` + the capitalized context key: `requireSession`, `requireAccess`,
`requireRefresh`, etc.

<warning>

When combining plugins you **must** set distinct `contextKey` and `name` on each. With defaults they
would both expose `ctx.session` (the later one wins) and reuse default cookie names.

</warning>

## Configuration

### JWS — `SessionConfigJWS`

```ts
interface SessionConfigJWS<T, MaxAge, TContext> {
  key:
    | JWK_oct<JWK_HMAC> // symmetric (HMAC) JWK
    | {
        privateKey: JWSAsymmetricPrivateJWK;
        publicKey: JWSAsymmetricPublicJWK | JWSAsymmetricPublicJWK[] | JWKSet;
      };

  maxAge?: MaxAge; // ExpiresIn duration; sets exp + cookie lifetime
  name?: string; // cookie name (default: "elysia-jws")
  contextKey?: string; // context property (default: "session")
  cookie?: false | (CookieAttributes & { chunkMaxLength?: number });
  sessionHeader?: false | string; // default: x-<name>-session; "Bearer " is stripped
  generateId?: () => string; // default: crypto.randomUUID()

  jws?: {
    signOptions?: Omit<JWSSignOptions, "expiresIn">; // set `alg` to pin the algorithm
    verifyOptions?: JWTClaimValidationOptions;
  };

  hooks?: SessionHooksJWS<T, MaxAge, TContext>;
}
```

### JWE — `SessionConfigJWE`

```ts
interface SessionConfigJWE<T, MaxAge, TContext> {
  key:
    | string // password for PBES2
    | JWEEncryptJWK // symmetric or public asymmetric JWK
    | { privateKey: JWEAsymmetricPrivateJWK; publicKey?: JWEAsymmetricPublicJWK };

  maxAge?: MaxAge;
  name?: string; // default: "elysia-jwe"
  contextKey?: string; // default: "session"
  cookie?: false | (CookieAttributes & { chunkMaxLength?: number });
  sessionHeader?: false | string;
  generateId?: () => string;

  jwe?: {
    encryptOptions?: Omit<JWEEncryptOptions, "expiresIn">; // set `alg` + `enc` to pin
    decryptOptions?: JWTClaimValidationOptions;
  };

  hooks?: SessionHooksJWE<T, MaxAge, TContext>;
}
```

<note>

**Pin algorithms** for production: set `jws.signOptions.alg` (or `jwe.encryptOptions.alg` + `enc`).
Signing/sealing uses them and verification/unsealing then accepts only those values. Without them,
allowed algorithms are inferred from the key's metadata.

</note>

## The session manager

```ts
interface SessionManager<T, ConfigMaxAge> {
  readonly id: string | undefined; // from jti — undefined until update() is called
  readonly createdAt: number; // from iat, in ms
  readonly expiresAt: ConfigMaxAge extends ExpiresIn ? number : number | undefined;
  readonly data: SessionData<T>; // payload (excludes jti/iat/exp)
  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);
```

Sessions are **lazy** — `session.id` is `undefined` until `session.update()` is first called.
Reading `ctx.session` without updating sets no cookie.

```ts [update.ts]
await session.update({ theme: "dark" }); // partial merge
await session.update((d) => ({ count: (d.count ?? 0) + 1 })); // updater fn
await session.update(); // rotate token (new jti/iat/exp), same data
await session.clear(); // expire the cookie + reset state, fires onClear
```

`clear()` (explicit termination) is distinct from token expiry — they fire `onClear` vs `onExpire`.

<note>

**Reserved claims.** `jti` and `iat` always overwrite same-named keys in your session data. `exp` is
written only when `maxAge` is set — without `maxAge`, an `exp` key in the data is carried into the
token as its real expiry (useful for per-session lifetimes; surprising if accidental).

</note>

## Cookies

Defaults: JWE `path: "/", secure: true, httpOnly: true`; JWS the same but `httpOnly: false`. Override
via `cookie`:

```ts [cookie-opts.ts]
jweSession({
  key: secret,
  maxAge: "7D",
  cookie: { sameSite: "strict", domain: ".example.com", chunkMaxLength: 3000 },
});
```

Large tokens (common for JWE with sizeable payloads) are transparently **chunked** across
`name.0`, `name.1`, … and reassembled on read (default 4000 bytes/chunk, override via
`cookie.chunkMaxLength`). Pass `cookie: false` for header-only mode.

## Header-based tokens

For clients that can't use cookies, read the token from a header (the default is
`x-<name>-session`; a leading `Bearer ` is stripped):

```ts
jweSession({ key: secret, sessionHeader: "authorization" });
```

`sessionHeader: false` disables header reads. When both are enabled, the header takes precedence,
then the cookie.

## Lifecycle hooks

`config.hooks` accepts the same set as the H3 adapters — `onRead`, `onUpdate`, `onClear`,
`onExpire`, `onError`, plus key-lookup hooks `onVerifyKeyLookup` (JWS) / `onUnsealKeyLookup` (JWE).

<note>

These are unjwt **session hooks** (`config.hooks.*`), not Elysia's request **lifecycle hooks**
(`onRequest`, `beforeHandle`, …). The names are unrelated.

</note>

Each hook receives `{ session, context, config }` (the Elysia `context`, not an H3 `event`).
`onRead`/`onExpire`/`onError` are mutually exclusive per incoming token; `onUpdate` fires after every
successful sign/seal (including a data-less token refresh) with a deep-cloned `oldSession`.

```ts [hooks.ts]
jweSession({
  key: secret,
  maxAge: "7D",
  hooks: {
    onUpdate: ({ session }) => db.sessions.upsert(session.id!, session.expiresAt),
    onExpire: ({ session }) => db.sessions.revoke(session.id), // session.id from error.cause.jti
  },
});
```

## Lower-level: manual `resolve` wiring

The plugins are built on `createJWSSession` / `createJWESession`, which take a minimal context
(`{ cookie, request }`) and return a `SessionManager`. Use them directly when you want to wire the
session into your own `resolve`/`derive`:

```ts [manual.ts]
import { createJWSSession } from "unjwt/adapters/elysia";

const app = new Elysia()
  .resolve(async ({ cookie, request }) => ({
    session: await createJWSSession({ cookie, request }, { key: keys, maxAge: "1h" }),
  }))
  .get("/me", ({ session }) => session.data);
```

<warning>

You must **destructure** `{ cookie, request }` in the `resolve` signature (rather than taking the
whole `ctx`). Elysia provisions context fields by statically scanning the hook source — if `cookie`
isn't referenced there, it won't be populated and the session can't read or write it.

</warning>

## Typing session data

```ts [typed.ts]
interface MyData {
  userId: string;
  role: "admin" | "user";
}

const app = new Elysia().use(jweSession<MyData>({ key: secret })).get("/me", ({ session }) => {
  session.data.userId; // string
  session.data.role; // "admin" | "user"
});
```

The generic threads through `update()` and every hook payload.

## See also

- [Adapters overview →](/adapters) — JWE vs JWS, the session manager, what adapters do.
- [Lifecycle hooks →](/adapters/hooks) — patterns (logging, revocation, key rotation) shared with H3.
- [Example: refresh token pattern →](/examples/refresh-token-pattern).
