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
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 in local time, causing tokens to expire hours early.
expMistake #3: Not Validating the Signature
This mistake is surprisingly common. Developers use instead of because they "only need the user ID." Without signature verification, anyone can forge a token.
jwt.decode()jwt.verify()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 accidentally committed to production.
algorithms: ['none']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.
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 header from to . The server uses the RSA public key as the HMAC secret. Since public keys are public, attackers can forge tokens.
algRS256HS256The 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.