JWTs enable stateless authentication, but securing them is critical. Avoid storing tokens in local storage due to XSS risks. Instead, use HttpOnly, Secure cookies to prevent access via JavaScript and limit CSRF attacks. Pair short-lived access tokens with refresh tokens for enhanced security.

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.
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:
After a user is authenticated, a JWT is sent to the client, which must be securely stored and handled for future requests.
Here are some common storage options for JWT tokens on the client side:
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.
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.
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.
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.
A secure JWT authentication flow should involve using both access tokens and refresh tokens. Here's an example of how to implement it:
After successful user authentication, you generate both an access token and a refresh token:
// 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.
Once the access token expires, the refresh token is used to generate a new access token without requiring the user to log in again.
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.
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.
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.
New tutorials, book updates, and behind-the-scenes notes from the studio. No schedule, no spam.