DenyFirst blog
Your login screen protects the UI, not the data
Here is a pattern we find over and over in Next.js apps shipped from AI builders. The app has a real login screen. It looks polished. You can't see the dashboard without signing in. The team reasonably concludes the app is "behind auth."
Then you send a single request:
curl https://your-app.example.com/api/orders
…and it returns JSON. Real, private-looking JSON. No cookie, no token, no session. The login screen guarded the *page*. The /api route behind it guards nothing.
The gate is on the wrong layer
In an App Router app, the login screen is a client concern: it decides which React routes render. But your data lives behind API routes and server actions, and those are reachable directly — they don't care whether a human ever loaded your login page. If the auth check lives in the UI (or in a middleware matcher that doesn't actually cover the data routes), the browser is protected and the database is not.
We've scanned dashboards where the split was stark: most of the app's data routes returned data to an unauthenticated request, while only a handful correctly required auth. The app *had* a working auth boundary — it simply wasn't applied to most of its routes. The account/identity endpoint answered 401; meanwhile a broad set of routes serving private-looking data answered 200 to anyone. Same app, same deploy, opposite behavior — because auth was bolted on per-route, and most routes were missed.
That's the tell of this bug: it's not that the team doesn't know how to do auth. They clearly do — some routes are locked. It's that auth-per-route fails open. Ship a new endpoint, forget the check, and it's public by default. Nothing breaks. Nothing warns you.
Why it hides
The same reason as always: the app works for you. You're logged in, the UI calls its endpoints with your session attached, and everything is scoped and correct. The vulnerability only appears when someone calls the endpoint *without* the session — which no part of your normal testing ever does, but which the entire internet can do trivially.
Middleware bypasses make it worse. A path-pattern matcher that's supposed to gate /api/* can be slipped with a trailing slash, a case change, a locale prefix, or an RSC transport variant — the route still resolves and serves data while the check never runs. If your only enforcement is a matcher, a normalization quirk is an auth bypass.
Enforce auth where the data is
The durable fix is to stop gating per route and enforce authentication centrally, so a new endpoint can't ship unguarded:
// middleware.ts
export const config = { matcher: ["/api/:path*"] };
Protect everything by default, then allowlist the handful of routes that are public by design. And treat middleware as defense in depth, not the whole defense — verify the session inside each handler too:
const session = await auth();
if (!session) return new Response("Unauthorized", { status: 401 });
Then scope every query to the caller (where userId = session.userId) so that "logged in" never means "can read everyone's rows."
How to check
Log out. Open your network tab, find the /api/... calls your dashboard makes, and replay one without the session cookie. If it returns data, that data is public. Repeat for the endpoints you *didn't* think to protect — those are the ones that ship open.
DenyFirst enumerates your API routes and tries each one the way an anonymous visitor would — read-only, no exploitation — then shows you exactly which ones answer without auth and hands you the fix. See if we can get in →
← All posts