Your middleware.ts isn't your auth layer. Seven specific checks for Next.js route handlers, Server Actions, and Stripe webhooks before you ship.

Twelve minutes into your Next.js app. middleware.ts is checking the session, redirecting unauthenticated users to /login, and you feel covered. Then I open a terminal and curl your /api/admin/users with no cookie attached. The response is 200 OK. Every user row, every email, every Stripe customer ID, sitting in JSON. The middleware did its job for /dashboard. Your route handler did nothing. You assumed middleware was the bouncer for the whole building. It is actually the doorman at the front door while the kitchen has a wide open service entrance. This is the most common Next.js shipping failure I see. App Router apps where the founder thinks one file is gating everything, when half the routes are not even on the matcher. The other half are server endpoints that never re-check the session because the framework let them work without one. Here are the seven checks that catch it before a customer support email becomes a breach disclosure.

02Middleware is not your auth layer

Open middleware.ts. Look at the matcher config at the bottom. If it does not explicitly list every /api route you care about, those routes run with zero auth check. Default Next.js setups skip /api in the matcher because of Edge runtime constraints, and most AI-generated code leaves it that way. Now look at the route handlers themselves. Every file under app/api/**/route.ts is a public HTTP endpoint until you prove otherwise. Inside the handler, you should be re-calling your session helper. With NextAuth that is await auth(). With Supabase it is createServerClient and getUser(). If the result is null and the route is not explicitly public, return 401 and stop. Two checks you can run right now: 1. grep -r 'export async function' app/api and list every route. For each, open the file and confirm line one of the handler reads the session. 2. Hit your three most sensitive routes with curl, no cookie. /api/admin/anything, /api/users/[id], /api/billing. If any of them returns data instead of 401, that is the one shipping tonight. Server Components reading from the database have the same problem. cookies() and auth() must be called inside the component, not assumed from a parent layout that may or may not have run.

03Server Actions look safe and aren't

Server Actions feel like calling a local function. They are not. Every "use server" function is a POST endpoint that any browser can hit with a crafted form body. The function signature is your API contract. The classic shipping bug: a Server Action like deleteOrganization(orgId: string) that takes the orgId from a hidden form field. The founder reasons that the UI only ever passes the org the user belongs to. The attacker opens DevTools, edits the value, and deletes someone else's organization. Same pattern with updateUserRole(userId, role) and getInvoices(customerId). Two checks: 1. Every Server Action that accepts an ID must derive the user_id from the session inside the function, then verify that user has access to the target organization_id or customer_id. Never trust IDs from form data. 2. If you are on Supabase, your RLS policies are the safety net. But Server Actions using the service role key bypass RLS entirely. Search your codebase for SUPABASE_SERVICE_ROLE_KEY usage. Every hit is a place where the database stops protecting you and your code is the only thing left. Bonus check: revalidatePath in a Server Action runs server-side. If the action runs without auth, your cache poisoning surface just got wider too.

04Webhooks need signatures, not sessions

Last thread. Your /api/webhooks/stripe route handler. This one cannot use session auth because Stripe is calling it, not a logged-in user. The only thing standing between an attacker and a fake checkout.session.completed event is the signature check. The bug ships when the founder copies a Stripe quickstart, parses the body as JSON, and processes the event. No stripe.webhooks.constructEvent call. Or it is there, but the raw body was already consumed by Next.js before reaching it. In App Router you need req.text() for the raw payload, not req.json(). Get this wrong and anyone who knows your webhook URL can upgrade themselves to your top tier by posting a forged event. Three checks before you ship: 1. constructEvent runs first. If it throws, return 400. No try/catch that swallows the error and continues. 2. Store every event.id you process. Stripe retries on 5xx. Without idempotency, a replay credits the user twice. 3. STRIPE_WEBHOOK_SECRET in your Vercel preview environment is almost certainly wrong or missing. If your handler fails open when the secret is undefined, previews become free upgrade endpoints. Guardian runs these probes against your real endpoints. We curl the routes your middleware does not cover, post crafted bodies to your Server Actions, and forge unsigned webhook events. If anything responds with data instead of 401 or 400, you get a one line report with the route, the payload, and the fix.

The Guardian Team
Security for apps built with AI.

Find the /api/admin route your middleware doesn't cover

Guardian hits every Next.js route handler, Server Action, and webhook endpoint the way an attacker does and tells you exactly which ones ship without auth or signature checks.

Scan my app free
More articles