All posts
VibeZero Team10 min read

The admin page nobody linked to is already public

Forced browsing is requesting a URL nobody linked you to. Why the admin route in your bundle is already public, and why middleware is the wrong place to guard.

  • security
  • vibe coding
  • engineering

Forced browsing is asking for a URL the application never offered you. No id is tampered with, no payload is sent, no session is stolen. You type /admin/users, or /api/internal/export, or /reports/all-tenants, and the server answers, because the only thing that was ever keeping you out of that route was the fact that the navigation did not render a link to it.

MITRE files it as CWE-425, Direct Request (‘Forced Browsing’), and defines it as a web application that “does not adequately enforce appropriate authorization on all restricted URLs, scripts, or files.” The word “all” is where the whole class lives. Most apps enforce authorization on the URLs the developer was thinking about.

It is not IDOR, and the URL was never a secret

IDOR is a known route with an id that does not belong to you. Multi-tenant leakage is a known route with a missing boundary predicate. Forced browsing is the route itself: the handler may be perfectly scoped once you reach it, and reaching it is the vulnerability, because it was supposed to be reachable only by an admin.

All three are broken access control, and forced browsing is the one people are most confident they do not have, because the reasoning feels solid: nobody knows that URL exists. That reasoning has one flaw. In an app whose frontend is JavaScript, you shipped the list of URLs to every visitor before they signed up.

Your route table is a download

A single-page app has to know its own routes to render any of them, so the router’s route table is in the bundle. Every path, including the ones whose nav entry is behind {user.isAdmin && ...}. The conditional hides the link. It cannot hide the string, because the string is what the router matches against when the browser navigates.

Four things publish the map, in rough order of how completely they give it away.

Source maps. This is the total case. A .map file with sourcesContent does not merely list the routes, it hands over the original files, the directory tree, the comments, and the logic of the checks themselves. It is also easy to ship by accident, because it is a build flag rather than a decision. Our runtime scan engine reports it as source-map-exposed, a HIGH, and it is deliberately two checks rather than one: source-maps-referenced (LOW) fires when a bundle carries a sourceMappingURL comment, and the HIGH only fires when the engine actually follows that comment, gets a 200, and finds sourcesContent in the body. The distinction matters because a reference to a map that is not served is untidy, and a map that downloads is your source code on a public URL.

The bundle itself, even minified. Route strings are string literals, and minifiers do not rename them, because renaming them would break routing. This is the same fact that puts a Supabase key in a Lovable app’s bundle where anyone can read it from DevTools, applied to paths instead of credentials. Our free exposed API key scanner fetches the same-origin scripts your page loads and reads them for credentials; anyone reading them for "/admin" is doing the same fetch with a different regex.

sitemap.xml, when it is generated from the route table rather than from a curated list of public pages. Generating it from the router is the obvious implementation and it publishes everything the router knows.

robots.txt, which is the one that surprises people, because writing Disallow: /admin-panel feels like locking a door. It is a public file that names the paths you consider sensitive. Google’s own documentation is blunt about the limits: robots.txt “is not a mechanism for keeping a web page out of Google,” a disallowed page can still be indexed if another site links to it, and the instructions “cannot enforce crawler behavior to your site; it’s up to the crawler to obey them.” An attacker is a crawler that does not obey them, reading a file you wrote to tell it where to look.

And under all four sits convention. Anyone who knows what you built with knows where your routes live before reading anything, which is why the tech stack checker exists as a security tool: fingerprinting the framework is the first step of the attack, so it is worth knowing what your site announces.

The gate in front of the door: CVE-2025-29927

The most instructive recent example of this class is not a missing check. It is a check that existed, ran, and was skipped by a header.

Next.js middleware (the file is middleware.ts, deprecated and renamed proxy.ts in v16.0.0) is the natural place to put an auth guard, and a great deal of generated code puts it there:

// middleware.ts
// Vulnerable in the versions below, and structurally fragile in all of them:
// this file is the only thing standing in front of /admin.
export function middleware(request: NextRequest) {
  const session = request.cookies.get("session");
  if (!session) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
}

export const config = { matcher: "/admin/:path*" };

CVE-2025-29927 scored 9.1 and its description is one sentence: “It is possible to bypass authorization checks within a Next.js application, if the authorization check occurs in middleware.” The mechanism was the internal x-middleware-subrequest header, which Next.js used to stop middleware recursing into itself. Send it from outside and middleware does not run. No authentication, no privileges, no user interaction. The advisory’s mitigation for anyone who could not upgrade was to strip that header at the edge before it reached the app. Every release from 11.1.4 up to the four patched versions was affected: 12.3.5, 13.5.9, 14.2.25 and 15.2.3.

