Hooks

Callbacks on config.hooks that run when a session is read, issued, cleared, expires or fails, plus two hooks that pick the key for an incoming token.

import { defineJWSSession } from "unjwt/adapters/h3v2";

const useSession = defineJWSSession({
  key,
  maxAge: "1h",
  hooks: {
    onUpdate: ({ session }) => audit.log("issued", session.id),
    onClear: ({ oldSession }) => audit.log("signed out", oldSession?.id),
    onExpire: ({ session }) => audit.log("expired", session.id),
  },
});

const session = await useSession.use(event);

These are unjwt session hooks. They are unrelated to Elysia's request lifecycle hooks (onRequest, beforeHandle, …) and to Nitro's runtime hooks.

#Signatures

Each hook receives one object. On h3 and on unjwt/session the request travels as event: the { req, res }, { headers } or { token } object passed to use(), or a driver's own event. On Elysia it travels as context. config is the session config the hook was registered on. Every hook may return a promise, and the adapter awaits it.

interface SessionHooks<
  TSession,
  TManager,
  TArgs /* { event } on h3 and unjwt/session, { context } on Elysia */,
  TConfig,
> {
  onRead?(
    args: TArgs & { session: TManager & { id: string; token: string }; config: TConfig },
  ): void | Promise<void>;
  onUpdate?(
    args: TArgs & {
      session: TManager & { id: string; token: string };
      oldSession: TSession;
      trigger: "update" | "rolling";
      config: TConfig;
    },
  ): void | Promise<void>;
  onClear?(
    args: TArgs & { oldSession: TSession | undefined; config: TConfig },
  ): void | Promise<void>;
  onExpire?(
    args: TArgs & {
      session: {
        id: string | undefined;
        createdAt: number | undefined;
        expiresAt: number | undefined;
        token: string;
      };
      error: Error;
      config: TConfig;
    },
  ): void | Promise<void>;
  onError?(args: TArgs & { session: TManager; error: any; config: TConfig }): void | Promise<void>;

  // JWS sessions only
  onVerifyKeyLookup?(
    args: Partial<TArgs> & { header: JWKLookupFunctionHeader; config: TConfig },
  ): JWKSet | JWSVerifyJWK | Promise<JWKSet | JWSVerifyJWK>;
  // JWE sessions only
  onUnsealKeyLookup?(
    args: Partial<TArgs> & { header: JWEHeaderParameters; config: TConfig },
  ): JWEDecryptJWK | Promise<JWEDecryptJWK>;
}

TManager is the session manager, the object use() returns: live id, data, expiresAt and token, plus update() and clear(). A hook acts on the session in place. TSession is a snapshot of the state (id, createdAt, expiresAt, data, token) taken before the change.

#When each hook fires

HookFiresCan it change the outcome?
onReadOnce per request, when the session is read from its token.No. A throw rejects the read.
onUpdateAfter a new token is issued and, on a cookie session, its cookie written, by update() or by rolling.A throw rejects update(), and the new session stays in place.
onClearAfter clear() empties the session and expires the cookie, where the context takes one.A throw rejects clear(). The cookie is already expired.
onExpireWhen the incoming session token is past its exp, or past iat + maxAge, after the cookie is expired.No. The session is already empty.
onErrorWhen an incoming token fails for any other reason, and when issuing a token fails.No. On the write path the error is thrown again after the hook.
onVerifyKeyLookup / onUnsealKeyLookupBefore an incoming token is verified or decrypted.It chooses the key.

For one incoming token exactly one of onRead, onExpire and onError fires. onClear only follows an explicit clear(), never expiry.

#Ordering

This run logs every hook across a sign-in, a request that reads the session twice, an update, a sign-out, an expired token and a tampered one:

login             onUpdate undefined -> 1405756a data={"user":"alice"} old={}
read twice        onRead id=1405756a
update(fn)        onRead id=1405756a
                  onUpdate 1405756a -> e6b0f077 data={"user":"alice","visits":1} old={"user":"alice"}
logout            onRead id=e6b0f077
                  onClear old=e6b0f077
expired token     onExpire id=62f41286 exp=1792073932000 created=1792070332000 ERR_JWT_EXPIRED
tampered token    onError ERR_SESSION_TOKEN_INVALID cause=ERR_JWS_SIGNATURE_INVALID

The token is read once per request, so the request that opened the session twice logged one onRead. Opening the session is a read, so onRead comes before onUpdate or onClear in the same request. Every hook runs after the session state has settled, so a hook may open the same session again: it gets the same session back, and onRead does not fire a second time.

#onRead

onRead fires once per request, the first time the session is read from a valid token. Use it for activity tracking, or to refuse a session the application has revoked: session is the manager, so the hook can end it on the spot.

const config: SessionConfigJWS = {
  key,
  maxAge: "20m",
  hooks: {
    async onRead({ session }) {
      if (await revoked.has(session.id)) await session.clear();
    },
  },
};

A cleared session leaves the handler with an empty one, and the response expires the cookie. A context that cannot take a cookie, such as a WebSocket upgrade, clears the same way and leaves the cookie to the next request. For sliding expiry, set rolling instead of reissuing from the hook.

#Rolling expiry

rolling reissues a session that was just read, so its exp moves forward with activity. rolling: true reissues on every request. A duration such as rolling: "15m" reissues once the token is at least that old, so an active session is re-signed at most once per duration, and a user is signed out only after maxAge without activity past that first window. It must be shorter than maxAge. Either form needs a maxAge. Neither caps the session's total life: one read per maxAge keeps it alive, so an absolute limit is yours to enforce, for example a sign-in time kept in the session data and checked in onRead.

