ALL POSTS
next.jsapi keysauthenticationapi design

Next.js API key authentication: the matcher that skips your API

The negative matcher in the Next.js docs excludes /api, so a key check in proxy.ts never runs on your API routes. Measured on Next.js 16.2.12, plus where the check actually belongs.

Sorower

Sorower

Co-founder

Aug 3, 202612 min read
In this article

You shipped a Next.js API key authentication check in middleware.ts, copied the matcher straight out of the docs, watched a request with a bad key get a clean 401 on /dashboard, and called it done. Then somebody curled /api/reports with no key at all and got a 200 with real data.

The matcher was the problem. Then Next.js 16 landed and renamed the whole file convention out from under you, which is a good moment to ask whether the check ever belonged there.

This post is the answer for the machine-client case: you are serving an API to other people's code, keys are how you know who they are, and you need the check to run every single time. Not sessions, not JWT, not a login page.

What Next.js 16 actually changed

Next.js 16 shipped on 21 October 2025 and renamed middleware.ts to proxy.ts. The release notes are blunt about the scope: "Rename middleware.tsproxy.ts and rename the exported function to proxy. Logic stays the same." There is a codemod:

npx @next/codemod@canary middleware-to-proxy .

The old filename still works. I checked, because "deprecated" covers everything from a gentle nudge to a hard crash. On Next.js 16.2.12, a project with middleware.ts boots and serves requests, with one line of complaint on startup:

⚠ The "middleware" file convention is deprecated. Please use "proxy" instead.
  Learn more: https://nextjs.org/docs/messages/middleware-to-proxy

[MIDDLEWARE RAN] /api/ping
 GET /api/ping 200 in 89ms (next.js: 56ms, proxy.ts: 25ms, application-code: 8ms)

Look at the timing breakdown on that last line. The file on disk is called middleware.ts and Next still attributes the 25ms to proxy.ts. Internally the rename already happened; you are just running on the compatibility shim. The docs say it "is still available for Edge runtime use cases, but it is deprecated and will be removed in a future version."

The rename is not cosmetic. Vercel explained the reasoning in the proxy reference: the word "middleware" made people think of Express, so they treated it like an Express middleware stack, which it never was. A proxy is a network boundary that sits in front of your app. And proxy.ts now defaults to the Node.js runtime, so the old edge-runtime constraint that pushed people toward token-only checks is gone.

Here is the part that should change what you do, buried in the same docs page:

We recommend users avoid relying on Middleware unless no other options exist.

The framework is telling you its own request-interception layer is a last resort. That is an unusual thing for a framework to say about a feature, and it is worth taking literally.

The matcher in the docs excludes your API routes

This is the trap that costs people real data, and it is not a mistake anyone should feel bad about, because it is copied from the official docs. The negative-matching example reads:

export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - api (API routes)
     * - _next/static (static files)
     * ...
     */
    '/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
  ],
}

Read the first exclusion again. api. That pattern exists so your auth redirect does not fire on stylesheets and image requests, which is sensible. It also means every path under /api is invisible to the file you just put your key check in.

Arguing about this is less useful than measuring it, so here is the probe. A stock Next.js 16.2.12 app, one route handler, one proxy that stamps a header:

// app/api/ping/route.ts
export async function GET(request: Request) {
  return Response.json({
    route: "/api/ping",
    proxyRan: request.headers.get("x-proxy-ran") ?? "NO HEADER — proxy did not run",
  });
}

// proxy.ts
export function proxy(request: NextRequest) {
  console.log("[PROXY RAN]", request.nextUrl.pathname);
  const headers = new Headers(request.headers);
  headers.set("x-proxy-ran", "yes");
  return NextResponse.next({ request: { headers } });
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)"],
};

Two requests, one to a page and one to the API:

$ curl -s localhost:3941/api/ping
{"route":"/api/ping","proxyRan":"NO HEADER — proxy did not run"}

$ curl -s -o /dev/null -w "page status %{http_code}\n" localhost:3941/
page status 200

# server log
[PROXY RAN] /

One log line. The page was intercepted, the API route was not. If the only thing standing between a stranger and your data is a key check in that file, there is nothing standing between a stranger and your data.

Swap the matcher for one that actually covers the API and the header lands:

$ # matcher: ["/api/:path*"]
$ curl -s localhost:3941/api/ping
{"route":"/api/ping","proxyRan":"yes"}

