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

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

<table>
<thead>
  <tr>
    <th>
      Hook
    </th>
    
    <th>
      Fires when
    </th>
    
    <th>
      Mutually exclusive with
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        onRead
      </code>
    </td>
    
    <td>
      A valid session token was decoded and loaded.
    </td>
    
    <td>
      <code>
        onExpire
      </code>
      
      , <code>
        onError
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        onUpdate
      </code>
    </td>
    
    <td>
      After a successful <code>
        session.update()
      </code>
      
       (token signed/encrypted).
    </td>
    
    <td>
      —
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        onClear
      </code>
    </td>
    
    <td>
      After an explicit <code>
        session.clear()
      </code>
      
       call.
    </td>
    
    <td>
      <code>
        onExpire
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        onExpire
      </code>
    </td>
    
    <td>
      A token's <code>
        exp
      </code>
      
       is in the past (clock-driven expiry).
    </td>
    
    <td>
      <code>
        onRead
      </code>
      
      , <code>
        onClear
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        onError
      </code>
    </td>
    
    <td>
      Token verification/decryption fails for a non-expiry reason.
    </td>
    
    <td>
      <code>
        onRead
      </code>
    </td>
  </tr>
</tbody>
</table>

<warning>

`onExpire` **does 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.

</warning>

## `onRead` — every valid request

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

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

```ts [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):

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

```ts [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](/examples/refresh-token-pattern) lives — check a separate refresh session and mint a new access token:

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

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

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

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

<tip>

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.

</tip>

## Hook signatures

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

- [H3 sessions →](/adapters/h3-sessions)
- [Lower-level functions →](/adapters/lower-level)
- [Example: refresh-token pattern →](/examples/refresh-token-pattern)
