DEV Community

Cover image for JWT Authentication in Node.js: A Complete Security Guide with Express
Mehrdad khodaverdi
Mehrdad khodaverdi

Posted on

JWT Authentication in Node.js: A Complete Security Guide with Express

We’ve all experienced it: logging into a web application, closing the browser tab, and returning hours later to find ourselves still authenticated. That seamlessness is often powered by JSON Web Tokens (JWTs) working behind the scenes.

JWTs have become the de facto standard for authentication in modern web applications, particularly in the Node.js ecosystem with Express. Yet despite their widespread adoption, a significant number of developers implement JWT authentication without fully understanding what happens under the hood. That knowledge gap is precisely where security vulnerabilities take root.

This guide goes beyond the basic “install the library and call sign()” approach. We’ll dissect what JWTs actually are, implement a production-grade authentication system in Node.js with Express, and explore the real-world security pitfalls that continue to plague applications today. Whether you’re building a microservice architecture or a monolithic API, understanding the nuances of JWT security is non-negotiable.

Section 1: Deconstructing the JSON Web Token
Before writing any code, it’s essential to understand what a JWT actually represents. A JWT is a compact, URL-safe string comprising three Base64Url-encoded segments separated by dots:

xxxxx.yyyyy.zzzzz
│ │ │
header payload signature
The Header
The header typically contains two properties: the signing algorithm (alg) and the token type (typ). For most implementations, this looks like:

{
"alg": "HS256",
"typ": "JWT"
}
The alg field is particularly significant from a security perspective. As we’ll discuss later, trusting the algorithm specified in the header without server-side restrictions has been the root cause of numerous CVEs, including the infamous jsonwebtoken signature bypass vulnerabilities.

The Payload
The payload contains the claims—statements about an entity (typically the user) and additional metadata. Claims fall into three categories:

Registered claims: Predefined, recommended fields like iss (issuer), exp (expiration time), sub (subject), aud (audience), and iat (issued at).
Public claims: Custom claims defined in the IANA registry or agreed upon by parties.
Private claims: Application-specific data shared between the issuer and consumer.
A critical point often misunderstood: the payload is base64-encoded, not encrypted. Anyone with access to the token can decode and read its contents without possessing the secret key. Never store sensitive data like passwords, credit card numbers, or personally identifiable information in the payload.

The Signature
The signature is what makes JWTs trustworthy. It’s created by taking the encoded header and payload, combining them with a secret (for HMAC algorithms) or a private key (for RSA/ECDSA), and passing them through the specified hashing algorithm.

signature = HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
This cryptographic stamp ensures the token hasn’t been tampered with. If an attacker modifies any part of the token, the signature verification fails—provided the server enforces proper validation.

A Practical Exercise
To truly understand JWT structure, take any real token and paste it into a JWT debugging tool. You’ll instantly see the decoded header and payload without any authentication. This transparency reinforces why sensitive data has no place in the payload.

Section 2: Building a Secure JWT Implementation in Express
Now let’s implement a robust, production-ready JWT authentication system using Node.js and Express.

Project Setup and Dependencies
Initialize your project and install the required dependencies:

npm init -y
npm install express jsonwebtoken bcrypt dotenv cookie-parser
jsonwebtoken handles JWT creation and verification, bcrypt provides secure password hashing, and dotenv manages environment variables.

Generating Tokens on Authentication
When a user successfully authenticates, generate an access token using the jsonwebtoken library:

import jwt from 'jsonwebtoken';
import 'dotenv/config';

function generateAccessToken(user) {
return jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: '15m', issuer: 'yourapp.com', audience: 'your-api' }
);
}
Several critical decisions inform this implementation:

