JWT mistakes in vibe-coded apps: 5 patterns that ship to production.
alg: "none" (anyone can forge them), tokens with no exp claim (valid forever once leaked), tokens with multi-month expiration (long-lived access tokens conflated with refresh tokens), hardcoded admin/service-role tokens shipped in client bundles (every visitor becomes admin), and stale test fixtures that survived the build (signal of a sloppy fixture pattern that probably contains live tokens too). Each pattern has a specific fix; none of them require swapping auth providers — they all come down to verifier configuration and not putting test fixtures in src/.
JWTs are the default token format for vibe-coded auth. Every popular auth library — @auth/core (NextAuth), better-auth, lucia-auth, Supabase Auth, Firebase Auth, Clerk, Auth0 — issues JWTs by default. The format is well-documented, well-supported, and well-suited to stateless backends. It's also subtle enough that "this code looks correct, the tests pass, the login flow works" can ship security holes that show up only when an attacker reads the bundle.
This post covers the five patterns vibecheck flags. Each section gives the failure mode, how to test for it from outside, and the exact verifier-side fix.
1. alg: "none" accepted by the verifier
The classic. JWT spec says the algorithm is declared in the header — and historically every major library trusted that header at verification time. An attacker took a real signed token, swapped the header's alg to "none", deleted the signature segment, and the verifier accepted the unsigned token because the header said it was unsigned, so why check?
This bug has been re-introduced into jsonwebtoken at least twice in the library's history, into pyjwt, into node-jsonwebtoken, and into custom implementations more often than into popular libraries. The fix is universal: pin the algorithm at verification time instead of reading it from the token.
Test from outside:
# Find a JWT in the bundle (any token will do).
curl -s https://your-app.com/ | grep -oE 'eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+' | head -1
# Decode the header. If it ever shows alg: "none", you have a code path
# that produces them — which means your verifier almost certainly accepts them.
echo '<token>' | cut -d. -f1 | base64 -d
Fix. Pin the algorithm whitelist at the verifier:
// jose (recommended)
import { jwtVerify, importSPKI } from "jose";
const key = await importSPKI(process.env.JWT_PUBLIC_KEY!, "RS256");
export async function verify(token: string) {
const { payload } = await jwtVerify(token, key, {
algorithms: ["RS256"], // explicit — rejects "none" and HS-vs-RS confusion
issuer: "https://your-app.com",
audience: "your-app",
requiredClaims: ["sub", "exp", "iat"],
});
return payload;
}
// jsonwebtoken (legacy but common)
import jwt from "jsonwebtoken";
export function verify(token: string) {
return jwt.verify(token, key, { algorithms: ["RS256"] });
}
The algorithms array is non-negotiable. Without it, the verifier reads alg from the token header and the entire JWT signature model collapses.
Dedicated fix-guide: /fix/jwt_alg_none.
2. No exp claim
A token without exp is valid forever as long as the signing secret is. Combined with the fact that the token is in your bundle — public to every visitor — your security model collapses to "the secret never leaks."
That's not a security model. That's a wish.
The pattern shows up because the auth flow code that mints tokens often looks like this:
// minus exp — works, ships, "I'll come back to this"
const token = jwt.sign({ sub: user.id, role: user.role }, secret);
...and the developer never comes back. The login works, tokens validate, the feature ships.
Fix. Always set exp at sign time. Use the library's helper rather than computing the timestamp manually:
// jose
const token = await new SignJWT({ sub: user.id, role: user.role })
.setProtectedHeader({ alg: "RS256" })
.setIssuedAt()
.setExpirationTime("15m") // ← always
.setIssuer("https://your-app.com")
.setAudience("your-app")
.sign(privateKey);
// jsonwebtoken
const token = jwt.sign({ sub: user.id, role: user.role }, secret, {
algorithm: "RS256",
expiresIn: "15m",
issuer: "https://your-app.com",
audience: "your-app",
});
And on the verify side, require exp via requiredClaims (jose) or by checking the decoded payload manually. Tokens without exp should be rejected, not silently accepted.
Dedicated fix-guide: /fix/jwt_no_expiration.
3. Long-lived access tokens (no refresh-token split)
Better than no exp: exp set to 30d or 90d. Worse than the right thing: short access tokens with separate refresh tokens.
The reason short tokens matter: when a user resets their password, your auth flow should invalidate their old sessions. With a 90-day token, the only ways to invalidate are (a) maintain a server-side denylist of revoked tokens — which means you've reinvented sessions and lost statelessness, or (b) rotate the signing secret — which logs 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 standard pattern: short-lived access tokens (15-60 minutes) paired with long-lived refresh tokens (7-30 days) that stay server-side. The browser holds the access token; the refresh token sits in an HttpOnly cookie. When the access token expires, the browser hits /api/refresh with the cookie, server validates, server mints a new access token. Refresh tokens are typically single-use — using one to mint a new access token also rotates the refresh token, so a stolen refresh cookie can be invalidated by the legitimate user's next refresh.
Fix.
// 1. Access token: short-lived, sent to browser
const accessToken = await new SignJWT({ sub: user.id, role: user.role })
.setProtectedHeader({ alg: "RS256" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(privateKey);
// 2. Refresh token: opaque random string, stored in DB, set as HttpOnly cookie
const refreshToken = crypto.randomBytes(32).toString("hex");
await db.refreshTokens.create({
userId: user.id,
tokenHash: await hash(refreshToken),
expiresAt: addDays(new Date(), 30),
});
response.cookies.set("refresh", refreshToken, {
httpOnly: true, secure: true, sameSite: "strict", maxAge: 30 * 86400,
});
Most modern auth libraries do this for you out of the box — Clerk, Auth0, better-auth, lucia-auth, Supabase Auth's built-in flow. If you're rolling your own JWT auth in 2026, you're probably doing more work than you need to. Adopt a library.
Dedicated fix-guide: /fix/jwt_long_lived.
4. Hardcoded admin / service tokens
The most damaging pattern. A developer needs an admin-shaped token to test a feature, generates one with a long expiration, drops it into the client code as a temporary fixture, and forgets to remove it. The token survives the build, ships in the bundle, and every visitor can extract it and present it to your backend with admin claims.
The shape vibecheck looks for in client bundles:
// Decoded JWT payload contains one of:
{ role: "admin" }
{ role: "service_role" }
{ role: "owner" }
{ scope: "admin write" }
{ is_admin: true }
{ is_superuser: true }
This is the JWT analogue of the Supabase service_role key leak: a credential meant to live server-side ended up in the client. The response shape is similar: rotate, audit, prevent.
Fix.
- Rotate the signing secret immediately. Console for your auth provider, or env vars for self-rolled JWT auth. Accept the user-facing logout — it's required.
- Remove the hardcoded token from source.
git log -S "<first-12-chars-of-token>"finds the commit. Delete. Push. - Audit access logs for usage. Any action taken with admin claims that wasn't yours should be reviewed.
- Stop generating long-lived admin tokens for testing. Mint fresh short tokens through the same flow real admins use. For CI tests, use a per-test fixture user with a token minted at test setup.
- Add a build guard. A custom rollup/vite plugin that decodes any JWT in the output and rejects builds containing tokens with privileged claims.
Dedicated fix-guide: /fix/jwt_admin_in_client.
5. Stale test fixtures
An expired JWT in the client bundle is technically harmless — your backend rejects it. The reason it's flagged is what surrounds it. Dev environments often have token-shaped constant blocks like:
// src/lib/test-tokens.ts — got included in the production build
export const TEST_USER_TOKEN = "eyJ...<expired in 2024>...";
export const STAGING_ADMIN_TOKEN = "eyJ...<valid until 2027>...";
export const PRODUCTION_FALLBACK = "eyJ...<valid forever>...";
If one of those is expired and visible to vibecheck, the others are probably visible too. Treat the expired-token finding as a signal to audit the surrounding file.
Fix.
- Find the surrounding file:
grep -rB5 -A5 "<first-12-chars>" src/. - If any token in the same block is still valid, treat it per its claims — privileged, no-exp, or long-lived.
- Move the fixtures to
test/fixtures/orscripts/. Verify your bundler isn't pulling those directories into production output (Vite and Webpack both exclude by default, but if you've customisedbuild.rollupOptions.inputorentry, double-check).
Dedicated fix-guide: /fix/jwt_expired_in_client.
What vibecheck does
The JWT detector decodes every JWT-shaped string in your bundle and runs the five checks above. Tokens issued by safe public-issuer flows (Supabase anon keys, etc.) are skipped — those are handled by their respective platform detectors. Findings link to per-rule fix-guides at /fix/jwt_*. Run a scan against any deploy and check the JWTs in the bundle section.
The five rules: jwt_alg_none, jwt_no_expiration, jwt_long_lived, jwt_admin_in_client, jwt_expired_in_client.