AI Added a Route That Bypasses Auth Middleware

AI registered an endpoint outside the authenticated route group, leaking internal data. The fastest fix: move the route under the authed router and enforce auth at the data layer, not in middleware.

You asked Cursor to add an endpoint at /api/admin/export-users. It generated a handler, wired it into your Express app at the top level, returned 200 with the full user list, and the new code worked perfectly in dev — because dev has no auth. In staging, someone hit the URL without logging in and got every user’s email. The route was registered outside your requireAuth middleware boundary. The AI did not see the boundary; it saw “add a route” and added it where routes typically live. This is a P0 security incident, and it happens because auth boundaries are usually structural (one router mounts middleware X, another does not) rather than per-route declarations the AI can pattern-match.

Fastest fix: find the new route’s registration line, move it under the router that already mounts requireAuth (do not add auth inside the handler body), and add an integration test that hits the route with no credentials and asserts a 401. Then audit the rest of this PR’s new routes the same way. If real users could have reached it, treat the leak as an incident: review access logs and rotate anything exposed. The rest of this guide explains how to find the gap, close it for every framework, and stop the AI from doing it again.

Which bucket are you in

Run one check, then jump to the matching cause below.

What you seeMost likely causeJump to
New route uses app.get(...) / app.post(...) on the root appRegistered on root, not the authed routerCause 1
New route file sits outside your protected folder conventionPlaced outside the auto-loaded authed directoryCause 2
Diff deletes a requireAuth import or app.use(requireAuth, ...) lineMiddleware removed to silence a dev 401Cause 3
Handler reads req.user but no upstream middleware sets itAuth load step skipped; req.user is undefinedCause 4
New file lives outside app/(authed)/... in Next.js / Remix / AstroRoute-group convention not followedCause 5
Auth lives only in middleware.ts (Next.js)Middleware is not a security boundary (see CVE-2025-29927)Cause 5
OPTIONS / CORS handler returns a body before auth runsPreflight short-circuit leaks dataCause 6
Webhook uses a secret header compared with ==Naive or skippable secret checkCause 7

Common causes

Ordered by how often each surfaces in real audits.

1. AI registered the route on the root app, not the auth-guarded router

Your Express setup is:

app.use("/api/public", publicRouter);
app.use("/api", requireAuth, authedRouter);
app.use(adminRouter); // <- AI added the new route here

The new admin route sits on the bare app, so requireAuth never runs.

How to spot it: New route file imports app (or the root router) and calls app.get(...) instead of using the existing authed router.

2. AI placed the route file outside the protected directory convention

Your project’s pattern is: anything in src/routes/authed/ gets requireAuth; the AI created src/routes/export-users.ts next door, outside that folder. The auto-loader does not pick it up; the AI then manually wires it where it sees a similar import — usually the top-level app.

How to spot it: New route file lives in an unusual directory; its import path differs from peer routes.

3. AI removed an existing middleware to “fix” a 401 in dev

The endpoint kept returning 401 in local testing. The AI’s diagnosis was “remove the middleware that is blocking the test request” instead of “log in the test request”. The route now requires no auth in any environment.

How to spot it: A diff that deletes a requireAuth import or a app.use(requireAuth, ...) line in route registration code.

4. AI guarded with if (req.user?.isAdmin) but skipped the auth load step

The endpoint checks req.user.isAdmin but req.user is never populated because the upstream auth middleware that sets req.user from the JWT was never wired up. Without auth, req.user is undefined, the check fails closed in the optimistic path but the AI returned different defaults in another branch.

How to spot it: Code references req.user but there is no req.user = ... anywhere upstream of this route’s middleware chain.

5. AI added a Next.js / Astro / Remix route file without checking the route-group auth convention

In framework-route conventions, app/(authed)/admin/users/page.tsx is auth-protected by the layout; app/admin/users/page.tsx is not. AI created the second one.

How to spot it: New file lives outside the (authed) (or equivalent) group; corresponding layout.tsx does not enforce auth.

There is a sharper trap in Next.js: AI often puts the only auth check in middleware.ts. As of June 2026, the official Next.js guidance is explicit that middleware is not a security boundary — it runs at the edge for routing and response shaping, not as a last line of defense (see the Next.js Data Security guide). This is not theoretical: CVE-2025-29927 (CVSS 9.1) let attackers skip middleware entirely by sending an x-middleware-subrequest header, bypassing every auth check that lived only in middleware. The fix shipped in Next.js 15.2.3 (and 14.2.25, 13.5.9, 12.3.5), but the architectural lesson stands: real auth must run inside your Route Handlers, Server Actions, and a server-only Data Access Layer.

How to spot it: The only auth check for a protected page or route.ts is in middleware.ts; the Route Handler or Server Action itself does no auth() check. Also confirm your Next.js version is at or above the patched release — run npm ls next.

6. AI added a OPTIONS or CORS preflight handler that responded with data