Keep the payload minimal. Include only the user ID and role—enough to identify the user and check permissions, but not the entire user object. This keeps tokens small and reduces attack surface.
Use environment variables for secrets. The secret must never be hardcoded in source code. Generate a cryptographically random string with at least 256 bits of entropy.
Set explicit expiration. A token without an expiration is a permanent security liability. Short-lived access tokens (15 minutes is typical) limit the window for exploitation if a token is compromised.
The Verification Middleware
Protecting routes requires middleware that intercepts requests, extracts the token, and validates it:

import jwt from 'jsonwebtoken';

export function authenticate(req, res, next) {
const authHeader = req.headers.authorization;

if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authorization header missing or malformed' });
}

const token = authHeader.substring(7);

try {
const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET, {
algorithms: ['HS256'],
issuer: 'yourapp.com',
audience: 'your-api'
});

req.user = decoded; // { userId, role, iat, exp }
next();
Enter fullscreen mode Exit fullscreen mode

} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}
The key security detail here is the algorithms parameter. Explicitly restricting the algorithm prevents algorithm confusion attacks where an attacker tricks the server into treating an RSA token as an HMAC token or accepting the none algorithm.

Apply this middleware to protected routes:

app.get('/api/profile', authenticate, (req, res) => {
res.json({ userId: req.user.userId });
});
Refresh Token Strategy
Access tokens have a short lifespan, requiring a mechanism to obtain fresh tokens without re-authentication. Refresh tokens solve this:

function generateRefreshToken(user) {
const refreshId = crypto.randomUUID();
return jwt.sign(
{ userId: user.id, jti: refreshId },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
}
Store refresh tokens server-side (in Redis or a database) with the jti (JWT ID) as the key and the user ID as the value. This enables revocation—when a user logs out, delete the stored refresh token.

The refresh endpoint validates the refresh token and issues a new access token:

app.post('/auth/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}

try {
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
const stored = await getRefreshToken(decoded.jti);
if (!stored || stored.userId !== decoded.userId) {
return res.status(401).json({ error: 'Invalid refresh token' });
}

const newAccessToken = generateAccessToken({ id: decoded.userId });
res.json({ accessToken: newAccessToken });
Enter fullscreen mode Exit fullscreen mode

} catch {
res.status(401).json({ error: 'Invalid refresh token' });
}
});
Section 3: Navigating the JWT Threat Landscape
JWT vulnerabilities are rarely theoretical—they’re exploited in production systems with alarming frequency. The jsonwebtoken package alone has seen multiple signature-bypass CVEs between 2015 and 2022, including CVE-2015-9235, CVE-2022-23540, and CVE-2022-23541. Understanding these attack vectors is essential for building secure systems.

Algorithm Confusion
Algorithm confusion occurs when a server configured for asymmetric verification (RS256, ES256) is tricked into treating a token as an HMAC token (HS256). Because RSA public keys are publicly available, an attacker can sign a forged token with HS256 using that public key as the secret—and the verification succeeds if the library accepts the algorithm from the token header.

The fix is simple but non-negotiable: always specify an algorithms allowlist in every verify() call:

jwt.verify(token, secret, { algorithms: ['HS256'] })
The none Algorithm Exploit
Some implementations historically honored the alg: none header by skipping signature verification altogether. An attacker could take any valid token, rewrite the header to declare none, strip the signature, and submit it to an unsuspecting server.

This was exploited in CVE-2022-23540, where jsonwebtoken versions up to 8.5.1 could be tricked into accepting none-algorithm tokens when verify() was called with a falsy secret. Again, the same mitigation applies: explicit algorithm restrictions.

Weak Secret Vulnerabilities
A weak or leaked secret compromises every token your system has ever issued. If an attacker obtains the secret, they can forge valid tokens with arbitrary payloads. Use a cryptographically random secret with at least 256 bits of entropy, store it securely in environment variables, and rotate it periodically.

The Importance of Expiration
JWTs are stateless by design—they don’t have a server-side revocation mechanism. Once issued, a token remains valid until it expires. This makes expiration absolutely critical. A token without expiresIn is a permanent session that cannot be invalidated.

