Verify a credential on the server in every protected route handler. Here the guard looks a bearer token up in the sessions table and returns the stored user, or the handler answers 401; it never trusts a userId query parameter or an X-Authenticated-User header. Keep /health public and guard /me and /admin/metrics.
- List the route handlers that return user or internal data (here app/me and app/admin/metrics) and the public ones (app/health). Treat every handler as protected unless you decided otherwise.
- Write one authentication function that reads the credential from the Request, verifies it against server-side state (a session store, or a signed token you validate with jose), and returns the user or undefined.
- Call it at the top of each protected handler and return a 401 Response on undefined. For a whole protected area, enforce it in middleware.ts as well, but keep the per-handler check: middleware matching is easy to get wrong.
- Take identity only from the verified credential. Never read a user id, tenant or role from the query string, the body or a header such as X-Authenticated-User.
- Return 401 with no user data for an absent, unknown or malformed credential.
- Add regression tests that call each protected handler with no credential, a forged identity header and 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(request: Request): User | undefined {
const claimed = new URL(request.url).searchParams.get('userId') ?? undefined;
const row = claimed
? (getDb().prepare('SELECT id, email, role FROM users WHERE id = ?').get(claimed) as User | undefined)
: undefined;
// Nothing is verified: the caller picks who they are, and an unknown or missing id
// still gets through as an anonymous guest.
return row ?? { id: claimed ?? 'guest', email: '', role: 'member' };
}With IMPL=vulnerable, 7 of 9 tests fail (4 exploit tests).
The fix
export function authenticate(request: Request): User | undefined {
const header = request.headers.get('authorization') ?? '';
const match = /^Bearer (\S+)$/.exec(header);
if (!match) return undefined;
const row = getDb()
.prepare(
`SELECT u.id, u.email, u.role
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = ?`,
)
.get(match[1]) as User | undefined;
return row;
}With IMPL=fixed, 9 of 9 tests pass.
A fix that looks safe but is not
export function authenticate(request: Request): User | undefined {
// "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 = request.headers.get('x-authenticated-user');
if (!claimed) return undefined;
return getDb().prepare('SELECT id, email, role FROM users WHERE id = ?').get(claimed) as User | undefined;
}With IMPL=wrong, 4 of 9 tests fail (2 exploit tests).
Route handler: the guard runs before the data
// currentImpl().authenticate is the guard: it returns the signed-in user built from
// a verified credential, or undefined. The handler answers 401 on undefined and
// never reads identity from the request itself.
export async function GET(request: Request) {
const user = currentImpl().authenticate(request);
if (user === undefined) return Response.json({ error: 'authentication required' }, { status: 401 });
const row = getDb().prepare('SELECT id, email, role FROM users WHERE id = ?').get(user.id);
if (row === undefined) return Response.json({ error: 'not found' }, { status: 404 });
return Response.json(row);
}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.
// exploit: reachable without a credential, or defeated by a client-set header.
describe(`exploit (IMPL=${implName})`, () => {
async function assertRefused(route: 'me' | 'metrics', headers: Record<string, string> = {}, query = '') {
const res = await call(route, headers, query);
expect(res.status, res.body).toBe(401);
expect(res.body.includes('@example.com'), `response leaked a profile: ${res.body}`).toBe(false);
}
test('the internal metrics are not reachable without a credential', async () => {
await assertRefused('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('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 meant to set (X-Authenticated-User, X-Forwarded-User). If a route handler is reachable without the proxy (and in Next.js it is, directly on the Node server), the client sets the header. That is this card's wrong implementation.
- Relying on middleware.ts alone: its matcher can miss a route, route groups and rewrites change paths, and a handler with no check of its own then runs unauthenticated. Check in the handler too.
- Reading identity from request.nextUrl.searchParams, the body or cookies without verifying a signature or a server-side session.
- Doing the check in a Client Component or a layout: only server code decides authentication, and a route handler is reachable without ever rendering the page.
- A metrics or health handler that returns internal counts, versions or configuration is a critical function; keep it behind the guard.
- Authentication is not authorization: after the guard names the user, still check they may read the specific object.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs missing-authentication/typescript-nextjs does this for you).
- Against your own app, call each protected route with no Authorization header, with a forged X-Authenticated-User and with a random bearer token. Each must get 401 and no data.
- Do not expect a scanner to find this: Semgrep's packs flag nothing here and CodeQL has no query for an unauthenticated handler. 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 on the route handlers or lib/impl/*.ts. No rule knows which route handlers must be authenticated, so the vulnerable, fixed and wrong guards look the same.Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0 (semgrep scan --config p/owasp-top-ten --config p/javascript --config p/typescript --config p/security-audit app lib) |
In github/codeql main, Next.qll models App Router route handlers as request sources, but no query flags a handler that performs no authentication or one that trusts a client-set header. The regression tests are the control.
| Component | Version |
|---|---|
| Language | TypeScript (Next.js App Router) |
| Runtime | Node.js 26.11.1 (node:sqlite needs 22.13.0 or later; route handlers use the default Node.js runtime) |
| Framework | Next.js 16.4.0 (App Router route handlers) |
| next | 16.4.0 |
| react, react-dom | 19.3.0 |
| node:sqlite (built in) | Node.js 26.11.1; SQLite 3.53.4 at run time |
| vitest (test only) | 5.0.3 |
| typescript (type check and next build only) | 6.0.3 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the missing-authentication/typescript-nextjsfolder.
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/typescript-nextjs 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
- next 16.4.0
- react 19.3.0
- vitest 5.0.3
- typescript 6.0.3
- node:sqlite built in (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); IMPL=wrong 4/9 fail (2 exploit). tsc --noEmit passes.
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
- Next.js: Route Handlers
- Next.js: Authentication
Attack patterns: