Skip to content

API routes return 500 instead of 400 when the request body is not valid JSON #7150

Open

Description

@maphew

Problem

apps/web API routes call await request.json() directly on the untrusted request body. When the body is not valid JSON, that call rejects with a SyntaxError. Where the call is not wrapped in a try/catch and has no .catch() on it, the exception escapes the route handler and Next.js responds 500 for what is a client error. The correct status is 400.

Two routes (verify-turnstile, update-email) additionally destructure the parsed value, so a body of literal null — which is valid JSON — throws an uncaught TypeError and also produces a 500.

Verified reproduction

Measured by invoking each route's exported POST with Content-Type: application/json and a body of not-json{, on main at 332f0c033:

Route not-json{ body null
/api/auth/magic-link 500 (uncaught SyntaxError) 400
/api/auth/verify-turnstile 500 (uncaught SyntaxError) 500 (uncaught TypeError)
/api/billing/update-email 500 (uncaught SyntaxError) 500 (uncaught TypeError)
/api/code-indexing/search 500 (uncaught SyntaxError) n/a — passes null to tRPC input validation
/api/code-indexing/delete 500 (uncaught SyntaxError) n/a — passes null to tRPC input validation
/admin/api/abuse/bulk-block 500 (uncaught SyntaxError) 400

Why it matters

/api/auth/magic-link and /api/auth/verify-turnstile are unauthenticated. Any client that can reach the app can POST a malformed body and get a 5xx back. That is a 5xx raised by 4xx-class input, which is exactly the signal that pollutes error-rate alerting and burns an error budget, and it tells the caller "we broke" when the truth is "your request was malformed". It also means the response a well-behaved client gets for a bad request is indistinguishable from a genuine server fault, so retry logic cannot do the right thing.

Scope on main

Of the 49 await request.json() call sites in apps/web/src/app/**/route.ts:

  • 12 already guard with .catch() on the same line and are fine.
  • 26 already sit inside a try/catch, so the route handles the parse failure. Whether each returns a 4xx from its catch is a separate per-route question and is not triaged here.
  • 11 are genuinely unhandled, so a malformed body escapes as a 500.

The 11 unhandled sites:

Route Status
/api/auth/magic-link fixed by #6780
/api/auth/verify-turnstile fixed by #6780
/api/billing/update-email fixed by #6780
/api/code-indexing/search fixed by #6780
/api/code-indexing/delete fixed by #6780
/admin/api/abuse/bulk-block fixed by #6780
/api/exa/[...path] fixed on an unmerged branch, see below
/admin/api/enrichment-data/upsert open
/admin/api/organizations/[id]/consume-credits open
/admin/api/users/[id]/dev/insert-usage-record open
/admin/api/users/add-credit open

The four open ones are behind adminOnly auth, so they are lower severity than the two auth routes. /admin/api/enrichment-data/upsert is the notable one: it parses the body before delegating to caller.admin.enrichmentData.upsert, so the 500 is reachable ahead of the tRPC procedure's own authorization.

The exa / discord "wave 1" was never opened as a PR

#6780 refers to a "Wave 1 PR (exa, discord)" that is still open and awaiting a rebase. No such PR exists on this repository. The work exists only as a branch on a contributor fork — fix/request-body-invalid-json-400, commit 5fb4663d2 — and it covers /api/exa/[...path] and the discord route. If that branch is dropped, the exa fix is lost with it.

Suggested fix

Match the convention already used by the 12 guarded call sites: parse defensively, then map the failure to the 400 response shape the route already uses for a schema violation, and return before any side effect.

const body: unknown = await request.json().catch(() => undefined);

For routes that destructure, narrow to the expected type rather than destructuring blind, so a null body takes the same 400 path instead of throwing. For routes whose 400 shape is a zod result, safeParse(undefined) already fails correctly and needs no extra handling.

Test note for anyone picking this up

The apps/web jest suite runs globalSetup + setupFilesAfterEach that create and migrate a per-worker Postgres database, so pnpm --filter web test needs a reachable POSTGRES_URL. A route-body repro does not need the database and can be run against a copy of apps/web/jest.config.ts with the two DB-dependent setup entries removed.

Node 24 is required (engines: ">=24 <25"); the suite does not run on Node 22.

Activity

  1. maphew commented on Oct 4, 2026

    @maphew
    ContributorAuthor

    Partial fix: #6780 covers 6 of the 11 unhandled sites (magic-link, verify-turnstile, update-email, code-indexing search, code-indexing delete, admin bulk-block).

    Its change was verified by executing each route's POST before and after: every not-json{ case goes from an uncaught SyntaxError (500) to the route's existing 400 shape, and the two null-body TypeErrors in verify-turnstile and update-email become 400.

    Still open after that PR lands:

    • /admin/api/enrichment-data/upsert — parses before the tRPC auth check, so the 500 is reachable pre-authorization
    • /admin/api/organizations/[id]/consume-credits
    • /admin/api/users/[id]/dev/insert-usage-record
    • /admin/api/users/add-credit
    • /api/exa/[...path] — fixed only on an unmerged fork branch, never PR'd
  2. maphew commented on Oct 4, 2026

    @maphew
    ContributorAuthor

    Two operational findings that affect how this gets fixed.

    1. Fork PRs never get CI on this repo. Every workflow run on a fork branch concludes action_required with zero jobs executed, pending maintainer approval. This affects all 8 open PRs. It means an unlinked fix like this one can sit for a week with no signal that its tests were never executed — #6780's tests had never run until they were run locally on Node 24.

    2. The wave 1 work is still applicable as-is. The fix/request-body-invalid-json-400 branch (5fb4663d2) has not drifted: neither apps/web/src/app/api/exa/[...path]/route.ts nor apps/web/src/app/discord/webhook/route.ts has changed on main since its base f2ee0e6f2. The diff against current main is exactly the original change, so it can be opened as its own PR with no rebase and no conflict against #6780 (which touches neither file).

    Worth noting apps/web/src/app/discord/webhook/route.ts is outside this issue's original scope: it parses via request.text() + JSON.parse() at lines 51 and 80 rather than request.json(), so it was missed by a request.json() sweep. Any future sweep should match JSON.parse( as well.

  3. maphew commented on Oct 5, 2026

    @maphew
    ContributorAuthor

    Status update: the exa and discord work is no longer stranded. It has been folded into #6780 as a single commit, so there is now one landing point for this defect class instead of two.

    #6780 is now 8 routes / 16 files and covers 7 of the 11 unhandled sites — the original six plus /api/exa/[...path]. /discord/webhook is covered too, though it was never in the 11 because it parses via request.text() + JSON.parse() rather than request.json().

    What consolidation surfaced: discord/webhook/route.test.ts had import type { NextRequest } — a type-only import, erased at compile time — while calling new NextRequest(...). All 9 tests in that suite failed with ReferenceError: NextRequest is not defined. It has never been run, because the branch it lived on never got CI and no PR was opened. Fixed as part of the consolidation; the suite now passes.

    Combined verification on the consolidated branch, Node 24.21.0 with the repo's Postgres setup: 8 suites / 74 tests pass, and all 8 fail when only the corresponding route fix is reverted — 14 tests across the two newly added routes. Typecheck, lint and format all clean across all 16 files.

    Still open after #6780 lands — 4 admin routes:

    Route Note
    /admin/api/enrichment-data/upsert parses before its tRPC auth check, so the 500 is reachable pre-authorization
    /admin/api/organizations/[id]/consume-credits admin-gated
    /admin/api/users/[id]/dev/insert-usage-record admin-gated
    /admin/api/users/add-credit admin-gated

    None of these has a test yet, so a follow-up PR would need new test files written and the environment rebuilt to run them. That is the remaining work on this issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions