GuardVibe
Blog
· 12 min read

Auth Coverage: Finding the Route Your AI Forgot to Protect

Your proxy guards /dashboard, not the /api route your agent added. How to inventory every Next.js entry point and find the ones that never check the caller.

You asked your agent for a CSV export of invoices. It wrote a clean route handler, wired up a button on the dashboard, and the feature worked on the first try. The dashboard is behind login, so the export must be too.

It isn't. The dashboard is protected because proxy.ts matches /dashboard/:path*. The new handler lives at /api/export, which the matcher never mentions. Anyone who can guess the URL can download every invoice in the database with one curl.

Nothing in that diff looks wrong. There is no injected string, no leaked key, no suspicious import. The bug is an absence, and absences don't show up in code review. They only show up when you stop reading files one at a time and ask a different question: of every entry point this app exposes, which ones check who is calling?

Why agents leave one route open

An agent writing a route handler optimizes for the task in the prompt: return the invoices as CSV. Authentication is a property of the whole application, not of the task, and the agent usually sees only a slice of the application when it writes the file.

A few things push it toward the unguarded version:

  • The prompt didn't say. "Add a CSV export" says nothing about who may export. The model fills gaps with the most common shape of a route handler it has seen, and the shortest correct-looking handler has no auth call.
  • It trusts the gate it can see. If proxy.ts is in context and contains clerkMiddleware or a session check, the reasonable inference is "the app handles auth centrally." That inference is only true for the paths the matcher covers, and agents rarely re-derive the matcher against the new URL.
  • It copies its neighbours. If the existing handlers in app/api/ rely on the proxy, the new one will too. One unguarded pattern becomes the template for the next ten.
  • Refactors move code out from under the gate. Moving a Server Action to a shared actions.ts, renaming a route group, or tightening a matcher to fix a performance problem can all drop coverage without touching a single line of auth code.

None of this is specific to one model. We covered the broader pattern in why AI coding agents keep writing the same vulnerabilities: the model is rewarded for code that works, and an unguarded endpoint works perfectly.

The proxy is not a boundary

Next.js 16 renamed middleware.ts to proxy.ts. The behaviour is the same, and so is the warning in the official authentication guide: proxy checks are "optimistic", and the proxy "should not be your only line of defense in protecting your data." The same guide recommends doing the majority of security checks "as close as possible to your data source."

There are three concrete reasons to take that literally.

Matchers are allowlists of where the proxy runs. A route outside the matcher never reaches your auth logic. The inverse also bites: a createRouteMatcher(['/admin(.*)']) protect list inside a matcher that covers everything still leaves every non-admin path public by design.

Server Actions don't have their own URL. The proxy reference notes that Server Functions "are handled as POST requests to the route where they are used, so a Proxy matcher that excludes a path will also skip Server Function calls on that path." Clerk's migration guide puts it more bluntly: Server Functions are "called by ID, not by path." An action defined under /settings can be invoked while the browser is on /pricing, and the proxy sees /pricing.

The gate itself has been bypassed. CVE-2025-29927 (CVSS 9.1) let a request carrying an x-middleware-subrequest header skip Next.js middleware entirely in 12.x before 12.3.5, 13.x before 13.5.9, 14.x before 14.2.25 and 15.x before 15.2.3. In April 2026, CVE-2026-41248 (CVSS 9.1) showed that crafted requests could get past Clerk's createRouteMatcher in @clerk/nextjs 5.0.0–6.39.1 and 7.0.0–7.2.0 (fixed in 5.7.6, 6.39.2 and 7.2.1). The advisory notes that auth() calls inside handlers still reported the correct session. Apps that checked at the resource were not exposed. Apps that only checked in middleware were.

Clerk has since deprecated createRouteMatcher() for auth and recommends protecting every page, Route Handler and Server Function individually. If you are still gating with it, you have a migration on your list regardless of anything else in this post.

What the exposed code looks like

Here is the setup from the opening, reduced to the parts that matter.

// src/proxy.ts
import { clerkMiddleware } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";

export default clerkMiddleware(async (auth, req) => {
  if (req.nextUrl.pathname.startsWith("/dashboard")) {
    const { isAuthenticated, redirectToSignIn } = await auth();
    if (!isAuthenticated) return redirectToSignIn();
  }
  return NextResponse.next();
});

export const config = { matcher: ["/dashboard/:path*"] };
// src/app/api/export/route.ts
import { db } from "@/lib/db";

export async function GET() {
  const rows = await db.query.invoices.findMany();
  return Response.json(rows);
}

The attacker's whole exploit:

curl https://app.example.com/api/export

They get every invoice row for every customer. No session, no token, no rate limit that cares.

The Server Action version is quieter:

// src/app/(app)/settings/actions.ts
"use server";
import { auth } from "@clerk/nextjs/server";
import { db } from "@/lib/db";

