Fix guide · medium · jwt_long_lived

JWT in client bundle has multi-month expiration

What this rule means

A JWT in your client bundle has an exp claim more than 30 days in the future. Access tokens should be short-lived (15m-1h); refresh tokens stay server-side. A long-lived access token is a credential that survives password changes and account compromises.

Why it matters

If a user resets their password, your auth flow should invalidate their old sessions. If a JWT lives for 90 days, the only way to invalidate it is to maintain a server-side denylist (defeating the purpose of stateless tokens) or to rotate the signing secret (logging out everyone). In practice, neither happens — and the user's old token stays valid against your backend until it expires, even though the password has changed.

The industry-standard pattern is access tokens at 15-60 minutes paired with refresh tokens at 7-30 days. The refresh token never enters the browser. The access token can be short because the refresh token can mint a new one without prompting the user.

How to fix it

Shorten access-token lifetime; introduce refresh tokens if you don't have them.

Access token (lives in browser, 15m):

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

Refresh token (lives in HttpOnly cookie, 30 days, server-side rotation tracking):

const refreshToken = crypto.randomBytes(32).toString("hex");
await db.refreshTokens.create({
  userId: user.id,
  tokenHash: hash(refreshToken),
  expiresAt: addDays(new Date(), 30),
});

response.cookies.set("refresh", refreshToken, {
  httpOnly: true,
  secure: true,
  sameSite: "strict",
  maxAge: 30 * 86400,
});

Refresh endpoint exchanges the cookie for a new access token, rotates the refresh token (one-time-use), and revokes the old one server-side. This is the standard pattern in every modern auth library — @auth/core, lucia-auth, better-auth, Clerk, Auth0, etc.

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