So you can fix the matcher. The rest of this post is about why fixing the matcher is not the same as fixing the problem.

Request flow diagram: a request reaches proxy.ts, where the matcher may skip it, before reaching the route handler where the key check belongs

Three reasons the proxy is the wrong home for a key check

It is a routing config away from doing nothing. You just watched a one-line regex silently turn off authentication for an entire URL subtree, with no error, no warning, and a passing test suite if your tests hit pages. Security controls that fail silently when someone edits an unrelated config line are not controls, they are decorations.

Server Functions inherit the same blind spot. The docs spell this out: Server Functions are not separate routes, they are POSTs to the route where they are used, so a matcher that excludes a path also skips Server Function calls on that path. Their recommendation is to verify auth inside each Server Function rather than relying on the proxy alone. Same lesson, different door.

It has already been bypassed once, at the framework level. CVE-2025-29927 is rated Critical at CVSS 9.1. Next.js used an internal header, x-middleware-subrequest, to stop middleware recursing into itself, and trusted that header when it arrived from outside. Send it yourself and middleware did not run. Every authorization decision living in that file evaporated, no credentials required. It is patched in 12.3.5, 13.5.9, 14.2.25 and 15.2.3, and if you are on 16 you are fine. But the shape of the bug is the argument: a check that lives outside your handler can be skipped by anything that decides not to call it.

Infographic: three ways a proxy-level key check disappears, via a config edit, Server Functions, or a framework CVE

None of this makes proxy.ts useless. It makes it a filter, not a gate.

Where Next.js API key authentication belongs

In the route handler. Every time, on every handler, close to the thing being protected.

PlacementGood forWhat it cannot do
proxy.ts Cheap blanket rejection, CORS preflight, adding request headers, redirect logic Guarantee it ran. A matcher edit, a Server Function, or a route moved to a new prefix all remove it silently.
Route handler The actual authorization decision, per-consumer identity, quota deduction, correct status codes Cover a handler nobody wrapped. This is a discipline problem, and it is a solvable one.
Data access layer Catching internal callers and Server Functions that never touch a route handler Return a good HTTP response. By then you are deep in application code with no request context.

ByteGrad worked through the same question for session auth after the rename, and lands in the same place, which is a decent sanity check that this is not just an API-key opinion:

ByteGrad video thumbnail: Next.js 16 Middleware DEPRECATED, Authentication In Proxy Or Data Access Layer?

The version every tutorial shows you

Here is the handler-level check as it appears in most Next.js guides, cleaned up slightly:

// app/api/reports/route.ts
export async function GET(request: Request) {
  const key = request.headers.get("authorization")?.replace(/^Bearer /i, "");

  if (key !== process.env.API_KEY) {
    return Response.json({ error: "unauthorized" }, { status: 401 });
  }

  return Response.json({ reports: await loadReports() });
}

This is fine for exactly one situation: one caller, who is you, on an internal service. The moment a second customer exists it stops answering the questions you need answered.

Who sent this request? process.env.API_KEY has no owner, so your logs say "someone authorized." How do you revoke one customer without redeploying and breaking the other four? You don't. What stops one customer from burning your entire upstream budget in an afternoon? Nothing. And a string comparison against an env var is not the same as a lookup against stored keys, which is where hashing and lookup strategy start to matter.

Infographic: a production API key check needs identity, revocation, limits and distinct status codes

The real list, in the order you will need it:

  • Identity, not a boolean. A valid key should resolve to a consumer you can name, log, bill, and cut off individually.
  • Revocation that takes effect now. Not on next deploy. A leaked key is a live incident.
  • Per-consumer limits. Both kinds: how much (quota) and how fast (rate). They are different axes and belong at different levels.
  • Status codes a client library can act on. 401 for "I don't know who you are," 403 for "I know, and no," 402 when they are out of credit, 429 when they are going too fast. A blanket 401 for all four sends every customer to your support inbox.
  • Usage you can attribute. If you cannot answer "what did customer X call last Tuesday," you cannot bill it or debug it.

Doing it with ReqKey in the App Router

ReqKey's Node SDK ships a Next.js adapter, so the check is a wrapper around the handler rather than a separate file that may or may not run. Install it:

npm install reqkey

The App Router version wraps the exported method handler directly:

// app/api/payments/route.ts
import { getReqKey, withReqKey } from "reqkey/next";

