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

Fix missing authentication for a critical function in Next.js

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 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.

Stack
  • 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)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 33 KB
On this page

Steps

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Return 401 with no user data for an absent, unknown or malformed credential.
  6. Add regression tests that call each protected handler with no credential, a forged identity header and 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

Vulnerablelib/impl/vulnerable.tsTypeScript
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

Fixedlib/impl/fixed.tsTypeScript
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

Wrong fixlib/impl/wrong.tsTypeScript
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

app/me/route.tsTypeScript
// 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 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.

Testtests/account.test.tsTypeScript
// 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' });
  });
});

Pitfalls

  • 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.

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/typescript-nextjs does this for you).
  2. 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.
  3. 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.

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 Next.js
ToolRuleWhat it found here
Semgrepp/owasp-top-ten0 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.

Libraries and versions

Libraries and versions
ComponentVersion
LanguageTypeScript (Next.js App Router)
RuntimeNode.js 26.11.1 (node:sqlite needs 22.13.0 or later; route handlers use the default Node.js runtime)
FrameworkNext.js 16.4.0 (App Router route handlers)
next16.4.0
react, react-dom19.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

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/typescript-nextjsfolder.

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/typescript-nextjs runs all three modes and checks the expected results.

Download the project ZIP, 33 KB

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

  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. Next.js: Route Handlers
  6. Next.js: Authentication

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.