The JWT Authentication Trap: How AI Assistants Get Auth Wrong (and How to Fix It) | Deployxa

AI assistants generate JWT auth code that works locally but is insecure in production. Here are the 7 most common JWT mistakes and how to fix them.

← Back to Dispatch Articles
Engineering Log

The JWT Authentication Trap: How AI Assistants Get Auth Wrong (and How to Fix It)

AI assistants generate JWT auth code that works locally but is insecure in production. Here are the 7 most common JWT mistakes and how to fix them.

The JWT Authentication Trap

Authentication is one of the most security-critical parts of any app, and it is also one of the areas where AI assistants make the most mistakes. AI assistants generate JWT auth code that works locally but is insecure in production, because they do not understand the security implications of their choices. The result is apps with weak secrets, no token expiry, no refresh tokens, tokens stored in localStorage (where they are vulnerable to XSS), and no CSRF protection. Here are the 7 most common JWT authentication mistakes AI assistants make, and how to fix each one.

The direct answer is that JWT (JSON Web Token) authentication is a standard way to authenticate users in SPAs and APIs, but it has a number of security pitfalls that AI assistants do not understand. The 7 most common mistakes are: weak JWT secrets, no token expiry, no refresh tokens, storing tokens in localStorage, no CSRF protection, no rate limiting on login, and no token revocation. Each one has a known cause and a known fix, and applying all 7 fixes gives you a production-ready auth system. For more on production readiness, see our article on why AI apps break on the first real user.

Mistake 1: Weak JWT Secrets

The most common JWT mistake is using a weak secret. AI assistants often generate secrets like secret, my-secret, or supersecret, which are trivially easy to guess or brute-force. An attacker who knows the secret can forge JWTs and impersonate any user, which is a catastrophic security breach. The fix is to use a strong, random secret of at least 256 bits (32 bytes), stored as an environment variable (e.g., JWT_SECRET). Generate a strong secret with openssl rand -base64 32 and set it in your Deployxa dashboard. Never commit the secret to your repository, and never hardcode it in your code. For more on environment variable management, see our article on the vibe coder's guide to environment variables.

Mistake 2: No Token Expiry

The second mistake is no token expiry. AI assistants often generate JWTs with no exp claim, which means the token is valid forever. If an attacker steals a token, they can use it indefinitely, even after the user changes their password. The fix is to set a short expiry (e.g., 15 minutes) on access tokens and to use refresh tokens for longer sessions. The access token is sent with every request and is short-lived, so if it is stolen, the attacker has only 15 minutes to use it. The refresh token is stored securely (in an httpOnly cookie) and is used to obtain new access tokens, so the user does not have to log in every 15 minutes. The refresh token should have a longer expiry (e.g., 7 days) and should be revocable (so you can log out a user remotely).

Mistake 3: No Refresh Tokens

The third mistake is no refresh tokens. Without refresh tokens, you have two bad options: either set a long access token expiry (which is insecure, because stolen tokens are valid for a long time), or set a short access token expiry (which is annoying, because users have to log in frequently). Refresh tokens solve this by providing a secure way to obtain new access tokens without requiring the user to log in again. The fix is to implement a refresh token flow: when the user logs in, issue both an access token (15-minute expiry) and a refresh token (7-day expiry). When the access token expires, the frontend uses the refresh token to obtain a new access token, transparently to the user. For more on authentication patterns, see our article on securing agentic cloud deployments.

Mistake 4: Storing Tokens in localStorage

The fourth mistake is storing tokens in localStorage. AI assistants often store JWTs in localStorage, because it is convenient (the token persists across page reloads and browser sessions). However, localStorage is accessible to JavaScript, which means any XSS (cross-site scripting) vulnerability in your app can steal the token. Since XSS vulnerabilities are common in AI-generated apps (which often include user-generated content), storing tokens in localStorage is a significant security risk. The fix is to store tokens in httpOnly cookies, which are not accessible to JavaScript and are therefore immune to XSS attacks. Set the cookie with httpOnly: true, secure: true (HTTPS only), and sameSite: 'strict' (to prevent CSRF). The trade-off is that httpOnly cookies are slightly more complex to manage (you need to set them on the server side, and you need to handle CSRF), but the security benefit is significant.

Mistake 5: No CSRF Protection

