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.
playground/main.ts.Setup
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
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:
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:
- Valid →
accessSession.datais populated from the token. - Expired →
onExpireruns, (attempts a) refresh, andaccessSession.datais populated from the new token. The client receives a new cookie. - Invalid / missing →
accessSession.datais empty; your handler throws 401 as normal.
Logout — clear both
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:
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:
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;