JSON Web Tokens (JWTs) are the darling of modern web development. They promise stateless, decentralized, easy-to-scale session management. When you generate a JWT on successful authentication, send it to the client, and have the client send it back in the headers of subsequent requests, it feels like magic. There is no database lookup on the session table, no heavy memory footprint on the server, and instant microservice scaling. But this simplicity is a trap.
Over the last few years auditing and repairing systems for clients, I have found that JWTs are rarely implemented securely. Because the specification delegates implementation details to developers, it creates a massive attack surface. In this article, I want to take you through a real-world case study—how a client’s production authentication system suffered a major compromise, how we investigated it, and how you can avoid the exact same pitfalls in your architecture.
The Incident: "Why are our users' sessions being hijacked?"
Last year, I received an urgent call from the engineering lead of a SaaS startup. Users were reporting that their accounts were performing actions they hadn't authorized. Orders were being placed, account details were being modified, and billing profiles were accessed. Oddly enough, there were no failed login alerts in their logs, no database compromises, and no brute-force attempts on their credentials endpoints.
The hijackers were presenting completely valid authentication tokens. They had somehow acquired active JWTs from real users. The startup had a React single-page application (SPA) communicating with an Express/Node.js backend. As soon as a user logged in, the backend issued a signed JWT. The frontend React application took this JWT and stored it in localStorage so that it would persist across browser tabs and refreshes.
localStorage, which was then read by a compromised third-party analytics script during a supply-chain attack. Over 4,000 active sessions were stolen within 48 hours.
To capture user analytics and optimize their signup funnel, the client had integrated a popular, lightweight third-party script via a Content Delivery Network (CDN) link. Three days before the incident began, that third-party analytics library suffered a supply-chain attack: hackers compromised the developer account of the utility library and pushed a malicious update. The script was modified to perform a silent scan of the browser's localStorage, match any strings looking like JWTs (using a regex pattern for three dot-separated base64 fields), and send them to an external endpoint controlled by the hackers.
Because the React app was storing the authentication token in localStorage, the malicious JavaScript had free reign. It didn't need to break cryptography or compromise the database; it just read the key-value pair and shipped it off to a remote server. The hackers then set their own browsers' headers with the stolen tokens, bypassing authentication entirely.
The Root of the Vulnerability: Local Storage and XSS
The fundamental mistake my client made is one that continues to plague modern SPAs: storing active credentials in web storage (either localStorage or sessionStorage). Web storage is incredibly convenient. It’s easy to write to using localStorage.setItem('token', jwt), and even easier to read on every outgoing HTTP request. However, web storage has no security boundaries against JavaScript running in the same origin.
Any JavaScript running on your page—whether it’s your code, a package from npm, a Google Analytics bundle, or a customer support widget—can read everything in localStorage via a simple window.localStorage dump. This means that if your app has even a single Cross-Site Scripting (XSS) vulnerability, or if a third-party dependency you load is compromised, your users' tokens are good as gone.
Why JWTs Aren't Like Traditional Session IDs
In the old days of stateful web applications, session IDs were stored in cookies. Cookies can be configured with the HttpOnly flag, which instructs the browser that the cookie should not be accessible via client-side JavaScript APIs (like document.cookie). If a hacker gets an XSS exploit onto a page using HttpOnly cookies, they might be able to trigger actions on the user's behalf (Cross-Site Request Forgery, or CSRF), but they cannot physically read and steal the session token to use on their own machines.
By moving the session state into a JWT and saving it in localStorage, developers unintentionally bypassed the browser's built-in defense mechanisms. They traded CSRF protection (which is easily mitigated with modern headers and SameSite cookies) for complete exposure to XSS credential theft (which is incredibly hard to prevent given the size of modern npm dependency trees).
The Mitigation Strategy: Rebuilding the Auth Flow
To patch this leak and secure the system, we migrated the client's architecture away from localStorage and moved the JWTs into secure cookies. Here is the exact blueprint we implemented, which you should use for any secure web application.
Step 1: Configure Secure, HttpOnly Cookies
Instead of returning the JWT in the JSON payload of the login response, we updated the Express server to set the JWT inside an encrypted cookie. Crucially, we applied three flags: HttpOnly, Secure, and SameSite.
// Secure cookie configuration on Express server
app.post('/api/login', (req, res) => {
const user = authenticateUser(req.body);
if (!user) return res.status(401).send('Unauthorized');
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' } // Short-lived access token
);
// Set the token inside a secure HttpOnly cookie
res.cookie('token', accessToken, {
httpOnly: true, // Prevents client-side scripts from reading the cookie
secure: true, // Ensures the cookie is only sent over HTTPS
sameSite: 'strict', // Mitigates CSRF attacks from external sites
maxAge: 15 * 60 * 1000 // Matches token expiration (15 minutes)
});
res.status(200).json({ success: true, user: { name: user.name } });
});
With this setup, the browser automatically attaches the cookie to every subsequent API call to the same domain. The React frontend no longer needs to store, read, or manually attach the token to request headers. Most importantly, if a malicious script runs on the page, calling document.cookie will return an empty string or omit the token, keeping it completely safe from extraction.
Step 2: Mitigating CSRF Attacks
While moving the token to a cookie solved the XSS leakage problem, it introduced a new risk: Cross-Site Request Forgery (CSRF). Because browsers automatically attach cookies to matching requests, a malicious website could host a form that submits a request to our API, and the browser would attach the user's authentication cookie.
To mitigate this, we did two things:
- Set SameSite to Strict or Lax: The
sameSite: 'strict'flag ensures the cookie is never sent in cross-site requests (like when clicking a link from an external email or site). - Enforce Custom Request Headers: We configured the frontend client (like Axios) to always include a custom header, such as
X-Requested-With: XMLHttpRequest. Since browsers do not allow cross-origin requests to set custom headers without passing preflight CORS checks, this acts as a robust secondary defense.
Beyond Storage: Two Other Critical JWT Failures We Patched
While investigating the client's codebase, we discovered two other critical issues in how their JWTs were generated and processed. Both of these are common in early-stage projects and would trigger immediate rejections in professional security audits or AdSense compliance reviews.
1. Sensitive Information Leakage in the Payload
I inspected the code that generated their JWT and found this payload structure:
// Unsafe: Leaking internal identifiers and password hashes in JWT payload
const payload = {
id: user.id,
email: user.email,
role: user.role,
passwordHash: user.password_hash // CRITICAL SECURITY RISK!
};
Many developers think that because a JWT is cryptographically signed, its contents are private. This is a dangerous misconception. A JWT signature only guarantees that the payload hasn't been altered; **it does not encrypt the data.** A JWT is simply Base64URL encoded. Anyone who intercepts the token (via proxy logs, network sniffing, or browser history) can paste it into a decoder like the ones on our tool site and read the password hash, email, and user ID in plain text.
We immediately stripped the payload down to the bare minimum: a stateless `userId` and a role claim. If you need to store sensitive data in the token, you must use JSON Web Encryption (JWE), or keep the token stateless and query details on the backend database.
2. The Lack of Token Revocation (Stateless vs Stateful)
Another major issue with their implementation was session termination. When a user clicked "Logout," the React app simply deleted the token from `localStorage` (locally). However, because the backend was entirely stateless and only verified the token's signature and expiration time, the token remained completely valid on the server until it expired!
If an attacker stole a token that had a 24-hour lifespan, the user clicking "Logout" did nothing to stop the attacker. The server would keep accepting the token for the rest of the day.
To fix this, we implemented a **Hybrid Revocation Strategy** using Redis:
- Keep the access token lifetime short (15 minutes).
- Implement a secure **Refresh Token** stored in a database/Redis. When the short-lived access token expires, the client uses the refresh token to get a new access token.
- If a user logs out or changes their password, we delete the refresh token from our database and add the active access token's unique ID (`jti` claim) to a fast Redis blacklist for the remaining 15 minutes of its life.
Conclusion: Secure by Design
JWTs are not a magic bullet. They are a tool with strict rules. If you store them in local storage, you are inviting session hijacking. If you put sensitive information inside them, you are leaking data. And if you don't build a revocation mechanism, you cannot control active sessions.
By moving to secure, HTTP-only cookies, keeping payloads lean, and implementing robust token lifespans, we secured our client's architecture and stopped the session hijackings dead in their tracks. When building your developer tools, APIs, and web projects, always design with security in mind from day one.