Lifecycle hooks

Both useJWESession and useJWSSession accept a hooks object in their config — attach callbacks to key moments in the session lifecycle for logging, revocation tracking, key rotation, or side-effects.

shape.ts
useJWESession(event, {
  key: secret,
  hooks: {
    onRead({ session, event, config }) {
      /* ... */
    },
    onUpdate({ session, oldSession, event, config }) {
      /* ... */
    },
    onClear({ oldSession, event, config }) {
      /* ... */
    },
    onExpire({ session, event, error, config }) {
      /* ... */
    },
    onError({ session, event, error, config }) {
      /* ... */
    },
    onUnsealKeyLookup({ header, event, config }) {
      /* JWE: return a key */
    },
    // JWS uses `onVerifyKeyLookup` instead
  },
});

All hooks are async-compatible.

When each hook fires

HookFires whenMutually exclusive with
onReadA valid session token was decoded and loaded.onExpire, onError
onUpdateAfter a successful session.update() (token signed/encrypted).
onClearAfter an explicit session.clear() call.onExpire
onExpireA token's exp is in the past (clock-driven expiry).onRead, onClear
onErrorToken verification/decryption fails for a non-expiry reason.onRead
onExpiredoes not call clearSession. It invalidates the cookie inline and fires its hook. onClear is reserved for explicit termination (user logout, forced revocation) — these two events are semantically distinct and should trigger different downstream side effects.

onRead — every valid request

Fires once per request when a valid session token is loaded. Use for request-scoped logging, last-activity tracking, etc.

on-read.ts
hooks: {
  async onRead({ session, event }) {
    await metrics.increment("session.read", {
      userId: session.data.userId,
    });
  },
},

Only fires when the session was successfully decoded — failures go through onError or onExpire instead.

onUpdate — session created or rotated

Fires after session.update() succeeds. Receives both the new session and a snapshot of the old one:

on-update.ts
hooks: {
  async onUpdate({ session, oldSession, event }) {
    // First-time session creation: oldSession.id is undefined
    if (!oldSession.id) {
      await audit.log("session.created", { userId: session.data.userId });
    } else {
      await audit.log("session.rotated", {
        oldJti: oldSession.id,
        newJti: session.id,
      });
    }
  },
},

Useful for audit trails, detecting first-time logins, and syncing session state to external stores.

onClear — explicit termination

Fires after session.clear(). Receives the old session snapshot (may be undefined if no session existed):

on-clear.ts
hooks: {
  async onClear({ oldSession, event }) {
    if (oldSession?.id) {
      await revocationList.add(oldSession.id);
    }
  },
},

This is the logout path — users intentionally ending their session. onExpire is a different event (clock-driven, not user-driven) and doesn't fire here.

onExpire — natural expiry

Fires when a token's exp claim is in the past. Receives a snapshot of the expired token:

on-expire.ts
hooks: {
  async onExpire({ session, event, error, config }) {
    // session.id is the jti from the expired token (if the token was decodable)
    // session.expiresAt is the old exp × 1000 ms
    // error is a JWTError with code like "ERR_JWT_EXPIRED"
    if (session.id) {
      await revocationList.add(session.id);
    }
  },
},

onExpire is where the refresh-token pattern lives — check a separate refresh session and mint a new access token:

on-expire-refresh.ts
import { getJWESession, updateJWSSession } from "unjwt/adapters/h3v2";

hooks: {
  async onExpire({ event, config }) {
    const refresh = await getJWESession(event, refreshConfig);
    if (refresh.data.sub) {
      await updateJWSSession(event, config, {
        sub: refresh.data.sub,
        scope: refresh.data.scope,
      });
    }
  },
},

session.id in onExpire is populated from the expired token's jti when the token was cryptographically valid but past its exp. This lets you correlate expired-token events with issued-session records (e.g. to decrement an active-session counter).

onError — verification failures

Fires when a token exists in the cookie/header but fails verification for a reason other than expiry — bad signature, unknown kid, disallowed algorithm, malformed structure:

on-error.ts
hooks: {
  async onError({ session, event, error }) {
    logger.warn("session.verify.failed", {
      code: error.code,
      ip: event.request.headers.get("x-forwarded-for"),
    });
  },
},

Useful for tamper detection and fraud signals. The session argument is an empty manager (no data, id: undefined) because verification couldn't produce a valid session.

Key-lookup hooks

These are different — they don't fire on lifecycle events. They override the key used for verification / unsealing on a per-request basis.

onVerifyKeyLookup (JWS)

key-rotation-jws.ts
hooks: {
  onVerifyKeyLookup({ header, event, config }) {
    // Return a JWSVerifyJWK (HMAC oct or public asymmetric with a signing alg) or a JWKSet
    return keyStore.forKid(header.kid);
  },
},

Overrides the config.key just for verification — signing still uses config.key. Classic use: rotate signing keys while continuing to verify tokens signed with retired keys.

onUnsealKeyLookup (JWE)

key-rotation-jwe.ts
hooks: {
  async onUnsealKeyLookup({ header, event, config }) {
    // Return a symmetric or private JWK
    return await keyStore.forKid(header.kid!) ?? fallbackKey;
  },
},

Same idea for encrypted sessions — decryption uses the lookup result, encryption uses config.key.

For symmetric key rotation (JWE with pre-shared keys), rotate with overlap: your new sessions are encrypted with the new key, but onUnsealKeyLookup returns the old key when the token's kid matches. Once all old-key-wrapped tokens have expired, retire the old key.

Hook signatures

interface SessionHooksJWS<T, MaxAge, TEvent> {
  onRead?(args: {
    session: SessionJWS<T, MaxAge> & { id: string; token: string };
    event: TEvent;
    config: SessionConfigJWS<T, MaxAge, TEvent>;
  }): void | Promise<void>;

  onUpdate?(args: {
    session: SessionJWS<T, MaxAge> & { id: string; token: string };
    oldSession: SessionJWS<T, MaxAge>;
    event: TEvent;
    config: SessionConfigJWS<T, MaxAge, TEvent>;
  }): void | Promise<void>;

  onClear?(args: {
    oldSession: SessionJWS<T, MaxAge> | undefined;
    event: TEvent;
    config: SessionConfigJWS<T, MaxAge, TEvent>;
  }): void | Promise<void>;

  onExpire?(args: {
    session: {
      id: string | undefined;
      createdAt: number | undefined;
      expiresAt: number | undefined;
      token: string;
    };
    event: TEvent;
    error: Error;
    config: SessionConfigJWS<T, MaxAge, TEvent>;
  }): void | Promise<void>;

  onError?(args: {
    session: SessionJWS<T, MaxAge>;
    event: TEvent;
    error: any;
    config: SessionConfigJWS<T, MaxAge, TEvent>;
  }): void | Promise<void>;

  onVerifyKeyLookup?(args: {
    header: JWKLookupFunctionHeader;
    event: TEvent;
    config: SessionConfigJWS<T, MaxAge, TEvent>;
  }): JWKSet | JWSVerifyJWK | Promise<JWKSet | JWSVerifyJWK>;
}

SessionHooksJWE has the same shape; swap JWS types for JWE types, and onVerifyKeyLookup for onUnsealKeyLookup (which returns a JWEDecryptJWK).

See also