Software Securityhome
Fix a findingSearch
Fix card · A07:2025 Authentication Failures

Fix missing authentication for a critical function in Node.js and Express

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Verify a credential on the server before any critical route runs. Here the guard looks a bearer token up in the sessions table and sets req.user from the stored row, or answers 401; it never trusts a user id from the query string or a header the client can set. Apply the same guard to every protected route.

Stack
  • Language JavaScript (ES modules)
  • Runtime Node.js 26.11.1 (node:sqlite needs 22.13.0 or later without a flag)
  • Framework Express 5.3.0
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 24 KB
On this page

Steps

  1. List the routes that expose user or internal data (here /me and /admin/metrics) and the one or two that are genuinely public (/health). Treat every route as protected unless you decided otherwise.
  2. Write one authentication guard that reads the credential, verifies it against server-side state (a session store, or a signed token you validate), and sets req.user from the result or answers 401.
  3. Mount the guard on every protected route (app.get('/me', requireUser, handler)) or, for an all-protected area, with app.use before the routes. Do not leave the decision to each handler.
  4. Take the user id, tenant and role from the verified credential only. Delete any code that reads identity from req.query, req.body or a header such as X-Authenticated-User or X-User-Id.
  5. Answer 401 for an absent, unknown or malformed credential, and keep the body free of user data.
  6. Add regression tests that call each protected route with no credential, with a forged identity header, and with a made-up token, and assert 401.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableimpl/vulnerable.jsJavaScript
export function authenticate(db) {
  const findUser = db.prepare('SELECT id, email, role FROM users WHERE id = ?');

  return (req, _res, next) => {
    const claimed = typeof req.query.userId === 'string' ? req.query.userId : undefined;
    const row = claimed ? findUser.get(claimed) : undefined;
    // Nothing is verified: the caller picks who they are, and an unknown or missing
    // id still gets through as an anonymous guest.
    req.user = row ?? { id: claimed ?? 'guest', email: '', role: 'member' };
    next();
  };
}

With IMPL=vulnerable, 7 of 9 tests fail (4 exploit tests).

The fix

Fixedimpl/fixed.jsJavaScript
export function authenticate(db) {
  const findSession = db.prepare(`
    SELECT u.id, u.email, u.role
    FROM sessions s JOIN users u ON u.id = s.user_id
    WHERE s.token = ?`);

  return (req, res, next) => {
    const header = req.get('authorization') ?? '';
    const match = /^Bearer (\S+)$/.exec(header);
    const user = match ? findSession.get(match[1]) : undefined;
    if (user === undefined) {
      return res.status(401).json({ error: 'authentication required' });
    }
    req.user = user;
    next();
  };
}

With IMPL=fixed, 9 of 9 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.jsJavaScript
export function authenticate(db) {
  const findUser = db.prepare('SELECT id, email, role FROM users WHERE id = ?');

  return (req, res, next) => {
    // "The gateway strips and re-sets this header after it authenticates." Except
    // the gateway can be bypassed, and then the header is whatever the client sent.
    const claimed = req.get('x-authenticated-user');
    const row = claimed ? findUser.get(claimed) : undefined;
    if (row === undefined) {
      return res.status(401).json({ error: 'authentication required' });
    }
    req.user = row;
    next();
  };
}

With IMPL=wrong, 4 of 9 tests fail (2 exploit tests).

Routes: one guard on every protected route

app.jsJavaScript
// impl.authenticate(db) builds the guard. It must set req.user from a credential
// it verifies on the server, or answer 401. /health is the one public route; /me
// and /admin/metrics are critical functions that must not run without a caller.
const requireUser = impl.authenticate(db);

app.get('/health', (_req, res) => res.json({ status: 'ok' }));

app.get('/me', requireUser, (req, res) => {
  const row = db.prepare('SELECT id, email, role FROM users WHERE id = ?').get(req.user.id);
  if (row === undefined) return res.status(404).json({ error: 'not found' });
  res.json(row);
});

