Skip to content

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 returnTo path written before login is gone unless you pass { keepSessionInfo: true } to req.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-session does; cookie-session does not, and the symptom is TypeError: req.session.regenerate is not a function at the first login rather than anything security-shaped.

Framework-Specific Guidance

// 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 a 500 from bcrypt.compare() being handed a missing hash.
  • Craft a JWT with alg: none or a forged signature and confirm jwt.verify() throws JsonWebTokenError rather than accepting the decoded payload.
  • Submit an expired token (exp in the past) and confirm TokenExpiredError is 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; keep decode() calls clearly separated from any trust boundary.
  • Setting ignoreExpiration: true on jwt.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 the clockTolerance option for a bounded skew allowance instead.
  • Regenerating the session in express-session but 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 pass algorithms to verify().
  • bcrypt (npm install bcrypt) or argon2 (npm install argon2) for password hashing/verification - avoid rolling a custom hash comparison.
  • passport and 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 default MemoryStore, 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.

Additional Resources