Guides
authenticationjwtsecurity

JWT Authentication: What Developers Get Wrong

Your JWT implementation looks correct, but users are getting logged out randomly. Sound familiar? Here's what we learned debugging authentication for production apps.

A
Avidity Studio Team
February 4, 2026
5 min read

JWT Authentication: What Developers Get Wrong

JWT authentication seems straightforward: sign a token, verify it on the server, and you're finished. However, the edge cases have a way of breaking at the worst possible moments. We've spent years implementing JWTs across various projects and made every mistake imaginable. Here's what actually matters.

What JWTs Actually Are

A JWT consists of three parts separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NSJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Breaking this down:
Header: Contains the algorithm and token type
Payload: The actual data—user ID, expiration, claims. Base64-encoded but not encrypted
Signature: Cryptographic hash proving the token wasn't tampered with
The key insight: JWTs are signed, not encrypted. Anyone can read the payload. Never store sensitive data there.

Mistake #1: Storing Tokens in localStorage

Developers often use localStorage because it's convenient. It's accessible from JavaScript and persists across reloads. However, this approach is vulnerable to XSS attacks. If an attacker injects malicious JavaScript, they can steal every token in localStorage.
Store tokens in httpOnly cookies instead:
Set-Cookie: token=eyJhbG...; HttpOnly; Secure; SameSite=Strict; Max-Age=86400
These cookies can't be accessed by JavaScript, eliminating the XSS attack vector. The tradeoff: you can't easily read the token from JavaScript for client-side logic. Use a separate non-sensitive identifier or derive state from authenticated API responses.

Mistake #2: Ignoring Clock Skew

Even after moving to httpOnly cookies, users might still get logged out unexpectedly.
We once debugged this issue for three hours. Tokens configured for 24-hour expiration were being rejected after 20 minutes. The culprit: a 20-minute clock skew between the authentication server and the application server. The token was issued at 10:45 PM, but the app server thought it was 10:25 PM.
Clock skew is common in distributed systems. NTP helps, but discrepancies of several minutes aren't unusual.
The fix: Build in a buffer. Most JWT libraries support clock tolerance:
jwt.verify(token, secret, { clockTolerance: 60 }); // 60 second tolerance
Also verify you're using UTC. We've seen bugs where servers interpreted
exp
in local time, causing tokens to expire hours early.

Mistake #3: Not Validating the Signature

This mistake is surprisingly common. Developers use
jwt.decode()
instead of
jwt.verify()
because they "only need the user ID." Without signature verification, anyone can forge a token.
Always verify the signature:
Here's what you should avoid:
// DANGEROUS — Never do this
const payload = jwt.decode(token);
Instead, always use:
// SAFE — Always verify
const payload = jwt.verify(token, secret);
Never disable signature verification in development. We've seen
algorithms: ['none']
accidentally committed to production.

Token Lifetime: Access vs. Refresh

Short-lived tokens are more secure but create friction. The solution is two tokens:
Access tokens: Short-lived (15 min - 1 hour). Used for API requests.
Refresh tokens: Long-lived (days to months). Only used to obtain new access tokens.
When an access token expires, the client sends the refresh token to get a new one. If the refresh token is compromised, revoke it—this invalidates all associated access tokens.
Best practices: Hash refresh tokens in the database (like passwords). Rotate them on each use. Implement "logout everywhere" by revoking all refresh tokens for a user.

Mistake #4: Oversized Payloads

JWT payloads are tempting places to store user data. But JWTs travel in HTTP headers, and servers typically limit header size to 8KB. Exceed this, and requests fail mysteriously.
We've seen 15KB tokens containing user profiles, permission arrays, even base64-encoded profile pictures. They worked locally but failed in production.
Store only:
  • User ID
  • Issued at time
    iat
  • Expiration time
    exp
  • Token type
Never store: Passwords, API keys, large datasets, or frequently changing data.

Debugging JWTs

When authentication breaks, follow this workflow:

Step 1: Decode and Inspect

Check what the token contains. Look at the claims, expiration, and algorithm.
Our JWT Decoder can help—paste the token to see the payload instantly.

Step 2: Verify the Signature

If signature verification fails, check:
  • Wrong secret/key
  • Algorithm mismatch (RS256 vs HS256)
  • Token tampering

Step 3: Check the Clock

If the signature passes but the token is rejected as expired, check your server's clock. More than a few seconds of drift is likely your problem.

Step 4: Trace the Lifecycle

Follow the token through your system: generation → transmission → storage → validation. Log at each step. We've found bugs from truncated headers, double-encoded cookies, and swapped tokens.

Algorithm Confusion Attacks

A critical vulnerability: attackers change the
alg
header from
RS256
to
HS256
. The server uses the RSA public key as the HMAC secret. Since public keys are public, attackers can forge tokens.
The fix: Explicitly specify allowed algorithms:
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
Never trust the algorithm from the token header.

Production Checklist

Before deploying:
  • ✅ httpOnly cookies, not localStorage
  • ✅ Signatures always verified
  • ✅ Clock skew tolerance configured
  • ✅ Access tokens short-lived (15 min - 1 hour)
  • ✅ Refresh tokens rotated on use
  • ✅ Small payloads only
  • ✅ Refresh tokens hashed in database
  • ✅ Explicit algorithm specification

Tools

JWT Decoder — Inspect token headers and payloads instantly. Free, no signup.

Conclusion

JWTs seem simple, but the edge cases are complex. The mistakes we covered—localStorage storage, clock skew, missing signature verification, oversized payloads—are all things we've encountered in production.
The good news: JWTs are well-understood. Follow established patterns, use reputable libraries, and test thoroughly.
Remember: signed, not encrypted. Verify always. Store securely. And keep your clocks synchronized.