If you are building an application with a Node/Express backend, there is a good chance you are using JSON Web Tokens (JWT) for authentication. And if you are like most developers, you probably send that token to the frontend and store it using localStorage.setItem('token', token).
When I started building the backend for my rich-text publishing platform, InkWell, I realized this standard approach leaves your application wide open to Cross-Site Scripting (XSS) attacks. If malicious JavaScript executes on your page, it can easily read localStorage and steal your users' tokens.
I wanted to build an enterprise-grade, highly secure authentication system. Here is how I architected a dual-token system using Express, RAM storage, strictly scoped HttpOnly cookies, and Redis to handle session revocation.
The Architecture: Dual Tokens
Instead of a single, long-lived JWT, the system relies on two separate tokens:
- The Access Token (AT): This token has a short lifespan (15 minutes). The API returns it in a JSON payload, and the React frontend stores it strictly in a live JavaScript variable (RAM). If the user refreshes the page, the variable disappears. This makes it almost impossible for an XSS payload to extract the token from storage.
- The Refresh Token (RT): This token has a longer lifespan (7 days). It is never exposed to the frontend JavaScript. Instead, the backend sets it as an HttpOnly cookie.
Restricting the Cookie Path
The most critical part of this architecture is restricting where the browser sends the Refresh Token cookie. You do not want the RT sent with every single API request or image load.
Here is the exact implementation from my SignIn controller:
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // Frontend JS cannot access this
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/InkWell/api/v1/public/refresh-token', // Proper scoping
maxAge: 7 * 24 * 60 * 60 * 1000
});
It is important to understand the security layers here. The sameSite: 'lax' attribute is what actually mitigates CSRF (Cross-Site Request Forgery) by blocking the cookie from being sent on cross-site subrequests. The path attribute isn't for CSRF, it ensures the browser only attaches this cookie when hitting the refresh endpoint, preventing it from being needlessly broadcasted on every API or image request.
The Stateless Problem: Solving Revocation with Redis
Stateless JWTs have one massive flaw: you cannot instantly revoke them. If a user logs out, you can clear the cookie, but if an attacker already intercepted the token, it remains valid until it expires.
To solve this, I integrated Redis. Redis acts as our high-speed session manager.
1. Single-Device Session Enforcement
When a user logs in, I store their newly generated Refresh Token in Redis, using their User ID as the key.
await redis.set(`refresh-token:${payload.id}`, refreshToken, {
ex: 7 * 24 * 60 * 60 // Expires in 7 days
});
(Note: I designed InkWell to strictly enforce single-device sessions. If your app requires multi-device support, you can simply change this key to refresh-token:${userId}:${deviceId})
When the user attempts to refresh their Access Token, the backend doesn't just verify the JWT signature; it checks Redis to ensure the token hasn't been superseded by a newer login on a different device.
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
const activeToken = await redis.get(`refresh-token:${decoded.id}`);
if (!activeToken || activeToken !== refreshToken) {
return res.status(403).json({
status: false,
package: { message: "Session superseded or inactive. Please login again" }
});
}
2. Instant Revocation on Logout
When a user clicks "Logout", we don't just clear the cookie; we actively delete the session from the Redis store. This guarantees that even if the cookie was somehow compromised, it is immediately dead.
export const LogOut = async (req: Request, res: Response) => {
const userId = req.user?.id;
try {
// Delete the session in Redis
await redis.del(`refresh-token:${userId}`);
// Clear the cookie
res.clearCookie('refreshToken', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/InkWell/api/v1/public/refresh-token'
});
return res.status(200).json({
status: true,
package: { message: "Successfully logged out" }
});
} catch (error) { ... }
}
Summary
Building authentication this way requires significantly more architecture than tossing a token into localStorage. You need an Express server, custom cookie parsing, and a Redis instance to manage state.
However, the result is a production-grade system that:
- Neutralizes XSS token theft via RAM storage.
- Mitigates CSRF via strictly scoped
HttpOnlycookie paths. - Enforces single-device login sessions.
- Allows for instant, server-side session revocation.
Here is a flow diagram to help you understand better.
Mermaid code for your reference
sequenceDiagram
participant Client as React Frontend
participant API as Express API
participant Redis as Redis Cache
Note over Client, Redis: 1. The Secure Login Flow
Client->>API: POST /sign-in
API->>Redis: SET refresh-token:userId
API-->>Client: Return AT (JSON) + RT (HttpOnly Cookie)
Client->>Client: Store AT in Live RAM (No localStorage)
Note over Client, Redis: 2. Instant Revocation (Logout)
Client->>API: POST /log-out
API->>Redis: DEL refresh-token:userId
API->>API: Clear HttpOnly Cookie
API-->>Client: 200 OK
Where do you store your JWTs on the frontend? Have you ever dealt with XSS vulnerabilities?
Let's discuss in the comments.

Top comments (0)