Am I Hackable?
Back to Learn

JWT Security: What Vibe Coders Get Wrong

Benji··5 min read

The short version

JSON Web Tokens (JWTs) are the standard way modern apps handle authentication. After you log in, the server gives you a signed token. You send it with every request to prove who you are. The server verifies the signature without hitting the database.

Simple concept. But JWTs have sharp edges, and vibe coded apps cut themselves on every single one.

What's inside a JWT

A JWT has three parts separated by dots: header, payload, and signature.

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3MTExMDAwMDB9.signature

The header says which algorithm was used. The payload contains claims like user ID, role, and expiration. The signature proves the token hasn't been tampered with.

The header and payload are just Base64 encoded JSON. They're not encrypted. Anyone can decode them. The signature is the only thing preventing someone from modifying the payload and giving themselves admin access.

Mistake #1: Storing tokens in localStorage

This is the most common mistake in vibe coded apps. Every tutorial shows it:

// After login - DON'T DO THIS
localStorage.setItem('token', response.data.token);

// On every request
axios.defaults.headers.common['Authorization'] = `Bearer ${localStorage.getItem('token')}`;

The problem: any JavaScript running on your page can read localStorage. If your app has a single XSS vulnerability, the attacker's script reads the token and sends it to their server. They now have full access to the user's account, and the token works until it expires.

The fix: store tokens in HttpOnly cookies.

// Server-side: set the token as a cookie
res.cookie('token', jwt, {
  httpOnly: true,   // JavaScript can't read it
  secure: true,     // Only sent over HTTPS
  sameSite: 'lax',  // CSRF protection
  maxAge: 3600000   // 1 hour
});

HttpOnly means JavaScript literally cannot access the cookie. XSS can still do damage, but it can't steal the token.

Mistake #2: Not validating the token properly

Some apps decode the JWT and trust whatever's inside without verifying the signature. This is like checking someone's name tag without confirming they actually work here.

// WRONG - just decoding, not verifying
const payload = JSON.parse(atob(token.split('.')[1]));
const userId = payload.user_id; // Trusting unverified data

// RIGHT - verify the signature
const payload = jwt.verify(token, process.env.JWT_SECRET);
const userId = payload.user_id;

If you only decode without verifying, an attacker can craft any payload they want. They can set role: "admin" or change their user_id to anyone else's.

Mistake #3: Weak or hardcoded secrets

AI generated code loves this pattern:

// Found in way too many repos
const JWT_SECRET = 'secret123';
// or
const JWT_SECRET = 'your-secret-key';

These get cracked in seconds. Tools like jwt_tool come with wordlists of common JWT secrets. If your secret is guessable, anyone can forge valid tokens for any user.

Use a cryptographically random secret of at least 32 bytes:

node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

Store it in an environment variable. Never commit it to your repository.

Mistake #4: No expiration or absurdly long expiration

// Token that never expires - DON'T
const token = jwt.sign({ userId: user.id }, SECRET);

// Token valid for 30 days - too long
const token = jwt.sign({ userId: user.id }, SECRET, { expiresIn: '30d' });

If a token is stolen, it works until it expires. No expiration means it works forever. The standard pattern is short lived access tokens with refresh tokens:

// Access token: 15 minutes
const accessToken = jwt.sign({ userId: user.id }, SECRET, { expiresIn: '15m' });

// Refresh token: 7 days, stored in database so it can be revoked
const refreshToken = jwt.sign({ userId: user.id }, REFRESH_SECRET, { expiresIn: '7d' });

When the access token expires, the client uses the refresh token to get a new one. If you need to revoke access (user changes password, suspicious activity), you invalidate the refresh token in the database.

Mistake #5: The "none" algorithm

The JWT spec includes an alg: "none" option that means "no signature." Some older libraries accepted this by default. An attacker could change the algorithm to "none," remove the signature, and the server would accept the forged token.

Modern libraries have fixed this, but make sure you explicitly specify the algorithm when verifying:

// Always specify the expected algorithm
const payload = jwt.verify(token, SECRET, { algorithms: ['HS256'] });

How Supabase and Firebase do it right

If you're using Supabase or Firebase Auth, you're already getting most of this handled correctly. They use RS256 with proper key management, set reasonable expiration times, and handle token refresh automatically.

But they don't handle access control. The JWT tells you WHO the user is. Your code decides WHAT they can do. If your API endpoint reads the user_id from the JWT but doesn't check whether that user should access the requested resource, you still have a [broken access control](/learn/glossary/broken access control) problem.

The checklist

Check your site

Want to know if your auth setup has gaps? Scan it now and find out in 60 seconds.

Frequently Asked Questions

Should I store JWTs in localStorage or cookies?
Cookies with HttpOnly, Secure, and SameSite=Lax flags. localStorage is readable by any JavaScript on the page, so a single XSS vulnerability lets an attacker steal the token. HttpOnly cookies can't be accessed by JavaScript at all.
What happens if my JWT secret is weak?
An attacker can brute-force it. Tools like jwt_tool and hashcat can crack short or common secrets in seconds. Use at least 256 bits of randomness for HMAC secrets, or use RS256 with a proper RSA key pair.
Do Supabase and Firebase handle JWT security for me?
They handle token generation, signing, and refresh flows correctly. But you still need to validate tokens server-side, check expiration, and implement proper access control based on the claims in the token.

Your AI writes the code. We find what it missed.

Paste your URL. Security audit in 60 seconds.

Scan my app