app.get('/admin/metrics', requireUser, (_req, res) => {
  const { n } = db.prepare('SELECT COUNT(*) AS n FROM users').get();
  res.json({ userCount: n });
});

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtest/account.test.jsJavaScript
describe(`exploit (IMPL=${implName})`, () => {
  // Reachable without a credential, or defeated by a header the client sets: both
  // are missing authentication. Each request must be refused and leak no data.
  async function assertRefused(path, headers) {
    const res = await get(path, headers);
    assert.equal(res.status, 401, `expected 401, got ${res.status} ${res.body}`);
    assert.ok(!res.body.includes('@example.com'), `response leaked a profile: ${res.body}`);
  }

  test('the internal metrics are not reachable without a credential', async () => {
    await assertRefused('/admin/metrics');
  });

  test('a profile cannot be read by naming a user in the query string', async () => {
    await assertRefused('/me?userId=bob');
  });

  test('a forged identity header does not reach the internal metrics', async () => {
    await assertRefused('/admin/metrics', { 'x-authenticated-user': 'sam' });
  });

  test('a forged identity header does not authenticate a profile read', async () => {
    await assertRefused('/me', { 'x-authenticated-user': 'bob' });
  });
});

Pitfalls

  • Trusting a header an upstream proxy is supposed to set (X-Authenticated-User, X-Forwarded-User, X-User-Id). If the app is reachable without going through the proxy, the client sets the header. That is this card's wrong implementation.
  • Protecting the route everyone remembers and forgetting a sibling: a new /admin/export or a /me.json added later ships without the guard. Mounting the guard on a router or with app.use covers them by default.
  • Reading identity from the request body or query string, so the caller answers the authentication question themselves.
  • Treating a present-but-unverified token as proof: compare it against stored session state or verify its signature; never accept any non-empty token.
  • A health or metrics endpoint that leaks internal data (user counts, versions, configuration) is a critical function, not a public one. Keep it behind the guard or strip it to a liveness check.
  • Authentication is not authorization: once the guard sets req.user, still check that the user may read the specific object (see the missing object-level authorization card).

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs missing-authentication/node-express does this for you).
  2. Against your own API, call every route that returns user or internal data with no Authorization header, then with a forged X-Authenticated-User (or whatever header your proxy sets), then with a random bearer token. Each must get 401 and no data.
  3. Do not expect a scanner to find this: no Semgrep registry rule or CodeQL query for Express knows which routes must be authenticated, and Semgrep's OWASP Top 10 pack reported nothing on the vulnerable code. The regression tests are the control.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix missing authentication for a critical function in Node.js and Express
ToolRuleWhat it found here
Semgrepp/owasp-top-ten0 findings across 73 rules on app.js and impl/*.js. No rule models an unprotected route: the vulnerable, fixed and wrong guards look the same to every rule in these packs.Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0

No Semgrep registry rule or CodeQL query for JavaScript flags a route that is reachable without authentication, or a guard that trusts a client-set header such as X-Authenticated-User. Static analysis cannot know which routes are meant to be protected, so the regression tests are the control.

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJavaScript (ES modules)
RuntimeNode.js 26.11.1 (node:sqlite needs 22.13.0 or later without a flag)
FrameworkExpress 5.3.0
express5.3.0
node:sqlite (built in)Node.js 26.11.1; SQLite 3.53.4 at run time

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the missing-authentication/node-expressfolder.

Shell
npm ci
Shell
# Expected: every test passes
IMPL=fixed npm test --silent

# Expected: an exploit test fails
IMPL=vulnerable npm test --silent

# Expected: a test fails
IMPL=wrong npm test --silent

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs missing-authentication/node-express runs all three modes and checks the expected results.

Download the project ZIP, 24 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • node 26.11.1
  • npm 11.20.0
  • sqlite 3.53.4
Matrix
fixed: 9 of 9 tests pass; vulnerable: 7 of 9 tests fail (4 exploit tests); wrong: 4 of 9 tests fail (2 exploit tests)
Test results
IMPL=fixed 9/9 pass; IMPL=vulnerable 7/9 fail (4 exploit: metrics open, profile read by query-string id, and the forged header reaching each route); IMPL=wrong 4/9 fail (2 exploit: the forged header reaching each route, plus the two valid-session reads it cannot serve).

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Top 10:2025 A07 Authentication Failures
  2. OWASP API Security Top 10 2023: API2 Broken Authentication
  3. CWE-306: Missing Authentication for Critical Function
  4. OWASP Authentication Cheat Sheet
  5. OWASP Session Management Cheat Sheet
  6. Express: Writing middleware
  7. Express: Using middleware
  8. Node.js v26: SQLite (node:sqlite)

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.