← All posts
JWTsecurityauthenticationweb developmentbest practices

JWT Security Best Practices: A Complete Guide for Developers

O

OverMCP Team

TL;DR: To securely handle JWT tokens in a web app, always store them in httpOnly cookies (not localStorage), use short expiration times with refresh tokens, validate the signature and claims on every request, and enforce HTTPS. Never trust the token's payload without verification.

---

JSON Web Tokens (JWTs) are everywhere in modern web apps — especially in vibe-coded applications built with AI tools. They're compact, self-contained, and easy to implement. But they're also easy to misuse. A single misconfiguration can expose your users' accounts to token theft, replay attacks, or privilege escalation.

This guide walks through the JWT security best practices you need to know — from storage and transmission to validation and rotation. Whether you're building with Next.js, Express, or a serverless framework, these principles apply.

Why JWT Security Matters

JWTs are bearer tokens: whoever possesses the token can access the resources it grants. If an attacker steals your JWT, they can impersonate the user until the token expires. Unlike session IDs, JWTs are often self-contained — they carry claims like userId and role in the payload. If you don't validate the signature, an attacker can forge arbitrary claims.

Common vulnerabilities include:

  • Storing tokens in localStorage (accessible to XSS)
  • Using long-lived tokens without rotation
  • Not validating the alg header (algorithm confusion attacks)
  • Leaking tokens in URLs or logs
  • ## JWT Security Best Practices: Step by Step

    1. Store Tokens in httpOnly Cookies (Not localStorage)

    Many tutorials tell you to store the JWT in localStorage after login. Don't. localStorage is accessible to any JavaScript running on your domain — including injected scripts from an XSS attack. httpOnly cookies are not accessible via JavaScript, so they're immune to XSS.

    Example (Express + cookie-parser):

    // Setting the cookie
    res.cookie('token', jwtToken, {
      httpOnly: true,
      secure: true,      // HTTPS only
      sameSite: 'strict', // CSRF protection
      maxAge: 15 * 60 * 1000 // 15 minutes
    });

    Frontend fetch:

    fetch('/api/protected', {
      credentials: 'include' // sends cookies
    });
    Trade-off: If you need to access the token client-side (e.g., for decoding claims), consider storing a separate non-sensitive copy in memory, but never in localStorage.

    2. Use Short Expiration + Refresh Tokens

    Short-lived access tokens (15 minutes or less) limit the damage if a token is stolen. But users don't want to log in every 15 minutes. Use a refresh token (long-lived, stored in a separate httpOnly cookie) to obtain new access tokens silently.

    Refresh token rotation: Issue a new refresh token each time one is used, and invalidate the old one. This prevents replay if a refresh token is stolen.

    // Refresh endpoint logic
    app.post('/refresh', async (req, res) => {
      const refreshToken = req.cookies.refreshToken;
      if (!refreshToken) return res.sendStatus(401);
      
      try {
        const payload = jwt.verify(refreshToken, REFRESH_SECRET);
        // Invalidate old refresh token (e.g., delete from DB)
        await invalidateRefreshToken(refreshToken);
        
        // Issue new tokens
        const newAccessToken = jwt.sign({ userId: payload.userId }, ACCESS_SECRET, { expiresIn: '15m' });
        const newRefreshToken = jwt.sign({ userId: payload.userId }, REFRESH_SECRET, { expiresIn: '7d' });
        await storeRefreshToken(newRefreshToken, payload.userId);
        
        // Set cookies
        res.cookie('token', newAccessToken, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 15*60*1000 });
        res.cookie('refreshToken', newRefreshToken, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 7*24*60*60*1000 });
        res.json({ ok: true });
      } catch (err) {
        res.sendStatus(403);
      }
    });

    3. Validate the Signature and Claims Every Time

    Never skip JWT verification on the server. Use a strong secret (at least 256 bits) and always specify the algorithm explicitly to avoid algorithm confusion attacks.

    // BAD: relies on the header's 'alg'
    const payload = jwt.verify(token, secret);
    
    // GOOD: explicitly require RS256 or HS256
    const payload = jwt.verify(token, secret, { algorithms: ['HS256'] });

    Also validate:

  • iss (issuer) matches your app
  • aud (audience) matches your app
  • exp (expiration) — the library does this, but double-check
  • Custom claims like role if needed
  • 4. Use Asymmetric Signing (RS256) for Microservices

    If you have multiple services that need to verify tokens, use a public/private key pair (RS256). The authentication service signs with the private key, and other services verify with the public key. This way, only the auth service can issue tokens.

    // Signing (auth service)
    const token = jwt.sign(payload, privateKey, { algorithm: 'RS256', expiresIn: '15m' });
    
    // Verification (any service)
    const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] });

    5. Never Include Sensitive Data in the Payload

    The JWT payload is base64-encoded, not encrypted. Anyone with the token can read it. Never put passwords, credit card numbers, or other sensitive info in the payload. If you need to carry sensitive data, use JWE (JSON Web Encryption) or store a reference ID.

    6. Implement Token Revocation (Blacklist or Version)

    JWTs are stateless — you can't invalidate them before they expire unless you maintain a blacklist or use a token version. For high-security apps, store a token version in your database and check it on each request.

    // User schema
    {
      userId: ObjectId,
      tokenVersion: { type: Number, default: 0 }
    }
    
    // Verification middleware
    const user = await User.findById(payload.userId);
    if (user.tokenVersion !== payload.tokenVersion) {
      throw new Error('Token revoked');
    }

    7. Use HTTPS Everywhere

    This should be obvious, but JWTs sent over HTTP can be intercepted. Always use HTTPS in production. Set secure: true on cookies, and for mobile apps, use certificate pinning.

    8. Rotate Secrets Periodically

    Change your JWT signing secrets every 90 days (or after a breach). This invalidates all existing tokens, forcing users to re-authenticate. Plan for token rotation by keeping the previous secret for a grace period to validate tokens signed before the rotation.

    9. Log and Monitor Token Usage

    Detect anomalies like:

  • Multiple refreshes from different IPs in a short time
  • Tokens used after revocation
  • Unexpected claim values
  • Use a security scanning tool like OverMCP to audit your app for exposed secrets or misconfigured JWT handling automatically.

    Common Mistakes to Avoid

  • Storing tokens in localStorage — leads to XSS theft
  • Long expiration without refresh — increases risk window
  • Not validating `alg` — allows attackers to forge tokens with alg: 'none'
  • Using the same secret for signing and encryption — if one leaks, both are compromised
  • Sending tokens in URLs — they get logged in server logs and browser history
  • Conclusion

    JWT security isn't complicated, but it requires discipline. Follow these JWT security best practices: use httpOnly cookies, short expiration with refresh tokens, explicit algorithm validation, and never trust the payload without verifying the signature. Test your implementation with automated security scanning to catch misconfigurations early.

    FAQ

    What is the safest way to store JWT tokens in the browser?

    The safest way is to store them in httpOnly, secure, sameSite cookies. This prevents access from JavaScript (XSS) and reduces CSRF risk. Avoid localStorage or sessionStorage.

    How often should I rotate JWT secrets?

    Rotate your JWT signing secrets every 90 days, or immediately after a suspected breach. Keep the previous secret for a short overlap period to avoid invalidating valid tokens.

    Can I revoke a JWT before it expires?

    Yes, by implementing a token blacklist or a token version field in your database. Check the version or blacklist on every request. This adds state to your auth system but gives you control.

    Is your app secure?

    Free scan in 30 seconds. No signup needed.

    Scan My App Free