export async function renameProject(projectId: string, name: string) {
  const { userId } = await auth.protect();
  await db.update(projects).set({ name })
    .where(and(eq(projects.id, projectId), eq(projects.ownerId, userId)));
}

export async function deleteProject(projectId: string) {
  await db.delete(projects).where(eq(projects.id, projectId));
}

renameProject is fine. deleteProject sits in the same file, was probably generated in the same session, and checks nothing. The settings page that renders the delete button may well be behind the proxy, but that doesn't matter. The Next.js data security guide is explicit that an exported Server Action "is reachable via a direct POST request, not just through your application's UI." Next.js encrypts action IDs and drops unused actions from the client bundle, and the same guide says that reduces risk but does not replace a check inside the action. Once any page ships the delete button, its action ID is in JavaScript the browser downloads, and a POST with a next-action header naming that ID runs the function with whatever projectId the caller supplies.

The fixed version moves the check into a small server-only helper and calls it at the top of every entry point:

// src/lib/dal.ts
import "server-only";
import { auth } from "@clerk/nextjs/server";

export async function requireUser() {
  const { isAuthenticated, userId } = await auth();
  if (!isAuthenticated || !userId) throw new Error("Unauthorized");
  return userId;
}
// src/app/api/export/route.ts
import { auth } from "@clerk/nextjs/server";
import { db } from "@/lib/db";

export async function GET() {
  const { userId } = await auth.protect(); // 404 for signed-out session requests
  const rows = await db.query.invoices.findMany({
    where: (inv, { eq }) => eq(inv.ownerId, userId),
  });
  return Response.json(rows);
}
// src/app/(app)/settings/actions.ts
export async function deleteProject(projectId: string) {
  const userId = await requireUser();
  await db.delete(projects)
    .where(and(eq(projects.id, projectId), eq(projects.ownerId, userId)));
}

Notice that both fixes do two things. They authenticate, and they scope the query to the caller. An auth check that stops anonymous users but lets any signed-in user delete any project is the next bug down, and an audit that only looks for "is there an auth call" will mark it green.

How to find the unguarded ones

You want an inventory: every page, every Route Handler method, every exported Server Function, and whether its own body calls a guard. Start with two greps from the repo root.

# Every file that declares Server Functions
grep -rlE "^\s*['\"]use server['\"]" --include='*.ts' --include='*.tsx' src

# Route handler files with no recognisable guard call anywhere in them
grep -rLE 'auth(\.protect)?\(|getServerSession\(|verifySession\(' \
  --include='route.ts' --include='route.tsx' src/app

Swap the guard names for whatever your codebase uses. The second command works per file, so a file with one guarded GET and one unguarded POST will look fine. For per-export results, a short script is more honest:

// scripts/auth-inventory.mjs
// List every server entry point and whether its own body calls a guard.
// Usage: node scripts/auth-inventory.mjs src
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";

