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.
- 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.
- 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.
- 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.
- 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.
- Answer 401 for an absent, unknown or malformed credential, and keep the body free of user data.
- 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.
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
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
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
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
// 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 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.
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' });
});
});- 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).
- 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).
- 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.
- 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.
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.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/owasp-top-ten | 0 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.
| Component | Version |
|---|---|
| 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 |
| express | 5.3.0 |
| node:sqlite (built in) | Node.js 26.11.1; SQLite 3.53.4 at run time |
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.
npm ci# 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 --silentEach 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.
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
- OWASP Top 10:2025 A07 Authentication Failures
- OWASP API Security Top 10 2023: API2 Broken Authentication
- CWE-306: Missing Authentication for Critical Function
- OWASP Authentication Cheat Sheet
- OWASP Session Management Cheat Sheet
- Express: Writing middleware
- Express: Using middleware
- Node.js v26: SQLite (node:sqlite)
Attack patterns: