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";
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:
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)
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:
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:
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 defaultx-<name>-sessionheader). 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.
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>;
}
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 lazy — session.id is undefined until session.update() is first called.
Reading ctx.session without updating sets no cookie.
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.
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:
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).
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.
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:
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);
{ 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
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 → — JWE vs JWS, the session manager, what adapters do.
- Lifecycle hooks → — patterns (logging, revocation, key rotation) shared with H3.
- Example: refresh token pattern →.