Some AI suggestions handle CORS by responding to OPTIONS with an early return. If the early return includes data (rare but happens), the response leaks before any auth checks.

How to spot it: OPTIONS handler returns 200 with a body; auth middleware runs only for GET/POST.

7. AI added a webhook or internal endpoint with a “shared secret” check that is misimplemented

Webhook handlers sometimes use a secret header instead of session auth. AI implementations often: compare the secret with == instead of crypto.timingSafeEqual, accept an empty secret as valid, or skip the check when the header is absent (if (header) { check } else { allow }).

How to spot it: New route uses a secret header; comparison is naive or has a missing-header fallthrough.

Before you start

  • Inventory all public routes in your app: grep -r "app\.\(get\|post\|put\|delete\|patch\)" src/ plus equivalents for your framework.
  • Map each route to its auth middleware. Routes with no middleware in the chain are suspect.
  • If the leak has already happened, treat it as a security incident: rotate any leaked secrets, audit logs for unauthorized access, notify per your IR process.

Information to collect

  • The exact file path of the AI-added route handler.
  • The route registration code — how it gets wired into the app.
  • Your project’s canonical pattern for authed vs public routes.
  • The middleware chain that should have applied (requireAuth, requireAdmin, etc.).
  • Recent commits where new routes were added; check each one.
  • Access logs for the unprotected endpoint since it was deployed.

Step-by-step fix

Ordered: close the hole now, then prevent recurrence.

Step 1: Disable the route immediately if it leaks data

Comment out the route registration or return 503 from the handler while you investigate:

// app.get("/api/admin/export-users", exportUsersHandler);  // DISABLED 2026-05-24 — missing auth

Or set a temporary feature flag that defaults to off.

Step 2: Move the route inside the authed router

Refactor:

// Wrong:
app.get("/api/admin/export-users", exportUsersHandler);

// Right:
adminRouter.get("/export-users", exportUsersHandler);
app.use("/api/admin", requireAuth, requireAdmin, adminRouter);

The middleware chain runs by virtue of where the route is mounted. The handler stays trivial.

Step 3: Add a deny-by-default check at the right layer

For Express, a root-level guard catches anything mounted outside the public allowlist. Register it before your route mounts so it runs first in the chain:

app.use((req, res, next) => {
  if (!req.user && !req.path.startsWith("/api/public")) {
    return res.status(401).json({ error: "auth required" });
  }
  next();
});

For Next.js, do not make middleware.ts your only gate. Middleware is fine for a cheap redirect to a login page, but as of June 2026 the official guidance is that middleware is not a security boundary (a missing-credential request can still reach the route — that is precisely what CVE-2025-29927 exploited). Put the real check at the data layer. Create a server-only Data Access Layer (DAL) that every protected Route Handler, Server Action, and Server Component calls:

// data/auth.ts
import "server-only";
import { cookies } from "next/headers";

export async function requireUser() {
  const token = cookies().get("session")?.value;
  const user = token ? await verifySession(token) : null;
  if (!user) throw new Error("auth required");
  return user;
}
// app/api/admin/export-users/route.ts
import { requireUser } from "@/data/auth";

export async function GET() {
  const user = await requireUser();   // runs even if middleware was skipped
  if (!user.isAdmin) return new Response("forbidden", { status: 403 });
  // ... return data
}

Because the check runs inside the handler, an AI-mounted route outside the protected group — and even a request carrying a spoofed x-middleware-subrequest header — still fails closed. A page-level check does not extend to the Server Actions defined within it, so re-verify inside each action too.

Step 4: Replace req.user.isAdmin checks with a typed helper

// In src/lib/auth.ts
export function requireAdmin(req: Request, res: Response, next: NextFunction) {
  if (!req.user) return res.status(401).json({ error: "auth required" });
  if (!req.user.isAdmin) return res.status(403).json({ error: "admin required" });
  next();
}

Then the route file uses it:

adminRouter.get("/export-users", requireAdmin, exportUsersHandler);

The handler itself does no auth logic, which is harder for AI to subtly break.

Step 5: Add a security-focused test for every protected route

describe("admin endpoints require auth", () => {
  const adminRoutes = ["/api/admin/export-users", "/api/admin/users", "/api/admin/billing"];
  for (const route of adminRoutes) {
    test(`${route} returns 401 without auth`, async () => {
      const res = await request(app).get(route);
      expect(res.status).toBe(401);
    });
  }
});

When AI adds a new admin route, add it to the list. The test fails the second the route bypasses auth.

Step 6: Add a static check for unprotected routes

A simple grep-based test or a custom AST check:

# In a CI script
git diff --name-only main | grep "src/routes/" | while read f; do
  if ! grep -q "requireAuth\|publicRoute" "$f"; then
    echo "ERROR: $f does not declare auth posture"
    exit 1
  fi
done

Force every route file to declare its auth posture explicitly. Files that say nothing are blocked at PR time.

Step 7: Teach the AI via rules file

In .cursorrules / CLAUDE.md:

## Auth posture (required for any new route)