rolling: true is express-session's rolling, which resets the cookie's lifetime on every response. The duration form is the sliding expiration of ASP.NET or h3's own idleTimeout, with the age that triggers it made explicit.

const config = { key, maxAge: "20m", rolling: "15m" } satisfies SessionConfigJWS;

The same handler, reading a fresh token and then one issued sixteen minutes earlier:

fresh token, 20 min left     onRead b38d8422 age: 1 s
                             {"id":"b38d8422","expiresAt":1792078773000}   no Set-Cookie
token issued 16 min ago      onRead 13331857 age: 960 s
                             onUpdate rolling 13331857 -> 8bfc8a9c
                             {"id":"8bfc8a9c","expiresAt":1792079733000}   Set-Cookie: h3-jws=eyJraWQiOiJmc…

A roll runs after onRead, and only if the hook left the session in place. A read on an event that cannot take a cookie does not roll: the client still holds the same token, so the next request rolls it. onUpdate sees it with trigger: "rolling".

#onUpdate

trigger says what issued the token: "update" for a call to update(), "rolling" for a roll. oldSession is a deep copy taken before the update, so it keeps the previous id, data and token. On a first sign-in its id is undefined. A throwing onUpdate makes update() reject with that error. The new token and cookie stay, because they were already written:

hooks: {
  onUpdate: () => {
    throw new Error("db down");
  };
}
// await session.update({ a: 1 }) rejects with "db down"
// session.id: "bb9f88d5-…", session.data: { a: 1 }, Set-Cookie sent

#onClear

oldSession is the session before the clear, or undefined when there was no session to clear. Use it for explicit sign-out bookkeeping, such as revoking oldSession.id in a deny list.

#onExpire

The adapter expires the cookie, replaces the session with an empty one, then calls onExpire. session carries what the expired token held: id is its jti, and createdAt and expiresAt are its iat and its expiry in milliseconds: exp, or iat + maxAge when sooner. error is the ERR_JWT_EXPIRED JWTError:

JWT "exp" is in the past (2026-10-15T11:47:59.000Z).

Only a token that is otherwise a valid session reaches onExpire. An expired token with a bad signature or another typ goes to onError.

#onError

On the read path error is always an ERR_SESSION_TOKEN_INVALID JWTError whose cause is the failure underneath (ERR_JWS_SIGNATURE_INVALID, ERR_JWE_DECRYPTION_FAILED, a typ mismatch, …). The session stays empty and the cookie that carried the token is dropped, as an expired one's is, so the client stops sending it. The one failure that keeps the cookie is a key lookup hook that threw (cause ERR_JWK_KEY_NOT_FOUND with the hook's error as its own cause): that says the key store is down, not that the token is bad, and dropping cookies then would sign everyone out.

On the write path error is whatever issuing threw, such as ERR_SESSION_CONFIG_INVALID for a token that needs more than 16 chunks. session is rolled back to its state before the update, and update() throws the same error after the hook returns.

Key problems never reach onError. A key the session cannot use throws ERR_SESSION_KEY_INVALID from the call that opened the session.

#Key lookup hooks

onVerifyKeyLookup (JWS) and onUnsealKeyLookup (JWE) receive the incoming token's protected header and return the key to check it with. They replace the configured key for reading. Issuing still uses key.

They are the only hooks that also run for open(), so they receive event (h3, unjwt/session) or context (Elysia) only when the token came through use(). open() passes none, and the request argument is typed as optional in these two hooks.

Rotate keys by tagging each with a kid and keeping the old ones until their tokens expire:

import { generateJWK, type SessionConfigJWS } from "unjwt/adapters/h3v2";

const previous = await generateJWK("HS256", { kid: "2026-09" });
const current = await generateJWK("HS256", { kid: "2026-10" });
const ring = new Map([
  [previous.kid, previous],
  [current.kid, current],
]);

const config = {
  key: current,
  jws: { signOptions: { alg: "HS256" } },
  hooks: {
    onVerifyKeyLookup: ({ header }) => {
      const key = header.kid === undefined ? undefined : ring.get(header.kid);
      if (!key) throw new Error(`No session key with kid ${JSON.stringify(header.kid)}`);
      return key;
    },
  },
} satisfies SessionConfigJWS;
// a cookie signed with "2026-09" still opens; new tokens are signed with "2026-10"

A throw from the hook fails the token, not the request. On use() it reaches onError as an ERR_SESSION_TOKEN_INVALID JWTError and the session stays empty. open() throws that same error. Its cause is an ERR_JWK_KEY_NOT_FOUND whose own cause is the error the hook threw.

Returning a JWK Set works for asymmetric public keys. A set of symmetric keys is refused by verify() unless jws.verifyOptions.allowSymmetricJWKSet is true and jws.signOptions.alg is pinned. Without the pin the adapter cannot infer which algorithms a set allows, and the token fails with Cannot infer allowed algorithms from this key, pass "algorithms". The same lookup on a JWE session returns one decryption key:

hooks: {
  onUnsealKeyLookup: ({ header }) => {
    const key = header.kid === undefined ? undefined : ring.get(header.kid);
    if (!key) throw new Error(`No session key with kid ${JSON.stringify(header.kid)}`);
    return key;
  };
}
// header.kid: "enc-1", header.alg: "dir", header.enc: "A256GCM"

#See also