Fix guide · high · jwt_no_expiration

JWT in client bundle has no exp claim

What this rule means

A JWT was found in your client JavaScript with no exp (expiration) claim. The token is valid forever as long as the signing secret stays valid. If the secret leaks or the token is exfiltrated, you have no time-based recovery.

Why it matters

A token without exp is a permanent credential. Combined with the fact that it's hardcoded in your bundle — public to every visitor — the only thing protecting your backend is the server's signing secret never leaking. Rotation of the signing secret invalidates every token (yours and the attacker's), which means rotation breaks production.

Long-lived credentials are why the OAuth refresh-token / access-token split exists. Access tokens should expire in 15-60 minutes; refresh tokens stay server-side and are exchanged for new access tokens. A no-exp JWT in the bundle conflates both into a single immortal credential.

How to fix it

Stop minting hardcoded tokens. If the token came from your auth flow, that flow should mint per-user tokens at login time and store them in localStorage / cookies, not in source.

When minting tokens, always include exp:

import { SignJWT } from "jose";

const token = await new SignJWT({
  sub: user.id,
  role: user.role,
})
  .setProtectedHeader({ alg: "RS256" })
  .setIssuedAt()
  .setExpirationTime("15m")           // ← always set this
  .setIssuer("https://your-app.com")
  .setAudience("your-app")
  .sign(privateKey);

For refresh-token flows, the refresh token expires in 7-30 days and is never sent to the browser; the access token expires in 15-60 minutes and is what the browser holds.

After fixing the mint flow:

  1. Rotate the signing secret to invalidate any existing no-exp tokens.
  2. Update your verifier to reject tokens with no exp (jose does this when you pass requiredClaims: ["exp"]).
  3. Audit logs for usage of pre-rotation tokens — those sessions should be re-authenticated.

Full guide: /blog/jwt-mistakes-vibe-coded.

Did vibecheck flag this on your app?

If you reached this page from a vibecheck inspection report, the redacted match in your scan output is the exact string we found in your bundle. After applying the fix above, run the inspection again — the finding should clear.

Run another inspection