The fifth mistake is no CSRF (cross-site request forgery) protection. If you store tokens in cookies (as recommended above), you need CSRF protection, because cookies are sent with every request, including requests initiated by other sites. An attacker can create a form on their site that submits to your API, and if the user is logged in, the cookie is sent, and the request succeeds. The fix is to use CSRF tokens: include a random token in a meta tag (or a separate cookie), and require the frontend to send it in a custom header (e.g., X-CSRF-Token) with every request. Since the attacker's site cannot read the meta tag (due to the same-origin policy), they cannot send the CSRF token, and the request is rejected. For Express, use the csurf middleware. For FastAPI, use the starlette-csrf middleware.

Mistake 6: No Rate Limiting on Login

The sixth mistake is no rate limiting on login. AI assistants rarely add rate limiting to login endpoints, which means an attacker can brute-force passwords by trying thousands of combinations per second. The fix is to add rate limiting to the login endpoint: limit each IP to 5 login attempts per minute, and lock the account (or require a CAPTCHA) after 10 failed attempts. For Express, use express-rate-limit. For FastAPI, use slowapi. For more on rate limiting, see our article on why AI apps break on the first real user, which covers rate limiting as one of the 7 reasons AI apps break under load.

Mistake 7: No Token Revocation

The seventh mistake is no token revocation. JWTs are stateless, which means the server does not track which tokens are valid. Once a token is issued, it is valid until it expires, even if the user logs out or changes their password. This is a problem if a token is stolen, because the user cannot revoke it. The fix is to implement a token revocation list (a blacklist of revoked tokens) or to use short-lived access tokens with a revocable refresh token. The revocation list approach is simpler but requires server-side state (a database or Redis set of revoked tokens). The short-lived access token approach is stateless but requires refresh tokens, which adds complexity. For most apps, the short-lived access token approach is recommended, because it is stateless and the refresh token can be revoked by deleting it from the database.

Step-by-Step: Implementing Secure JWT Authentication

Here is the exact workflow for implementing secure JWT authentication in a typical AI-generated app.

Step 1: Generate a strong JWT secret

openssl rand -base64 32

Set the generated secret as JWT_SECRET in your Deployxa dashboard.

Step 2: Install the JWT library

For Node.js:

npm install jsonwebtoken bcryptjs

For Python:

pip install pyjwt passlib[bcrypt]

Step 3: Implement the login endpoint

// server.js (Express)
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const rateLimit = require('express-rate-limit');

const app = express();
app.use(express.json());

// Rate limit login endpoint
const loginLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute
  max: 5, // 5 attempts per minute
});

app.post('/login', loginLimiter, async (req, res) => {
  const { email, password } = req.body;
  const user = await User.findByEmail(email);
  if (!user || !await bcrypt.compare(password, user.passwordHash)) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  
  // Generate access token (15-minute expiry)
  const accessToken = jwt.sign(
    { userId: user.id },
    process.env.JWT_SECRET,
    { expiresIn: '15m' }
  );
  
  // Generate refresh token (7-day expiry)
  const refreshToken = jwt.sign(
    { userId: user.id, type: 'refresh' },
    process.env.JWT_SECRET,
    { expiresIn: '7d' }
  );
  
  // Store refresh token in database (for revocation)
  await RefreshToken.create({ userId: user.id, token: refreshToken });
  
  // Set refresh token in httpOnly cookie
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
  });
  
  // Return access token in response body
  res.json({ accessToken });
});

Step 4: Implement the refresh endpoint

app.post('/refresh', async (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  if (!refreshToken) {
    return res.status(401).json({ error: 'No refresh token' });
  }
  
  try {
    const payload = jwt.verify(refreshToken, process.env.JWT_SECRET);
    const stored = await RefreshToken.findByToken(refreshToken);
    if (!stored) {
      return res.status(401).json({ error: 'Invalid refresh token' });
    }
    
    const accessToken = jwt.sign(
      { userId: payload.userId },
      process.env.JWT_SECRET,
      { expiresIn: '15m' }
    );
    
    res.json({ accessToken });
  } catch (err) {
    return res.status(401).json({ error: 'Invalid refresh token' });
  }
});

Step 5: Implement the logout endpoint

app.post('/logout', async (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  if (refreshToken) {
    await RefreshToken.deleteByToken(refreshToken);
  }
  res.clearCookie('refreshToken');
  res.json({ status: 'ok' });
});