const root = process.argv[2] ?? ".";
// Adjust to the guard functions your codebase actually uses.
const GUARD = /\b(auth\.protect|auth|getServerSession|verifySession|requireUser|requireAdmin|currentUser)\s*\(/;
const SKIP = new Set(["node_modules", ".next", ".git", "dist"]);

function* walk(dir) {
  for (const name of readdirSync(dir)) {
    if (SKIP.has(name)) continue;
    const p = join(dir, name);
    if (statSync(p).isDirectory()) yield* walk(p);
    else if (/\.(t|j)sx?$/.test(name)) yield p;
  }
}

// Split a file into [exportName, bodyText] chunks, one per export.
function exportsOf(src, namePattern) {
  const re = new RegExp(`export\\s+(?:const\\s+(${namePattern})\\s*=|(?:async\\s+)?function\\s+(${namePattern})\\b)`, "g");
  const hits = [...src.matchAll(re)];
  return hits.map((m, i) => [m[1] ?? m[2], src.slice(m.index, hits[i + 1]?.index ?? src.length)]);
}

const rows = [];
for (const file of walk(root)) {
  const src = readFileSync(file, "utf8");
  const rel = relative(root, file);
  if (/(^|\/)app\/.*\/?route\.(t|j)sx?$/.test(rel)) {
    for (const [name, body] of exportsOf(src, "GET|POST|PUT|PATCH|DELETE")) rows.push(["route", rel, name, body]);
  } else if (/(^|\/)app\/(.*\/)?page\.(t|j)sx?$/.test(rel)) {
    rows.push(["page", rel, "default", src]);
  } else if (/^\s*["']use server["']/.test(src)) {
    for (const [name, body] of exportsOf(src, "\\w+")) rows.push(["action", rel, name, body]);
  }
}

for (const [kind, file, name, body] of rows) {
  console.log(`${GUARD.test(body) ? "guard  " : "NO GUARD"}\t${kind}\t${file}\t${name}`);
}

On the example app above it prints:

NO GUARD	action	app/(app)/settings/actions.ts	deleteProject
NO GUARD	page	app/dashboard/page.tsx	default
NO GUARD	route	app/api/billing/route.ts	POST
NO GUARD	route	app/api/export/route.ts	GET
NO GUARD	route	app/api/webhooks/stripe/route.ts	POST
guard  	action	app/(app)/settings/actions.ts	renameProject
guard  	route	app/api/me/route.ts	GET

The billing route is one the agent wrote as export const POST = async (req) => { ... }, taking an orgId from the request body and changing that organisation's plan. Arrow-function exports like this are easy for tools to miss, which is why the script matches both forms.

Read the NO GUARD lines one by one and sort each into three buckets:

  1. Public on purpose. Marketing pages, health checks, the sign-in page.
  2. Authenticated by something other than a session. The Stripe webhook should verify its signature, a cron route should check a shared secret, a public API should check an API key. These need a guard, just not auth().
  3. Actually exposed. Fix these first. In the example that is the export route, the billing route, deleteProject, and the dashboard page, which is only protected by the matcher.

What this kind of check cannot tell you

Be clear about the limits before you trust a green result, whether it comes from this script or from a scanner.

  • A guard call is necessary, not sufficient. const session = await auth() with no check on the result passes the regex. So does an auth check followed by a query that ignores the user ID. Ownership bugs look identical to correct code at this level. We saw the same shape in the fail-open authorization bugs from Unleash, deepstream and 9router, where the check existed and simply didn't stop anything.
  • Indirection hides guards. If a handler calls withAuth(handler) or a helper three files away, a text search will report NO GUARD. Add those names to the regex rather than ignoring the line.
  • Inline "use server" inside a component function declares an action without a file-level directive. The script above doesn't list those. Search for "use server" anywhere, not just at the top of files.
  • Pages Router, tRPC, Hono and GraphQL each have their own entry points. pages/api/* handlers, tRPC procedures and resolvers need the same inventory with different patterns.
  • Servers that aren't your app count too. Dev tools and MCP servers listening on localhost are entry points with the same failure mode, as the DBHub, Cline and Rsdoctor advisories showed.

GuardVibe's npx guardvibe auth-coverage command does a similar pass on App Router pages and Route Handlers, parses the proxy matcher, and reports coverage per route. It also counts matcher and layout coverage as protected, which is useful as a first map but should be read with the section above in mind, and it does not enumerate Server Functions. Compare its route count against your own inventory. If the two disagree, the difference is exactly the list you should read by hand.

How to keep it closed

Ordered by how much each step buys you.

Make the guard the first line of every entry point. One requireUser() or auth.protect() call at the top of each page, Route Handler and Server Function, before any data access. Put the helper in a module that imports server-only so it can never be bundled for the client. Keep the proxy for redirects and user experience, not for security. The Next.js guide also warns against relying on layouts, because they "don't re-render on navigation" and don't stop nested segments or Server Actions from being reached.

Scope queries inside the data layer. If every query function takes the user ID and filters by it, a missing ownership check becomes a type error instead of a runtime leak. This is the difference between "signed in" and "allowed to touch this row."

Lint for it. Clerk ships @clerk/eslint-plugin with a @clerk/next/require-auth-protection rule that flags pages, Route Handlers and Server Functions in folders you mark as protected when they lack a check. The rule is marked experimental and Clerk recommends pinning the version. The protected globs are folder paths, not URLs, so a route group like (app) has to appear in the glob.

Gate CI on the inventory. Keep a reviewed allowlist of intentionally public entry points and fail the build on anything new:

# .auth-public holds reviewed lines like: app/api/webhooks/stripe/route.ts<TAB>POST
if node scripts/auth-inventory.mjs src | grep '^NO GUARD' | cut -f3,4 | grep -vxFf .auth-public; then
  echo "Unreviewed entry points without an auth guard (listed above)"
  exit 1
fi

This turns "someone forgot" into "someone has to add a line to a file that a reviewer will see."

Say it in the prompt. Put the rule where the agent reads it before it writes code: in CLAUDE.md, AGENTS.md or your Cursor rules, name the guard function and state that every Route Handler and Server Action calls it on the first line and scopes queries by the returned user ID. A concrete, checkable rule works better than "write secure code", and the CI gate tells you when it wasn't followed.

The takeaway

The route your agent forgot is not hiding in the diff. It is hiding in the gap between where your proxy runs and where your data is read, and that gap moves every time a file moves. Stop asking "is this file secure?" and start asking "which entry points don't check the caller?" A script, a lint rule and an allowlist answer that question on every commit, which is more often than any human reviewer will.

If you want a starting map, run npx guardvibe auth-coverage against your repo, then diff it against the inventory above.

Get the next one in your feed reader

Follow GuardVibe in your feed reader. No account, no email.