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.

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 })),
  );
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, thumbprint, importPEM, exportPEM and deriveJWKFromPassword.

#Signature

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 with Elysia cookie attributes and one more key.

OptionTypeDefaultWhat it does
keyJWK, key pair or CryptoKeyrequiredThe session key. See Keys.
maxAgeExpiresInnoneToken lifetime. Sets exp and the cookie's Expires.
rollingtrue | ExpiresInnoneReissues a session on read, so exp slides. true on every request, a duration once less than that remains. Needs maxAge. See rolling expiry.
namestring"elysia-jws" / "elysia-jwe"Cookie name, and the base of the default header.
contextKeystring"session"The context property the manager is exposed under. Also names the guard macro.
cookiefalse | 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.
sessionHeaderfalse | stringx-<name>-sessionHeader read when the cookie carries no token.
generateId() => stringcrypto.randomUUIDMints the jti of each new token.
jws / jwesign/verify or encrypt/decrypt overrides{}As on H3. Pin alg (and enc) here.
hooksSessionHooksJWS / SessionHooksJWEnoneSee 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. 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.

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:

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

CodeWhen
ERR_SESSION_KEY_INVALIDkey is missing, a password string, the wrong shape, or cannot be imported.
ERR_SESSION_CONFIG_INVALIDA 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_OPTIONupdate() data that is not a plain object, or a __proto__-like key in it.

Each is a JWTError. 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:

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

#See also

  • createJWSSession() to wire the session into your own resolve or derive.
  • Hooks for audit, revocation and key rotation.