Secure JWT Token Storage and Best Practices for Authentication in NodeJS

JWT (JSON Web Tokens) have become a widely adopted solution for handling authentication in modern web applications. They provide a stateless and scalable method for managing user sessions, but securing these tokens effectively is crucial to prevent security vulnerabilities such as XSS (Cross-Site Scripting) and CSRF (Cross-Site Request Forgery) attacks. In this guide, we'll explore best practices for securing JWT tokens and implementing a secure authentication flow in NodeJS.
What are JWT Tokens?
JWT is a compact, URL-safe token format that represents claims securely between two parties—typically between a client (like a browser) and a server. A JWT consists of three parts:
- Header: Information about the token and the algorithm used to sign it.
- Payload: Contains claims such as user identity.
- Signature: Used to verify the authenticity of the token.
After a user is authenticated, a JWT is sent to the client, which must be securely stored and handled for future requests.
Secure Storage Options for JWT Tokens
Here are some common storage options for JWT tokens on the client side:
Local Storage
-
Pros: Persists across browser sessions and is isolated by the Same-Origin Policy.
-
Cons: Highly vulnerable to XSS attacks where malicious scripts can access tokens.
-
Recommendation: Avoid using local storage for JWTs, especially for sensitive data like access tokens.
Session Storage
-
Pros: Isolated between browser tabs and cleared when the session ends.
-
Cons: Vulnerable to XSS attacks.
-
Recommendation: Use cautiously, with strong XSS protection, but avoid storing sensitive JWTs here.
Cookies
-
Pros: Cookies, when configured with the HttpOnly and SameSite flags, offer better security against XSS and CSRF attacks.
-
Cons: Vulnerable to CSRF if not properly mitigated.
-
Best Practice: Store tokens in HttpOnly, SameSite, and Secure cookies to minimize risks.
In-memory Storage
-
Pros: Least vulnerable to XSS since tokens aren't stored in persistent browser storage.
-
Cons: Tokens are lost on page reload, which may affect user experience.
-
Recommendation: Use in-memory storage when high security is needed, like in SPAs or sensitive apps.
Implementing Secure JWT Flow in NodeJS
A secure JWT authentication flow should involve using both access tokens and refresh tokens. Here's an example of how to implement it:
1. Generating Tokens on Login
After successful user authentication, you generate both an access token and a refresh token:
import { Request, Response } from "express";
import jwt from "jsonwebtoken";
import bcrypt from "bcrypt";
import UserModel from "../user/model";
// Generate and set tokens in cookies
export const setCookiesAndSendResponse = (
res: Response,
accessToken: string,
refreshToken: string,
isVerified: boolean,
deviceId: string,
mfaSecret: string | null
) => {
res.cookie("accessToken", accessToken, {
secure: true,
httpOnly: true,
sameSite: "none",
priority: "high",
});
res.cookie("refreshToken", refreshToken, {
secure: true,
httpOnly: true,
sameSite: "none",
priority: "high",
});
res.status(200).json({ message: "Login successful", isVerified: isVerified, mfaEnabled: mfaSecret ? true : false });
};Explanation:
The tokens are sent in HTTP-only cookies, making them inaccessible to JavaScript, thus mitigating XSS attacks.
We use a short-lived access token (e.g., 15 minutes) and a longer-lived refresh token (e.g., 7 days) to maintain session security.
2. Refreshing Access Tokens
Once the access token expires, the refresh token is used to generate a new access token without requiring the user to log in again.
import jwt from "jsonwebtoken";
import { Request, Response } from "express";
export const refreshToken = (req: Request, res: Response) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(400).json({ error: "Refresh token not provided" });
}
jwt.verify(refreshToken, process.env.REFRESH_SECRET_KEY || "", (err, decoded) => {
if (err) return res.status(403).json({ error: "Invalid refresh token" });
const newAccessToken = jwt.sign({ userId: decoded.userId, username: decoded.username }, process.env.SECRET_KEY || "", { expiresIn: "15m" });
res.cookie("accessToken", newAccessToken, { secure: true, httpOnly: true, sameSite: "none" });
res.status(200).json({ message: "Access token refreshed" });
});
};Explanation:
The refresh token is used to generate a new access token once the original expires. This keeps the user session active without the need to log in repeatedly.
JWT Security Best Practices
-
Short Expiry for Access Tokens: Use short lifetimes (e.g., 15 minutes) to minimize the impact of stolen tokens.
-
Use HttpOnly Cookies: Store JWTs in cookies with the HttpOnly flag to protect them from XSS attacks.
-
Refresh Tokens: Pair short-lived access tokens with long-lived refresh tokens to ensure seamless session management.
-
SameSite and Secure Flags: Use SameSite cookies to prevent CSRF and Secure cookies to enforce HTTPS usage.
-
CSRF Protection: Use additional measures like CSRF tokens or set the SameSite flag on cookies to mitigate CSRF attacks.
Conclusion
JWTs are an excellent way to manage user sessions in modern web applications, but improper storage can expose your application to attacks. The most secure approach is to store access tokens in HttpOnly, Secure cookies, use refresh tokens for session extension, and follow best practices like short-lived tokens and CSRF protection.
By following these recommendations, you can securely implement JWT-based authentication in your NodeJS applications.
