Elysia sessions

Cookie-based JWT session management for Elysia. 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.

import { jwsSession, jweSession } from "unjwt/adapters/elysia";
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.

Basic usage

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

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)

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

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:

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:

.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.

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.

Configuration

JWS — SessionConfigJWS

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

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>;
}
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.

The session manager

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 lazysession.id is undefined until session.update() is first called. Reading ctx.session without updating sets no cookie.

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.

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).

Cookies

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

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):

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).

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

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.

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:

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);
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.

Typing session data

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