The jsonwebtoken library does not enforce expiration by default—the verification call must explicitly check the exp claim. Always include exp in your tokens and verify it during authentication.

Best Practices
Always Validate the Audience and Issuer
The aud (audience) and iss (issuer) claims serve as guardrails against token misuse. Validate that the token was issued by a trusted source and intended for your service:

jwt.verify(token, secret, {
algorithms: ['HS256'],
issuer: 'yourapp.com',
audience: 'your-api'
});
This prevents token injection attacks where a token minted for one service is used against another.

Choose the Right Algorithm
HS256 (HMAC with SHA-256) uses a single secret key for both signing and verification. This is simpler and faster, making it suitable for single-service architectures where the same process verifies tokens it issued.
RS256 (RSA with SHA-256) uses asymmetric encryption—a private key for signing and a public key for verification. This is appropriate for multi-service architectures where verification services shouldn’t possess the signing key.
ES256 (Elliptic Curve with SHA-256) offers equivalent security to RS256 with smaller signatures, which matters when tokens travel in headers on every request.
Secure Token Storage
For web applications, the choice of token storage location has significant security implications:

localStorage is vulnerable to cross-site scripting (XSS)—any injected script can read the token.
httpOnly cookies with Secure and SameSite=Strict attributes protect tokens from XSS but require CSRF protection.
Memory storage (in-memory JavaScript variables) offers the strongest protection against persistent theft, though tokens disappear on page refresh.
A recommended architecture is: access token in memory, refresh token as an httpOnly cookie. On page load, the client requests a new access token using the refresh token cookie. This approach resists both XSS (refresh token inaccessible to scripts) and persistent token theft.

Implement Proper Logout
Stateless authentication makes logout non-trivial. Since the server can’t invalidate access tokens directly, implement a logout strategy that invalidates refresh tokens server-side:

app.post('/auth/logout', authenticate, async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (refreshToken) {
const decoded = jwt.decode(refreshToken);
await deleteRefreshToken(decoded.jti);
}
res.clearCookie('refreshToken');
res.json({ message: 'Logged out' });
});
This ensures the user can’t obtain new access tokens after logout, and the existing access token expires naturally within its short lifetime.

Common Mistakes
Mistake 1: Decoding Instead of Verifying – jwt.decode() merely decodes the token—it does not verify the signature. Using decode instead of verify means accepting any token regardless of whether it’s legitimately signed. Always use jwt.verify() for authentication purposes.
Mistake 2: Storing Secrets in Code – Hardcoding secrets in source code is a critical vulnerability. Secrets committed to version control are effectively public. Use environment variables or secret management services.
Mistake 3: Putting Sensitive Data in the Payload – The payload is base64-encoded, not encrypted. Anyone who captures the token can read its contents. Never include passwords, credit card numbers, or personal identifiable information in the token.
Mistake 4: Omitting Expiration – Tokens without expiration are permanent session keys. If compromised, they provide lifetime access. Always set an appropriate expiresIn value.
Mistake 5: Single Secret for Everything – Using the same secret for access tokens, refresh tokens, and across development/production environments amplifies the impact of secret compromise. Use distinct secrets for different purposes.
Final Thoughts
JWTs are not magic—they’re signed strings that cryptographically assert identity. The security of your authentication system ultimately depends on implementation choices: small payloads, strong secrets, explicit algorithm restrictions, short expiration windows, and thoughtful token storage.

The vulnerabilities that plague real-world JWT implementations are rarely cryptographic flaws in the algorithms themselves. Instead, they’re mistakes in how developers use the tools—trusting the algorithm in the header, forgetting to set expiration, storing sensitive data in the payload. Each of these mistakes is preventable with deliberate attention to implementation details.

As you build authentication into your Node.js applications, treat JWT implementation as a security-critical exercise. Test edge cases, review verification logic, and stay current with security advisories for the libraries you depend on. A few extra minutes of careful implementation can prevent significant production incidents down the road.

Top comments (0)