- All routes default to authenticated. Never register a route on the root app
  unless it is for /api/public/* or /api/webhooks/* (which use signature verification).
- Use the existing `adminRouter`, `authedRouter`, or `publicRouter` — do NOT
  create a new top-level router.
- Auth checks live in middleware, never in handler bodies.
- When adding a new admin route: file goes in src/routes/admin/, mounted under
  adminRouter, which is mounted with requireAuth + requireAdmin.
- Webhook endpoints use src/lib/webhook-verify.ts — never roll your own
  signature comparison.

Before adding a route, restate the auth posture in your plan.

How to confirm it’s fixed

  • Hitting the new endpoint without auth returns 401, both locally and in staging. Confirm from the command line, not just the browser: curl -i https://staging.example.com/api/admin/export-users should show HTTP/1.1 401.
  • The auth test suite includes the new route and is passing.
  • A grep for the route handler shows it is mounted under the authed router only, not the root app.
  • On Next.js, the auth check survives a spoofed middleware header: curl -i -H "x-middleware-subrequest: middleware" https://staging.example.com/api/admin/export-users still returns 401/403. Confirm your version is patched with npm ls next (at or above 15.2.3 on 15.x).
  • Access logs from before the fix have been reviewed; any unauthorized hits have been investigated and reported per your IR policy.
  • Static check / lint rule blocks future routes that do not declare auth posture.

Long-term prevention

  • Adopt deny-by-default at the layer that actually enforces it: a server-only Data Access Layer (DAL) for Next.js, or a root guard plus per-tier routers for Express. Do not treat edge middleware as the boundary.
  • Keep one canonical router per auth tier (public, authed, admin). New routes attach to existing routers, not new ones.
  • Centralize auth in reusable helpers/middleware, never inline conditionals in handler bodies. AI rewrites handler bodies but rarely refactors the helper chain.
  • Every new route file must include a // auth: <public|authed|admin> comment that lint enforces.
  • Run a quarterly route audit: grep -r "router\.\(get\|post\)" src/ and verify every entry maps to a known auth tier.
  • Keep framework dependencies patched — middleware-bypass classes like CVE-2025-29927 are fixed by upgrades, so let Dependabot/Renovate raise the PR and merge it promptly.
  • Pair AI-generated route changes with a required security test in the same PR. No test, no merge.

Common pitfalls

  • Trusting the AI’s “I added auth” claim without inspecting the route registration site. The check might be in the handler body, which is the wrong place.
  • Letting the AI invent a new top-level app.use(...) instead of using the existing authed router.
  • Treating dev’s permissive auth (“everyone is admin in dev”) as a fixture the AI can lean on. Tests must run against staging-like auth.
  • Reviewing the handler diff but skipping the route-registration diff. The bug is almost always in registration, not in the handler.
  • Allowing the AI to define requireAuth inline in a single route file. Auth helpers belong in src/lib/auth.ts (or equivalent) and are imported everywhere.
  • Forgetting to also protect HEAD, OPTIONS, and other less-tested verbs.

For related issues see AI removed working logic, AI tests pass but feature broken, and AI rollback changes.

FAQ

Q: The AI’s handler checks req.user.isAdmin. Is that enough?

No. If the auth middleware that populates req.user never ran, req.user is undefined and your check depends on which branch handles undefined. Auth must run before the handler — in middleware, not in the handler body.

Q: We use a session cookie that browsers send automatically. Surely the AI’s route is fine?

Only if the middleware that reads the cookie and verifies the session ran for that route. Route mount location decides which middleware runs. AI gets this wrong silently.

Q: How do I audit historical AI commits for this bug class?

git log --diff-filter=A --name-only src/routes/ to list every added route file. For each, verify the registration site is under the authed router. A 30-minute audit catches the worst cases.

Q: Should I block the AI from touching auth code entirely?

That is too restrictive — AI is useful for handler bodies and tests. Block it from modifying middleware chains and route registration files without explicit approval. Pin those files in .cursorignore or require a maintainer review label.

Q: The AI built a webhook endpoint with a secret-header check. What do I look for?

Three classic bugs. First, the secret is compared with == or ===, which leaks timing information; use crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)) instead, and guard against unequal-length buffers (it throws). Second, the body was parsed by express.json() before verification, so the bytes you hash no longer match what the provider signed — verify against the raw buffer (express.raw({ type: "application/json" })). Third, a missing header falls through to “allow”; a missing or empty signature must fail closed. Add a timestamp-window check to block replays.

Q: The route is behind auth now, but can any logged-in user still read another user’s data?

That is authorization, not authentication, and AI misses it constantly. A handler that does db.post.findUnique({ where: { id } }) without checking post.authorId === user.id is an Insecure Direct Object Reference (IDOR). After confirming auth runs, verify each handler also checks that the caller owns or may access the specific resource. See the OWASP IDOR prevention cheat sheet.

Tags: #Troubleshooting #AI coding #Security #auth #middleware