Refresh token pattern

A common auth design: short-lived access tokens (minutes) for day-to-day calls, plus long-lived refresh tokens (days or weeks) that can mint new access tokens when the old one expires.

The shapes are deliberately different:

  • Access tokens are JWS — readable by the client (useful for UI: show "my profile" based on the payload), fast to verify.
  • Refresh tokens are JWE — the client holds them but can't inspect them; only your server can decrypt.

This example uses the H3 v2 session adapter so refresh is automatic on access-token expiry. If you're not using H3, the same pattern works — just call the lower-level getJWESession / updateJWSSession helpers manually.

Full source (a slightly different version of this) lives in the repo playground: playground/main.ts.

Setup

config.ts
import type { JWTClaims, SessionConfigJWE, SessionConfigJWS } from "unjwt/adapters/h3v2";
import { generateJWK } from "unjwt/adapters/h3v2";

// Access-token signing key (persist between deploys!)
const accessKey = await generateJWK("RS256", { kid: "at-2025" });

// Refresh-token encryption is password-based for simplicity
const refreshConfig = {
  key: process.env.REFRESH_SECRET!, // a long random string
  name: "refresh_token",
  maxAge: "7D",
  cookie: { httpOnly: true, secure: true, sameSite: "lax" },
} satisfies SessionConfigJWE;

const accessConfig = {
  key: accessKey,
  name: "access_token",
  maxAge: "15m",
  cookie: { httpOnly: false, secure: true, sameSite: "lax" },
  hooks: {
    async onExpire({ event, config }) {
      // Access token just expired — try to mint a new one from the refresh token
      const refresh = await getJWESession(event, refreshConfig);
      if (!refresh.data.sub) return; // no valid refresh session → stay logged out

      console.info("Access token expired, rotating…");
      await updateJWSSession(event, config, {
        sub: refresh.data.sub,
        scope: refresh.data.scope,
      });
    },
  },
} satisfies SessionConfigJWS<JWTClaims, "15m">;

The onExpire hook is where the magic lives. When a request comes in with an expired access token, unjwt:

Fires onExpire instead of onRead (they're mutually exclusive).

Your hook reads the refresh session, confirms it's valid, and calls updateJWSSession to mint a fresh access token.

The new token is set in the response cookies automatically — the client gets it on the same response.

Login — mint both tokens

login.ts
import { H3, HTTPError } from "h3";
import { useJWESession, useJWSSession } from "unjwt/adapters/h3v2";

const app = new H3();

app.post("/login", async (event) => {
  const refreshSession = await useJWESession(event, refreshConfig);
  const accessSession = await useJWSSession(event, accessConfig);

  // Already logged in? Return current state.
  if (accessSession.data.sub) {
    return {
      access: accessSession.data,
      refresh: refreshSession.data,
    };
  }

  const { username, password } = (await event.req.json()) as {
    username?: string;
    password?: string;
  };
  if (!username || !password) {
    throw new HTTPError("Username and password required", { status: 400 });
  }

  // TODO: validate against your user store
  const user = await validateCredentials(username, password);
  if (!user) throw new HTTPError("Invalid credentials", { status: 401 });

  const claims = { sub: user.id, scope: user.scopes.join(" ") };

  await refreshSession.update(claims);
  await accessSession.update(claims);

  return { access: accessSession.data, refresh: refreshSession.data };
});

Both sessions are lazy: calling useJWESession doesn't set a cookie. Only session.update() actually materializes the token and sets the cookie.

Protected route

Once logged in, every request with both cookies is handled automatically:

profile.ts
app.get("/profile", async (event) => {
  const accessSession = await useJWSSession(event, accessConfig);

  if (!accessSession.data.sub) {
    throw new HTTPError("Not authenticated", { status: 401 });
  }

  return { userId: accessSession.data.sub, scope: accessSession.data.scope };
});

If the access-token cookie is:

  • ValidaccessSession.data is populated from the token.
  • ExpiredonExpire runs, (attempts a) refresh, and accessSession.data is populated from the new token. The client receives a new cookie.
  • Invalid / missingaccessSession.data is empty; your handler throws 401 as normal.

Logout — clear both

logout.ts
app.post("/logout", async (event) => {
  const refreshSession = await useJWESession(event, refreshConfig);
  const accessSession = await useJWSSession(event, accessConfig);

  await accessSession.clear();
  await refreshSession.clear();

  return { ok: true };
});

Both cookies are expired on the response. On subsequent requests, neither session will have data.

Rotating keys without downtime

Because access tokens are signed (JWS), you can add key-rotation to the access config:

rotation.ts
const keys = { keys: [currentAccessKey, previousAccessKey] }; // JWKSet

const accessConfig = {
  key: currentAccessKey, // always sign with current
  // Ignore the overload: the verify path uses the hook below
  hooks: {
    onVerifyKeyLookup: () => keys, // verification tries both keys
  },
  // ...
} satisfies SessionConfigJWS;

Now tokens signed with previousAccessKey still verify, but new tokens are minted with currentAccessKey. Once the previous key's longest-lived token has expired, drop it from the set.

Refresh tokens (JWE) are trickier because you'd need to try multiple decryption keys — use onUnsealKeyLookup on the refresh config for the same pattern.

Revocation

For stronger logout guarantees (invalidate all sessions from a given user), use onExpire / onClear to track revoked jtis in a store:

revocation.ts
const accessConfig = {
  key: accessKey,
  maxAge: "15m",
  hooks: {
    async onExpire({ session }) {
      if (session.id) await revokedStore.add(session.id); // track expired jti
    },
    async onClear({ oldSession }) {
      if (oldSession?.id) await revokedStore.add(oldSession.id);
    },
    async onRead({ session }) {
      if (await revokedStore.has(session.id)) {
        throw new Error("Session revoked");
      }
    },
  },
  // ...
} satisfies SessionConfigJWS;

See also