CWE-287: Improper Authentication - JavaScript
Overview
In Node.js/Express applications, authentication is usually built on Passport.js strategies, a JWT library such as jsonwebtoken, or a session middleware like express-session. Improper authentication commonly appears as a Passport Strategy verify callback that fetches a user record and calls done(null, user) without ever comparing a password hash - granting access to anyone who supplies a known username. It also appears in JWT handling: jwt.decode() performs no signature verification at all, so code that treats its output as trusted accepts any token an attacker chooses to forge; jwt.verify() without an explicit algorithms allowlist leaves the token's own header free to select a weaker or unintended algorithm.
The fix is to compare a hashed credential in every verify callback, always call jwt.verify(token, key, { algorithms: [...] }) with an explicit allowlist, and regenerate the session on login to prevent fixation.
Common Vulnerable Patterns
Passport Verify Callback That Skips the Password Check
// VULNERABLE - authenticates on "user exists," never checks the password
const { Strategy: LocalStrategy } = require('passport-local');
passport.use(new LocalStrategy(async (username, password, done) => {
const user = await User.findOne({ username });
if (!user) return done(null, false, { message: 'Invalid credentials' });
return done(null, user); // password argument is never compared to anything
}));
// Attack example:
// POST /login with username=admin and password=anything
// Result: authenticated as admin - the callback never checked the supplied password
Why this is vulnerable: The verify callback receives password as an argument but never uses it. Any request naming a valid username authenticates successfully, regardless of what password value is supplied.
jwt.decode() Used for a Trust Decision
// VULNERABLE - decode() only base64-decodes the payload; it never checks the signature
const jwt = require('jsonwebtoken');
app.get('/api/profile', (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
const payload = jwt.decode(token); // no signature verification happens here
res.json({ userId: payload.sub, role: payload.role });
});
// Attack example:
// Craft any JWT with header/payload of your choice, base64url-encode it, append
// any signature segment (even garbage) - jwt.decode() returns the forged payload
// Result: attacker sets role: "admin" in a self-signed token and it is trusted
Why this is vulnerable: jwt.decode() is documented to skip signature verification - it exists for reading claims from a token whose authenticity was already established elsewhere, not as a way to authenticate a request. Using it as the only check means any well-formed (but unsigned or forged) token is accepted.
jwt.verify() Without a Pinned Algorithm List
// VULNERABLE - no algorithms option; the token's own header can select the algorithm
function verifyToken(token) {
return jwt.verify(token, process.env.JWT_SECRET);
}
// Attack example:
// If the server also holds an RSA public key anywhere reachable (e.g. for a
// separate RS256-signed integration), an attacker can craft an HS256 token
// signed with that public key as the HMAC secret and pass verification
Why this is vulnerable: Without algorithms: [...], jsonwebtoken infers acceptable verification behavior from the key format rather than a hardcoded expectation, which enables algorithm-confusion attacks when the application handles more than one key type anywhere in the same process.
Secure Patterns
Verify a Hashed Credential in Every Strategy Callback
// SECURE - compares a bcrypt hash before calling done(null, user), and spends the
// same time on an unknown username
const bcrypt = require('bcrypt');
const { Strategy: LocalStrategy } = require('passport-local');
// A real bcrypt hash at the same cost as the stored ones, used only to spend the
// same time on an unknown username. It has to be a genuine hash: bcrypt.compare()
// against '' or a malformed string returns in under a millisecond and the timing
// gap reopens.
const DUMMY_HASH = '$2b$12$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG';
passport.use(new LocalStrategy(async (username, password, done) => {
const user = await User.findOne({ username });
// ?? covers both "no such user" and a user row with no password (an SSO-only
// account): bcrypt.compare() throws on a null or undefined hash.
const match = await bcrypt.compare(password, user?.passwordHash ?? DUMMY_HASH);
if (!user || !match) return done(null, false, { message: 'Invalid credentials' });
return done(null, user);
}));
Why this works: bcrypt.compare() hashes the submitted password with the same salt embedded in user.passwordHash and compares the result, so authentication only succeeds when the supplied password actually matches what was stored - not merely because the username exists. done(null, false, ...) on any mismatch gives Passport a clear, consistent failure signal, and the message stays generic (Invalid credentials) so the response body cannot be used to enumerate valid usernames.
Comparing against DUMMY_HASH rather than returning early is what stops the response time from answering the same question. Measured on Node 24.3 with cost 12, a wrong password for a real user took 231 ms and an unknown username 0.004 ms - a 61,000x gap any client can time, with no failed-login message involved. The dummy has to be a genuine hash at the same cost: bcrypt.compare(password, '') returns false in 0.028 ms and leaves an 8,000x gap, so it closes nothing. Every login now pays the full hashing cost, so rate-limit the endpoint.
Verify JWTs With an Explicit Algorithm Allowlist
// SECURE - algorithms is pinned; signature, expiration, and format are all checked
const jwt = require('jsonwebtoken');
function verifyToken(token) {
return jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
// throws JsonWebTokenError/TokenExpiredError on any failure - never returns
// unverified claims
}
app.get('/api/profile', (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Missing token' });
try {
const payload = verifyToken(token);
res.json({ userId: payload.sub, role: payload.role });
} catch (err) {
res.status(401).json({ error: 'Invalid or expired token' });
}
});
Why this works: Passing { algorithms: ['HS256'] } tells jwt.verify() to accept only that specific algorithm regardless of what the token's own header claims, closing off algorithm-confusion attacks. jwt.verify() (unlike jwt.decode()) checks the cryptographic signature, and by default enforces exp/nbf unless ignoreExpiration/ignoreNotBefore is explicitly (and incorrectly) set to true. Any failure - bad signature, wrong algorithm, or expired token - throws, so payload is only ever populated from a token that passed every check.
Regenerate the Session on Login
// SECURE - a new session ID is issued before the login is stored
app.post('/login', (req, res, next) => {
passport.authenticate('local', (err, user, info) => {
if (err) return next(err);
if (!user) return res.status(401).json({ error: info?.message });
req.session.regenerate((err) => {
if (err) return next(err);
req.login(user, (err) => {
if (err) return next(err);
res.json({ success: true });
});
});
})(req, res, next);
});
Why this works: req.session.regenerate() discards whatever session ID the request arrived with and issues a fresh one before the authenticated login is attached to it, so a session ID an attacker set on the victim's browser before login (via a crafted link or cookie injection) is never promoted to an authenticated session.
Check your Passport version before writing this up as the fix. Since Passport 0.6.0 - the release that fixed CVE-2022-25896 - req.login() does this itself: SessionManager.logIn calls req.session.regenerate() and serializes the user inside that callback, so the session Passport populates is one it regenerated. On 0.6.0 and later the wrapper above is a second regeneration rather than the mechanism, and a bare req.login(user, cb) is already safe; on 0.5.x and earlier the wrapper is what closes the finding, which is why it is still shown here. Two things bite either way:
- Regeneration empties the session. A flash message, a CSRF token or a
returnTopath written before login is gone unless you pass{ keepSessionInfo: true }toreq.login(), which merges the previous session back in. A reader who adds regeneration and then loses their post-login redirect target is hitting this, not a bug. - The store has to implement
regenerate.express-sessiondoes;cookie-sessiondoes not, and the symptom isTypeError: req.session.regenerate is not a functionat the first login rather than anything security-shaped.
Framework-Specific Guidance
Express Session Cookie Hardening
// SECURE - session cookie configuration
const session = require('express-session');
app.set('trust proxy', 1); // required behind a reverse proxy for secure cookies
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true, // requires HTTPS
sameSite: 'strict',
maxAge: 1000 * 60 * 60, // 1 hour
},
}));
httpOnly prevents client-side script from reading the session cookie (defense-in-depth against XSS-driven session theft), secure stops it from ever being sent over plain HTTP, and sameSite: 'strict' reduces cross-site request exposure. None of these substitute for session.regenerate() on login - they protect the cookie in transit and storage, not the fixation window.
Express Middleware for Route Protection
// SECURE - centralize the auth check as middleware applied per route group
function requireAuth(req, res, next) {
if (!req.isAuthenticated || !req.isAuthenticated()) {
return res.status(401).json({ error: 'Authentication required' });
}
next();
}
const router = express.Router();
router.use(requireAuth);
router.get('/account', accountHandler);
router.get('/admin', adminHandler);
app.use('/api', router);
Applying requireAuth once to the whole router, rather than inside each handler, means a newly added route under /api is protected by default instead of depending on every handler author remembering the check.
Testing
- Submit the correct username with a wrong password and confirm the strategy returns
done(null, false, ...), not a successful login. - Time three logins - known username with the right password, known username with a wrong password, unknown username - and assert all three are within noise of each other. A sub-millisecond answer for the unknown username is the enumeration oracle, and a re-scan cannot see it. Assert on the unknown-username status too:
401, not a500frombcrypt.compare()being handed a missing hash. - Craft a JWT with
alg: noneor a forged signature and confirmjwt.verify()throwsJsonWebTokenErrorrather than accepting the decoded payload. - Submit an expired token (
expin the past) and confirmTokenExpiredErroris thrown, not swallowed silently. - Capture the session cookie issued before login, log in, and confirm the pre-login cookie no longer authenticates a request (session fixation test).
- Re-scan with the security tool that originally reported the finding to confirm it no longer fires.
Common Pitfalls
- Calling
jwt.decode()for "just reading a claim for logging" in the same code path that later uses a header value for an authorization decision - even debug-only decode calls are risky if their output leaks into a conditional anywhere downstream; keepdecode()calls clearly separated from any trust boundary. - Setting
ignoreExpiration: trueonjwt.verify()to work around a clock-skew issue in development, and shipping that option to production - this disables expiration enforcement entirely rather than adding tolerance; use theclockToleranceoption for a bounded skew allowance instead. - Regenerating the session in
express-sessionbut leaving a duplicate, hand-rolled "logged in" flag in a separate cookie set outside the session store - the duplicate cookie is a second, unregenerated trust signal an attacker can still fixate. - Passing an array of both HMAC and RSA algorithm names to
algorithms(for example['HS256', 'RS256']) when the issuer only ever signs with one - every additional accepted algorithm widens the surface for algorithm-confusion rather than adding safety margin.
Dependencies and Installation
jsonwebtoken(npm install jsonwebtoken) - keep on a current maintained major version; always passalgorithmstoverify().bcrypt(npm install bcrypt) orargon2(npm install argon2) for password hashing/verification - avoid rolling a custom hash comparison.passportand the specific strategy package in use (passport-local,passport-jwt, etc.) -npm install passport passport-local.express-session(npm install express-session) with a production-grade session store (Redis, PostgreSQL) rather than the defaultMemoryStore, which is documented as unsuitable for production.
Migration Considerations
Adding a real password comparison to a verify callback that previously authenticated on username alone will reject any integration, service account, or test harness that relied on the old "username only" behavior - audit for those before deploying. Tightening jwt.verify() to a pinned algorithms list will reject tokens signed with an algorithm no longer accepted, including any still in circulation when the change ships - coordinate the rollout so issuance and verification change together, and monitor for a spike in 401 responses during the transition.