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

```ts
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](/frameworks/drivers) 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.

```ts
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

| Hook | Fires | Can it change the outcome? |
| --- | --- | --- |
| `onRead` | Once per request, when the session is read from its token. | No. A throw rejects the read. |
| `onUpdate` | After 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. |
| `onClear` | After `clear()` empties the session and expires the cookie, where the context takes one. | A throw rejects `clear()`. The cookie is already expired. |
| `onExpire` | When the incoming session token is past its `exp`, or past `iat + maxAge`, after the cookie is expired. | No. The session is already empty. |
| `onError` | When 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` / `onUnsealKeyLookup` | Before 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:

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

```ts
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`](#rolling-expiry) 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.

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

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

```text
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](#rolling-expiry). `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:

```ts
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`](/errors):

```text
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()`](/frameworks/definition#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:

```ts
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()`](/jwt/jws/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:

```ts
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

- [Adapters](/frameworks) for the session lifecycle the hooks hang off.
- [Refresh token pattern](/examples/refresh-token-pattern), which reissues an access session from `onExpire`.