export const POST = withReqKey(
  async (request) => {
    const decision = getReqKey(request);
    return Response.json(
      { created: true, creditsRemaining: decision?.creditsRemaining },
      { status: 201 },
    );
  },
  {
    projectKey: process.env.REQKEY_PROJECT_KEY,
    apiId: "api_payments",
    mode: "both",
    keyName: "X-StartupName-Key",
  },
);

Three things worth pointing at. getReqKey(request) reads the decision without mutating the request object, because Next.js freezes request objects in some runtimes and a wrapper that relies on mutation will quietly hand you undefined. mode: "both" validates the key and ships the request record for analytics in one pass; "validate" skips the logging if you only want the gate. And keyName is there because your customers' keys arrive in whatever header you told them to use, which is frequently not Authorization.

On the Pages Router the same package exports withReqKeyPages, which wraps a classic (req, res) handler instead.

Under the hood the adapter calls the public validation endpoint, which you can also hit directly if you would rather not add a dependency:

curl -X POST "https://api.reqkey.com/key/validate" \
  -H "Authorization: Bearer reqkey_xxx..." \
  -H "Content-Type: application/json" \
  -d '{
    "key": "prod_A1B2C3D4E5F6G7H8I9J0K1L2",
    "apiId": "api_payment",
    "credits": 1,
    "resource": "/api/payments"
  }'
{
  "valid": true,
  "requestId": "abc123xyz",
  "apiId": "api_payment",
  "apiName": "PaymentAPI",
  "creditsRemaining": 9995,
  "creditsLimit": 10000,
  "allowedApis": ["api_payment", "api_analytics"]
}

Note what comes back: not a boolean but a consumer's remaining balance, the APIs that key is allowed to touch, and a request ID you can quote in a support thread. A key that exists but is disabled returns 403, one whose consumer is out of credit returns 402, and the error reference lists the rest. That mapping is the whole difference between an auth check and an auth system.

Questions people actually ask

Should I delete proxy.ts entirely? No. Keep it for what a network boundary is good at: CORS preflight, redirects, stamping a request ID onto every inbound request. Just stop treating it as the thing that decides whether a request is allowed. If you want a cheap early rejection there for obviously malformed keys, fine, as long as the handler check exists and would still fail closed on its own.

My middleware.ts still works on 16. Do I have to migrate? Not today. It boots with a deprecation warning and the docs say it will be removed in a future version. The codemod is one command and touches two identifiers, so the migration is not the expensive part. The expensive part is discovering during the upgrade that your matcher never covered /api anyway.

Is a route-handler check slower than a proxy check? A validation hop costs latency wherever you put it, and moving it later in the request does not make it free. What changes is that you only pay it on requests that reached a handler you chose to protect, instead of on every asset request that happened to match a broad pattern. Budget for the hop, cache what you safely can, and measure it in your own app rather than trusting anyone's published number, including ours.

What about rate limiting? Same seam, same reasoning. You cannot rate limit by API key in a layer that runs before you know the key, which is exactly why nearly every limiter ships keyed on IP by default. We measured what that costs across four frameworks in rate limit by API key, not IP.

Key takeaways

  • Check your matcher before you check anything else. The negative-matching pattern in the Next.js docs excludes api, and a key check under that matcher never runs on your API routes. Measured on 16.2.12: one log line for the page, zero for /api/ping.
  • Put the decision in the route handler. proxy.ts is a filter that a config edit, a Server Function, or a framework CVE can remove without telling you. The handler is the only layer that cannot run without your check running.
  • Migrate off middleware.ts on your own schedule, not in a panic. It still works on Next.js 16 with a warning, and npx @next/codemod@canary middleware-to-proxy . does the rename. Treat the upgrade as a prompt to audit placement, which is the part that actually matters.
  • A key check that returns true or false is not finished. You need identity, immediate revocation, per-consumer limits, and distinct 401 / 402 / 403 / 429 responses. Everything else is a support ticket waiting to happen.

If you are at the point where process.env.API_KEY has stopped scaling, the Node SDK wraps a route handler in about six lines, and ReqKey's key management handles the consumer, quota and revocation half. The free tier is enough to point a real integration at it and watch the credit balance move, which is the only way to find out whether your placement is right.

Share this post

Put your API keys on autopilot.

Keys, credits, plans, and real-time traffic analytics — free for your first 100k requests a month.