Authentication is the front door to your application — and in Node.js, getting it wrong is easier than you'd think. Following Node.js security best practices for authentication is not optional: a single misstep, whether it is storing passwords in plaintext, signing JWTs with a weak algorithm, or skipping rate limiting, can expose your entire user base.
This guide covers the concrete steps Node.js developers need to take to build a secure authentication system in 2026. Each section includes real code examples you can drop into an Express.js application.
1. Why Node.js Authentication Security Matters
Node.js is the runtime behind a huge share of web APIs and backend services. Its non-blocking I/O model makes it fast, but the npm ecosystem — with hundreds of thousands of packages — also makes it a large attack surface.
The most common attack vectors targeting Node.js authentication systems are:
- Credential stuffing and brute force — automated login attempts using leaked credential lists.
- Injection attacks — NoSQL injection into MongoDB queries used for login lookups, or SQL injection if you are using a relational database without parameterised queries.
- Insecure token handling — JWTs stored in
localStorageare readable by any JavaScript on the page, making them vulnerable to XSS. - Timing attacks — naive string comparison leaks information about password correctness through response time differences.
- Dependency vulnerabilities — a compromised or vulnerable npm package in your auth flow can be catastrophic.
- Session fixation — an attacker pre-sets a session ID before the user logs in and then hijacks the authenticated session.
None of these are theoretical. They appear regularly in real-world breaches. The sections below address each one directly.
2. Secure Password Handling
Never store plaintext passwords
This should go without saying, but it is still violated more often than it should be. Never store a password as plain text, and never store it with reversible encryption. Passwords must be hashed with a purpose-built, slow hashing algorithm.
Use bcrypt or argon2
bcrypt is the most widely used option in the Node.js ecosystem. Its cost factor makes brute-force attacks expensive.
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12; // Increase this as hardware gets faster
async function hashPassword(plaintext) {
return bcrypt.hash(plaintext, SALT_ROUNDS);
}
async function verifyPassword(plaintext, hash) {
return bcrypt.compare(plaintext, hash);
}
// Example usage in a registration handler
app.post('/register', async (req, res) => {
const { email, password } = req.body;
if (password.length < 12) {
return res.status(400).json({ error: 'Password must be at least 12 characters.' });
}
const hash = await hashPassword(password);
await db.users.create({ email, passwordHash: hash });
res.status(201).json({ message: 'Account created.' });
});argon2 is the winner of the Password Hashing Competition and is considered the stronger choice for new projects. It has three variants; use argon2id which resists both side-channel and GPU-based attacks.
const argon2 = require('argon2');
async function hashPassword(plaintext) {
return argon2.hash(plaintext, { type: argon2.argon2id });
}
async function verifyPassword(plaintext, hash) {
return argon2.verify(hash, plaintext);
}Which should you use? Both are solid. bcrypt has broader library support and more deployment history. argon2id has better theoretical properties. Either is fine — the key is using one of them consistently.
3. JWT Best Practices
JWTs (JSON Web Tokens) are widely used for stateless authentication, but they come with several sharp edges.
Use RS256, not HS256 for multi-service architectures
HS256 uses a single shared secret for both signing and verification. If you have multiple services, every service that needs to verify tokens also needs the secret — and every service that has the secret can issue tokens.
RS256 uses an asymmetric key pair: you sign with the private key (only your auth service has it) and verify with the public key (any service can have it). This is far safer in a microservices environment.
const jwt = require('jsonwebtoken');
const fs = require('fs');
const privateKey = fs.readFileSync('./keys/private.pem');
const publicKey = fs.readFileSync('./keys/public.pem');
// Generate a token (auth service only)
function issueToken(userId) {
return jwt.sign(
{ sub: userId },
privateKey,
{
algorithm: 'RS256',
expiresIn: '15m', // Short-lived access token
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
}
);
}
// Verify a token (any service with the public key)
function verifyToken(token) {
return jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});
}Always specify algorithms explicitly in jwt.verify. If you leave it unspecified, an attacker can change the algorithm to none in the token header and bypass verification entirely — a well-known JWT vulnerability.
Token expiry and refresh
Access tokens should be short-lived (15 minutes is a common choice). Pair them with a long-lived refresh token that is stored securely and exchanged for new access tokens.
function issueRefreshToken(userId) {
return jwt.sign(
{ sub: userId, type: 'refresh' },
privateKey,
{ algorithm: 'RS256', expiresIn: '7d' }
);
}
app.post('/token/refresh', async (req, res) => {
const { refreshToken } = req.body;
try {
const payload = verifyToken(refreshToken);
if (payload.type !== 'refresh') {
return res.status(401).json({ error: 'Invalid token type.' });
}
// Check the token has not been revoked (see below)
const isRevoked = await tokenStore.isRevoked(refreshToken);
if (isRevoked) {
return res.status(401).json({ error: 'Token has been revoked.' });
}
const newAccessToken = issueToken(payload.sub);
res.json({ accessToken: newAccessToken });
} catch (err) {
res.status(401).json({ error: 'Invalid or expired token.' });
}
});Where to store tokens: httpOnly cookies vs localStorage
Store tokens in httpOnly cookies, not localStorage.
localStorageis accessible to any JavaScript on the page. A single XSS vulnerability lets an attacker steal every token in storage.httpOnlycookies are not accessible to JavaScript at all. They are sent automatically with requests, and when combined withSecureandSameSite=Strict, they resist both XSS-based theft and CSRF.
res.cookie('accessToken', token, {
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'strict',
maxAge: 15 * 60 * 1000 // 15 minutes in ms
});Token revocation patterns
JWTs are stateless by design, which means there is no built-in revocation mechanism. When a user logs out or you need to invalidate a token early (e.g., after a password reset), you have two main options:
- Denylist (blocklist): Store revoked token JTIs (
jticlaim) in Redis until their natural expiry. Check this list on every request. - Short expiry + refresh rotation: Keep access tokens very short-lived and rotate refresh tokens on each use, invalidating the old one immediately.
// Add a unique jti to every token
function issueToken(userId) {
return jwt.sign(
{ sub: userId, jti: crypto.randomUUID() },
privateKey,
{ algorithm: 'RS256', expiresIn: '15m' }
);
}
// On logout, blocklist the jti
app.post('/logout', authenticate, async (req, res) => {
const { jti, exp } = req.user; // from decoded token
const ttl = exp - Math.floor(Date.now() / 1000);
await redis.setEx(`blocklist:${jti}`, ttl, '1');
res.clearCookie('accessToken');
res.json({ message: 'Logged out.' });
});4. Session Management
If you are using server-side sessions instead of JWTs, express-session is the standard choice — but its defaults are not production-ready.
Secure express-session configuration
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const redisClient = createClient();
redisClient.connect();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET, // Long, random, never hardcoded
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'strict',
maxAge: 30 * 60 * 1000 // 30 minutes
}
}));Key points:
- Never hardcode
secret. Pull it from an environment variable or secrets manager. - Set
saveUninitialized: falseso that unauthenticated requests do not create sessions. - Use a persistent store (Redis, PostgreSQL) rather than the default in-memory store, which leaks memory and loses sessions on restart.
Session fixation prevention
Session fixation is an attack where a malicious actor plants a known session ID before login. After the user authenticates, the attacker uses the same ID to access the authenticated session.
The fix is simple: regenerate the session ID immediately after login.
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.users.findByEmail(email);
if (!user || !(await verifyPassword(password, user.passwordHash))) {
return res.status(401).json({ error: 'Invalid credentials.' });
}
// Regenerate session ID BEFORE storing user data
req.session.regenerate((err) => {
if (err) return next(err);
req.session.userId = user.id;
res.json({ message: 'Logged in.' });
});
});5. Common Node.js Authentication Vulnerabilities and Fixes
Timing attacks on password comparison
A naive string comparison like storedHash === submittedHash exits as soon as it finds a mismatch. This means incorrect values return faster than correct ones, leaking information through response time.
Always use crypto.timingSafeEqual for any sensitive comparison:
const crypto = require('crypto');
function safeCompare(a, b) {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
// Buffers must be the same length for timingSafeEqual
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
}Note: bcrypt's .compare() and argon2's .verify() already handle timing safety internally. Use timingSafeEqual for cases like comparing API keys or TOTP codes where you are doing your own comparison.
Mass assignment and parameter pollution
Mass assignment happens when you pass req.body directly to your database layer, allowing an attacker to set fields they should not control (e.g., isAdmin: true).
// UNSAFE — never do this
await db.users.update(req.user.id, req.body);
// SAFE — allowlist the fields you accept
const { displayName, email } = req.body;
await db.users.update(req.user.id, { displayName, email });Use a validation library like zod or joi to enforce input shape before it reaches your database.
Insecure direct object references (IDOR)
An IDOR vulnerability lets users access other users' data by manipulating an ID in the request.
// UNSAFE — user can change the id parameter
app.get('/users/:id/profile', authenticate, async (req, res) => {
const profile = await db.users.findById(req.params.id);
res.json(profile);
});
// SAFE — always scope queries to the authenticated user
app.get('/me/profile', authenticate, async (req, res) => {
const profile = await db.users.findById(req.user.id);
res.json(profile);
});Dependency vulnerabilities
Your auth code is only as secure as its dependencies. Run npm audit regularly and integrate it into your CI pipeline.
# Check for known vulnerabilities
npm audit
# Auto-fix where possible
npm audit fix
# For CI: fail the build on high-severity issues
npm audit --audit-level=highUse npm audit alongside tools like Snyk or Dependabot for automated alerts on newly disclosed vulnerabilities.
6. Rate Limiting and Brute Force Protection
Without rate limiting, login endpoints are open to brute force and credential stuffing attacks. express-rate-limit is the standard solution:
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15-minute window
max: 10, // Max 10 attempts per IP per window
message: { error: 'Too many login attempts. Please try again later.' },
standardHeaders: true,
legacyHeaders: false,
});
app.post('/login', loginLimiter, async (req, res) => {
// login logic
});For production, back the rate limiter with Redis so it works correctly across multiple server instances:
const { RedisStore } = require('rate-limit-redis');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
store: new RedisStore({
sendCommand: (...args) => redisClient.sendCommand(args),
}),
});Consider also implementing account-level lockout (not just IP-level) after a threshold of failed attempts, and adding CAPTCHA challenges after a smaller threshold.
7. MFA Integration with TOTP
Multi-factor authentication (MFA) is one of the most effective controls against account takeover. Time-based One-Time Passwords (TOTP) — the codes generated by Google Authenticator, Authy, and similar apps — are straightforward to implement with the speakeasy package.
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
// 1. Generate a secret during MFA setup
app.post('/mfa/setup', authenticate, async (req, res) => {
const secret = speakeasy.generateSecret({
name: `MyApp (${req.user.email})`,
length: 20,
});
// Store the base32 secret (encrypted at rest)
await db.users.update(req.user.id, {
totpSecretPending: secret.base32,
});
// Return the QR code for the authenticator app
const qrDataUrl = await QRCode.toDataURL(secret.otpauth_url);
res.json({ qrCode: qrDataUrl });
});
// 2. Verify the user scanned it correctly
app.post('/mfa/verify-setup', authenticate, async (req, res) => {
const { code } = req.body;
const user = await db.users.findById(req.user.id);
const valid = speakeasy.totp.verify({
secret: user.totpSecretPending,
encoding: 'base32',
token: code,
window: 1, // Allow 1 step of clock drift
});
if (!valid) {
return res.status(400).json({ error: 'Invalid code. Please try again.' });
}
await db.users.update(req.user.id, {
totpSecret: user.totpSecretPending,
totpSecretPending: null,
mfaEnabled: true,
});
res.json({ message: 'MFA enabled.' });
});
// 3. Require TOTP code at login
app.post('/login/mfa', async (req, res) => {
const { userId, code } = req.body;
const user = await db.users.findById(userId);
if (!user.mfaEnabled) {
return res.status(400).json({ error: 'MFA not configured.' });
}
const valid = speakeasy.totp.verify({
secret: user.totpSecret,
encoding: 'base32',
token: code,
window: 1,
});
if (!valid) {
return res.status(401).json({ error: 'Invalid MFA code.' });
}
// Issue session or token
});For stronger MFA, consider passkeys (FIDO2/WebAuthn), which are phishing-resistant by design. See What Is FIDO2? Complete Guide to FIDO Authentication for a full breakdown.
8. Using an Auth Platform vs. Rolling Your Own
Everything covered so far — password hashing, JWT rotation, session management, rate limiting, MFA — represents a substantial engineering surface area. Each piece needs to be implemented correctly, kept up to date, and audited regularly.
Rolling your own authentication is viable, but it means you own every decision and every vulnerability. Most product teams underestimate how much ongoing work this requires.
A battle-tested auth platform handles all of this by default. Authgear is built specifically for this: it provides a Node.js SDK that integrates with Express in a few lines of code, and it takes care of:
- Secure session and token management
- Password hashing and breach detection
- MFA (TOTP, SMS, passkeys)
- Social login (Google, Apple, GitHub, etc.)
- Brute force protection and rate limiting
- Audit logs and security events
// Example: protecting a route with the Authgear Node.js SDK
const { authgear } = require('@authgear/node');
app.get('/api/me', authgear.middleware(), (req, res) => {
res.json({ userId: req.user.sub });
});You can integrate passkeys alongside traditional password login — something that would take weeks to build correctly from scratch. See the Passkey Implementation Guide for a detailed walkthrough.
For teams where authentication is not a core differentiator, using a platform like Authgear is usually the right call. You get better security coverage faster, and your engineers can focus on what makes your product unique.
If you do decide to build your own, use this guide as a checklist — and be honest about the ongoing maintenance cost.
9. Security Checklist
| Area | Check | Notes |
|---|---|---|
| Passwords | Hashed with bcrypt or argon2id | Never MD5, SHA-1, or plaintext |
| Passwords | Minimum length enforced (12+ chars) | Also check against known breach lists (HaveIBeenPwned API) |
| JWT | RS256 algorithm used | HS256 acceptable for single-service; avoid none |
| JWT | algorithms explicitly set in verify() | Prevents algorithm confusion attacks |
| JWT | Short expiry (15 min) + refresh rotation | Refresh tokens stored securely |
| JWT | jti claim added for revocation support | Blocklist in Redis |
| Tokens | Stored in httpOnly, Secure, SameSite cookies | Not localStorage |
| Sessions | ID regenerated after login | Prevents session fixation |
| Sessions | Persistent store (Redis) | Not in-memory |
| Sessions | saveUninitialized: false | No phantom sessions |
| Comparisons | crypto.timingSafeEqual for sensitive values | bcrypt/argon2 handle this internally |
| Input | Allowlisted fields on user update | Prevents mass assignment |
| IDOR | Queries scoped to authenticated user | Never trust user-supplied IDs |
| Rate limiting | Login endpoint limited per IP | Back with Redis in multi-instance deployments |
| Rate limiting | Account lockout after N failures | IP-level alone is bypassable |
| MFA | TOTP or passkey support | FIDO2/WebAuthn is phishing-resistant |
| Dependencies | npm audit in CI pipeline | Fail on high-severity issues |
| Cookies | HttpOnly, Secure, SameSite=Strict | All three flags set |
| HTTPS | All auth traffic over TLS | No exceptions |
10. Frequently Asked Questions
Should I use JWTs or server-side sessions for Node.js auth?
Both work. JWTs are stateless and scale easily across multiple servers without a shared session store. Server-side sessions are easier to revoke immediately (just delete the session record). For most applications, stateless JWTs with short expiry and refresh token rotation give a good balance. If instant revocation is a hard requirement — for example, in a high-security financial application — server-side sessions backed by Redis give you more control.
Is HS256 ever acceptable for JWT signing?
Yes, in a single-service deployment where only one service both issues and verifies tokens. If you have multiple services verifying tokens, use RS256 so you do not have to share your signing secret.
How do I prevent NoSQL injection in a Node.js login query?
MongoDB queries that use req.body directly are vulnerable. For example, passing { email: { $gt: '' } } as the email field can return all users. Always validate and sanitize inputs before they reach the query layer. Use a schema validator (zod, joi) and avoid using raw req.body as a query filter object.
What is the difference between HttpOnly and Secure cookie flags?
HttpOnly prevents JavaScript from reading the cookie — this blocks XSS-based token theft. Secure ensures the cookie is only sent over HTTPS — this prevents interception on unencrypted connections. Use both together, along with SameSite=Strict to block cross-site request forgery.
Summary
Node.js gives you the flexibility to build authentication however you like — which means you can get it very wrong if you are not deliberate about it. The Node.js security best practices covered here come down to a few core principles:
- Use a slow, purpose-built hashing algorithm (bcrypt or argon2id) for passwords.
- Sign JWTs with RS256 and set short expiry times.
- Store tokens in httpOnly cookies, never in localStorage.
- Regenerate session IDs after login and use a persistent session store.
- Add rate limiting to login endpoints and protect them with MFA.
- Run
npm auditin CI to catch vulnerable dependencies before they reach production.
If you want most of this handled for you out of the box, Authgear provides a Node.js SDK with secure authentication, MFA, passkeys, and social login — so your team can focus on building your product rather than maintaining an auth stack.



