Fix guide · medium · jwt_long_lived
JWT in client bundle has multi-month expiration
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