The CVE is fixed, and if you are on a v0 or Next.js codebase the version check plus the Server Action rules are laid out in how to secure a v0 app. The lesson outlives the patch, because it is about where the check was, not about the header. Middleware guards a request on its way to a route. The route is a separate thing that the request also arrives at, and any path around the middleware, a bug, a rewrite, a cached response, a direct call to a Server Action, is a path to a handler with no check in it.

So put the check where the data is:

// app/admin/page.tsx
// The check travels with the thing it protects. There is no request path that
// reaches this component without running it.
export default async function AdminPage() {
  const user = await getSessionUser();
  if (!user?.isAdmin) notFound();

  return <AdminDashboard />;
}

notFound() rather than a redirect, for the same reason the IDOR post returns 404 instead of 403: a redirect to /login confirms that /admin exists and is worth coming back to with better credentials.

A matcher is a list of paths you remembered

Look at the matcher in that config again. It is an allowlist of routes to protect, which means the protection covers exactly the paths someone thought of while writing it. Forced browsing is the systematic search for the path they did not think of, and in a codebase where routes are added by prompt, the set of paths grows faster than the set anyone is thinking about.

Next.js documents this failure against its own feature. Server Functions are not separate routes, they are POSTs to the route where they are used, so a matcher that excludes a path also skips the Server Function calls on it, and “a matcher change or a refactor that moves a Server Function to a different route can silently remove Proxy coverage.” The word doing the work there is “silently.” Nothing breaks, no test fails, and a route that was covered on Friday is not covered on Monday because someone moved a file.

The fix is structural: make guarded the default state of a route rather than a property attached to it afterwards.

Our own control plane composes it that way. Every feature module lives inside one private subtree behind a single auth middleware, so a route added tomorrow is authenticated because of where it is mounted, not because someone remembered to add it to a list. Making something public is an explicit edit to a short, reviewed list of exceptions. The same shape carries the demo workspace’s read-only rule: it is a layer over the whole subtree that refuses every mutating method, so a write route added later is read-only for the demo without anyone touching it.

The difference between that and a matcher is worth stating plainly. With a matcher, forgetting produces an unprotected route. With composition, forgetting produces a route that does not exist. Only one of those failure modes is safe, and it is the one where the mistake is loud.

In an Express app, which is what Replit’s agent tends to generate, the same idea is a mount point rather than a list of paths:

// Not this: every new admin route needs to be remembered here.
app.use("/admin/users", requireAdmin);
app.use("/admin/billing", requireAdmin);

// This: the router cannot be reached except through the guard, so a route
// added to adminRoutes later inherits it.
const adminRoutes = express.Router();
adminRoutes.get("/users", listUsers);
adminRoutes.get("/billing", billingReport);

app.use("/admin", requireAuth, requireAdmin, adminRoutes);

Test it with your own route list, not a wordlist

Attackers brute-force paths from wordlists because they have to guess. You do not have to guess. You have the filesystem, which is the complete and authoritative list of everything your app will answer, including the routes you forgot you made.

For a Next.js App Router project, the route table is a find:

# Every page and API route the app defines, straight from the source.
# The second sed drops route groups, which are (parentheses) in the
# filesystem and nothing at all in the URL.
find app \( -name "page.tsx" -o -name "route.ts" \) \
  | sed 's|^app||; s|/page\.tsx$||; s|/route\.ts$||' \
  | sed 's|/([^/]*)||g' \
  | sed 's|^$|/|' \
  | sort -u > routes.txt

Dynamic segments come out as /posts/[id], so substitute a real id before requesting those. Then ask for every path with no credentials at all, and read the status codes rather than the pages:

while read -r path; do
  code=$(curl -s -o /dev/null -w '%{http_code}' "https://your-app.example$path")
  echo "$code $path"
done < routes.txt

Anything under /admin, /internal, /api/cron, /api/webhooks or /debug that answers 200 to that loop is a finding, and it is a finding even if the page renders an empty state, because an empty state is a client-side decision made after the server already sent the data.

Then repeat the loop with an ordinary, non-privileged user’s session, which is the run that finds the real problems. Anonymous access tends to be blocked by something. Authenticated-but-not-authorized is the gap, and it is the exact shape of the two most common variants: a support tool that checks for a session and not for a role, and an internal API route that was only ever called by a page which itself checks the role.

Every instance of this class comes from the same substitution: a route was made hard to find, and hard to find was accepted as a stand-in for not allowed. The substitution is invisible while it holds, because an app whose admin panel is merely unlinked behaves identically to an app whose admin panel is properly guarded, for every user who uses the navigation.

AI-generated apps concentrate this failure for a reason that has nothing to do with code quality. A generator builds the screen it was asked for and wires it into the router, and the request “add an admin dashboard” does not contain the sentence “and refuse it to everyone else.” The route is created, the nav link is conditional because that is what looks right, and the check that would make the condition meaningful was never part of the description.

So the question to ask about your own app is not whether the admin link is hidden. It is what happens when someone types the URL, and the only way to know is to type it.

ShareXLinkedIn