Step 6: Add CSRF protection

For Express, use the csurf middleware:

const csrf = require('csurf');
app.use(csrf({ cookie: true }));

app.get('/csrf-token', (req, res) => {
  res.json({ csrfToken: req.csrfToken() });
});

Step 7: Set environment variables

In the Deployxa dashboard, set:

  • JWT_SECRET: your strong secret
  • NODE_ENV: production

The pre-flight scanner will warn you if JWT_SECRET is missing.

Common Pitfalls and Troubleshooting

The first pitfall is using the same secret for access and refresh tokens. This is insecure, because if one secret is compromised, both token types are compromised. The fix is to use separate secrets for access and refresh tokens. The second pitfall is not validating the token type. An attacker might try to use a refresh token as an access token, or vice versa. The fix is to include a type claim in each token and to validate it on the server. The third pitfall is not handling token expiry gracefully. When an access token expires, the frontend should automatically use the refresh token to obtain a new one, without interrupting the user. The fix is to implement an axios interceptor that catches 401 errors and automatically calls the refresh endpoint. The fourth pitfall is not logging out users when the refresh token is revoked. If a user's refresh token is revoked (e.g., because they changed their password), the frontend should detect this and redirect to the login page. The fix is to handle 401 errors from the refresh endpoint by redirecting to login. The fifth pitfall is not testing the auth flow. Auth is security-critical, so you should test it thoroughly: test login, logout, token refresh, token expiry, and rate limiting. For more on testing, see our article on building a self-healing CI/CD pipeline.

Production Hardening for Auth

Beyond the 7 fixes, auth systems benefit from several additional hardening steps. The first is password hashing. Passwords should be hashed with a strong, slow algorithm (e.g., bcrypt, scrypt, argon2) that is resistant to brute-force attacks. Never use MD5 or SHA-1 for password hashing, because they are too fast and can be brute-forced. The second is password requirements. Enforce minimum length (at least 12 characters), complexity (mix of letters, numbers, symbols), and check against a list of common passwords (e.g., the Have I Been Pwned API). The third is multi-factor authentication (MFA). For sensitive apps (e.g., financial, healthcare), MFA should be required, not optional. Use TOTP (time-based one-time passwords) via an app like Google Authenticator, or SMS-based MFA as a fallback. The fourth is session management. Sessions should be tied to a specific device and should be revocable. The fix is to store session metadata (device, IP, location) in the database and to display it to the user, so they can revoke suspicious sessions. The fifth is audit logging. Log all auth events (login, logout, password change, MFA enable/disable) with the user ID, IP, and timestamp, so you can investigate security incidents. For more on security, see our articles on securing agentic cloud deployments and the rate limiting gap.

When JWT Is Not the Right Choice

JWT is not always the right authentication mechanism. For apps that need to revoke sessions immediately (e.g., banking apps), JWT's statelessness is a disadvantage, because you cannot revoke a token without a revocation list. The fix is to use server-side sessions (stored in a database or Redis), which can be revoked instantly. For apps that need to pass session data to multiple services (e.g., microservices), JWT is a good choice, because the token is self-contained and does not require a central session store. For apps with strict security requirements (e.g., government, healthcare), server-side sessions might be preferred, because they are easier to audit and revoke. The choice depends on your app's requirements: JWT for stateless, distributed apps; server-side sessions for apps that need instant revocation. For more on auth patterns, see our articles on the environment variable guide and why AI apps break on the first real user.

Conclusion: Auth Is Security-Critical, Get It Right

JWT authentication is one of the most security-critical parts of any app, and AI assistants make predictable mistakes that leave apps vulnerable. By applying the 7 fixes above (strong secret, token expiry, refresh tokens, httpOnly cookies, CSRF protection, rate limiting, token revocation), you can build a production-ready auth system that protects your users. Stop shipping insecure auth and start protecting your users.

Ready to deploy secure auth? Drag your project to Deployxa Drop for an instant live preview, or install the CLI with npm i -g @deployxa/cli and deploy from your terminal. For more on security, see our articles on securing agentic cloud deployments and the environment variable guide. Learn about why AI apps break on mobile and why AI apps have no SEO in our companion articles. Explore our free developer tools to speed up your workflow.

Ready to deploy with Deployxa?

Deploy your apps globally with automatic SSL and AI diagnostics.

Start Free Now