10 min read

Is your Next.js middleware actually protecting your admin routes?

Every AI-generated Next.js app ships with middleware.ts that looks like it gates admin routes. Half of them do not actually run on the routes they think they run on. Here is the 5-minute test, the canonical bugs, and the fixes — written for solo founders who do not want to read the matcher RFC.

You asked Cursor to "add admin auth to /api/admin." Cursor wrote a middleware.ts that looks like it does the right thing. You shipped it. You assumed it works.

Let's check.

The 60-second test

Open a terminal. Replace YOUR_APP with your domain. Replace ADMIN_ROUTE with one of your protected admin routes. Run:

curl -i https://YOUR_APP/api/admin/users

If the response is the protected data instead of a redirect to login or a 401/403, your middleware is not protecting it. Stop reading this and fix it now.

If the response is a redirect or 401/403, you might be protected. Keep reading — you might still have one of the canonical bugs below.

Bug 1 — Matcher does not include /api

The most common bug in AI-generated middleware:

```ts // middleware.ts import { NextResponse } from 'next/server';

export function middleware(req) { // Looks like it checks an auth cookie... const session = req.cookies.get('session'); if (!session) return NextResponse.redirect(new URL('/login', req.url)); }

export const config = { matcher: '/admin/:path*', // <-- matches /admin/foo, NOT /api/admin/foo }; ```

The matcher /admin/:path* matches paths that START with /admin. It does NOT match /api/admin/foo — that path starts with /api. The middleware never runs on /api/admin/* routes.

This is a "half the AI-generated apps" bug. Cursor and Lovable both produce it. The reason: the model's training data on Express middleware is mostly about UI routes (/admin, /dashboard), not API routes (/api/admin).

Fix:

export const config = {
  matcher: ['/admin/:path*', '/api/admin/:path*'],
};

Or, more aggressively, match everything and exclude what you do NOT want to protect:

export const config = {
  matcher: [
    '/((?!_next/|public/|favicon.ico|api/health).*)',
  ],
};

Test it again with curl. If the response is now a redirect or 401, you fixed it.

Bug 2 — Middleware runs but does not check what you think

The matcher is correct. The middleware runs. But the auth check is wrong:

export function middleware(req) {
  const session = req.cookies.get('session');
  if (!session) return NextResponse.redirect(new URL('/login', req.url));
  // Allows any authenticated user — not just admins.
  return NextResponse.next();
}

The bug: any logged-in user, including a regular customer, passes this check. /api/admin/users returns the user list to whoever signs up.

Fix: check the role or claim, not just presence:

```ts import jwt from 'jsonwebtoken';

export function middleware(req) { const session = req.cookies.get('session')?.value; if (!session) return NextResponse.redirect(new URL('/login', req.url));

try { const claims = jwt.verify(session, process.env.JWT_SECRET); if (claims.role !== 'admin') { return new NextResponse(null, { status: 404 }); // 404, not 403 — don't reveal the route exists } } catch (e) { return NextResponse.redirect(new URL('/login', req.url)); } return NextResponse.next(); } ```

Test it: sign in as a regular user, hit /api/admin/users. If it returns the user list, you have the bug. If it returns 404, you fixed it.

Bug 3 — CVE-2025-29927 (the header bypass)

If you are on Next.js < 15.2.3 (or 14.2.25, 13.5.9, 12.3.5), there is an unpatched bypass:

curl -H "x-middleware-subrequest: src/middleware" https://YOUR_APP/api/admin/users

If that returns the protected data, you have CVE-2025-29927. Any unauthenticated attacker can bypass any middleware via that one header. The fix is upgrading Next.js. The workaround is dropping that header at your edge / CDN / Vercel rewrite. Full write-up.

Check your version:

grep '"next":' package.json

If you see 14.2.0, 13.5.5, or anything below the patched versions, upgrade now.

Bug 4 — Middleware checks but the route handler does not

You've got middleware on /api/admin/*. The middleware verifies the user's role. The route handler ALSO does database operations:

// app/api/admin/users/[id]/route.ts
export async function DELETE(req, { params }) {
  // Middleware already checked that the user is admin... right?
  await db.from('users').delete().eq('id', params.id);
  return Response.json({ ok: true });
}

The bug: middleware-only auth means the route handler trusts middleware. If middleware ever stops running (matcher bug, Vercel deployment issue, CVE-2025-29927-style bypass), the route handler is wide open.

Defense in depth fix: check auth in the route handler too. Trust nothing:

```ts import { getAuthenticatedUser } from '@/lib/auth';

export async function DELETE(req, { params }) { const user = await getAuthenticatedUser(req); if (!user || user.role !== 'admin') { return new Response(null, { status: 404 }); } await db.from('users').delete().eq('id', params.id); return Response.json({ ok: true }); } ```

Yes, this is double-checking. Yes, it's slightly slower. The cost is microseconds; the value is that a middleware regression cannot become a data breach.

Bug 5 — Middleware checks but the database does not

Even with route-handler auth, your database might be wide open. If you use Supabase with anon key + RLS, the database itself enforces who can read what. If your route handler uses the service-role key (bypassing RLS), the only authz layer is the route handler. If the route handler has a bug, every row leaks.

The pattern that works:

```ts // app/api/admin/users/[id]/route.ts import { createServerClient } from '@/lib/supabase';

export async function DELETE(req, { params }) { const supabase = createServerClient(req); // Uses anon key + JWT const { data: { user } } = await supabase.auth.getUser();

if (!user || user.app_metadata?.role !== 'admin') { return new Response(null, { status: 404 }); }

// Even if the role check is wrong, RLS would still enforce that // the deleting user has admin permission via the policy: // CREATE POLICY admin_delete ON users FOR DELETE // USING (auth.jwt() ->> 'role' = 'admin'); const { error } = await supabase.from('users').delete().eq('id', params.id); if (error) return new Response(null, { status: 404 });

return Response.json({ ok: true }); } ```

Three layers: middleware, route handler, RLS. Any one of them catching the bug stops the breach.

How to make this stop being your problem

Manually grepping every middleware file is the wrong long-term answer. Two layers of defense actually help:

1. Run [Securie](/signup) on every PR. The broken-auth specialist (Day-1) reads every middleware.ts, every API route, and every database call. It runs cross-user requests in a sandbox to verify the actual authorization holds — not just that the code looks correct. Findings ship only when the sandbox successfully reproduces the bypass; no false positives, no triage.

2. Add the curl tests above to your CI as a smoke test. After every deploy, run curl -i against your protected routes from an unauthenticated context. Fail the build if any of them return data. Three lines in a GitHub Action, catches the matcher-typo bug forever.

Both layers cost almost nothing. The bug class they catch is the most-common bug class in AI-generated apps. 30 minutes of setup once buys you forever.

Related

Related posts