AI Added a Route That Bypasses Auth Middleware

AI registered a new endpoint outside the authenticated route group, exposing internal data without checks. Detect the gap and enforce auth as a default.

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 failure mode is a P0 security incident and it happens because auth boundaries are usually structural (one router uses middleware X, another does not) rather than per-route declarations the AI can pattern-match.

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.

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 middleware at the app root

For Express:

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

Or for Next.js, in middleware.ts:

export function middleware(req: NextRequest) {
  if (req.nextUrl.pathname.startsWith("/api/public")) return NextResponse.next();
  const token = req.cookies.get("session");
  if (!token) return new NextResponse("auth required", { status: 401 });
  return NextResponse.next();
}

Now even an AI-mounted route outside the protected group hits the deny default.

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.

Verify

  • Hitting the new endpoint without auth returns 401, both locally and in staging.
  • 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.
  • 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 framework level so an un-routed AI suggestion cannot leak.
  • Keep one canonical router per auth tier (public, authed, admin). New routes attach to existing routers, not new ones.
  • Move auth into middleware, never inline conditionals. AI rewrites handler bodies but rarely refactors middleware chains.
  • 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.
  • 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.

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