Free tool · Next.js 16 · Auth.js v5
Paste a route.
See who can call it.
AI agents protect the page and forget the API route, the Server Action and the “is this record yours?” check. Paste one file and get each gap with the cause, a before → after fix and a prompt for Cursor or Claude Code.
› 12 checks for Next.js 16 App Router + Auth.js v5. Heuristic, one file at a time: a clean result means none of these patterns matched, not that the file is secure.
- 12 checks
- 5 high severity
- 12 before → after diffs
- checked against Next.js 16.4.3 App Router · Auth.js v5 (next-auth 5.0.0-beta.32)
- updated
01Where auth has to live
Four places to check. Only one is enough on its own.
Every route handler and Server Action is a public HTTP endpoint. Agents tend to put the check where they can see it (the page or the nav) and stop there.
- 01proxy.tsOptimisticRedirects signed-out visitors early. Next.js docs: it should not be your only line of defense, and its matcher decides what it even sees.
- 02layout.tsxNot a guardDoesn't re-run on client navigation and doesn't stop nested pages or Server Actions from running.
- 03page.tsxGuards the pageauth() + redirect() keeps the HTML and RSC payload private. It does nothing for the API routes and actions the page calls.
- 04route + action + queryThe real checkEvery route handler and Server Action calls auth(), and every query includes userId: session.user.id.
02Every check, explained
The 12 gaps agents leave most.
Route handler with no session check
This GET/POST/PUT/PATCH/DELETE handler never calls auth() (or checks req.auth). Route handlers are public HTTP endpoints: anyone with curl can call them, signed in or not, no matter what your UI shows.
usually because
- The agent built the UI behind a login and assumed the API behind it is private too
- proxy.ts was supposed to protect it, but its matcher skips /api
- The handler is wrapped in auth(), but req.auth is never checked
fix
- 1.Call const session = await auth() at the top of every exported method
- 2.Return 401 when !session?.user?.id, before reading params or the body
- 3.Then check that the record belongs to session.user.id (see the ownership check)
added: import { auth } from "@/auth";import { db } from "@/lib/db"; export async function GET() {removed: const projects = await db.project.findMany();added: const session = await auth();added: if (!session?.user?.id) {added: return Response.json({ error: "Unauthorized" }, { status: 401 });added: }added: const projects = await db.project.findMany({added: where: { userId: session.user.id },added: }); return Response.json(projects);}Audit every route handler in src/app/api/**/route.ts. Each exported GET/POST/PUT/PATCH/DELETE must call `const session = await auth()` from @/auth first and `return` a 401 JSON response when `!session?.user?.id`, before reading params or the body. Skip only the Auth.js handlers and webhooks that verify a signature. Don't rely on proxy.ts for this. List every handler you changed.
Server Action with no session check
An exported Server Action never checks the session. Next.js docs: Server Actions are reachable via a direct POST request, not just through your UI, so hiding the button or the page does not protect the action.
usually because
- The form that calls it is on a protected page, so the agent assumed the action is protected
- Auth was checked in the page or layout, not inside the action
- A matcher change or refactor moved the action out of proxy coverage
fix
- 1.Call auth() inside every exported action, as the first line
- 2.Throw or return an error when there is no session.user.id
- 3.Validate the input on the server (e.g. zod) and scope the write to session.user.id
"use server";added: import { auth } from "@/auth";import { db } from "@/lib/db";import { revalidatePath } from "next/cache"; export async function deleteProject(id: string) {removed: await db.project.delete({ where: { id } });added: const session = await auth();added: if (!session?.user?.id) throw new Error("Unauthorized");added: // deleteMany + userId: deletes nothing if the project isn't theirsadded: await db.project.deleteMany({ where: { id, userId: session.user.id } }); revalidatePath("/dashboard");}Find every Server Action (files or functions with "use server"). Treat each one as a public POST endpoint: make `const session = await auth()` the first line, throw when `!session?.user?.id`, validate the arguments on the server, and scope every DB write to `session.user.id` (use `where: { id, userId: session.user.id }` with updateMany/deleteMany, or check ownership first). Don't rely on the page, layout or proxy for this.
Record loaded by id with no ownership check
The query looks a record up by id alone. Being signed in is authentication; being allowed to touch THIS record is authorization. Any signed-in user can change the id in the URL or request and read, edit or delete someone else's data (an IDOR).
usually because
- findUnique({ where: { id } }) is the shortest query that works in the demo
- The id comes from params, the body or a form and is trusted as-is
- The agent checked auth() and stopped there
fix
- 1.Put the owner into the query: findFirst({ where: { id, userId: session.user.id } })
- 2.For writes use updateMany / deleteMany with the same where, and check count
- 3.Return 404 (not 403) when nothing matches, so ids can't be probed
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { const session = await auth(); if (!session?.user?.id) return Response.json({ error: "Unauthorized" }, { status: 401 }); const { id } = await params;removed: const invoice = await db.invoice.findUnique({ where: { id } });added: const invoice = await db.invoice.findFirst({ where: { id, userId: session.user.id } });added: if (!invoice) return Response.json({ error: "Not found" }, { status: 404 }); return Response.json(invoice);}Find every Prisma (or Drizzle) query that selects, updates or deletes a record by id alone (`findUnique({ where: { id } })`, `update({ where: { id } })`, `delete({ where: { id } })`, `eq(table.id, id)`). Scope each one to the signed-in user: `findFirst({ where: { id, userId: session.user.id } })`, `updateMany`/`deleteMany` with the same where and check `count`, and return 404 when nothing matches. For child records, check ownership through the parent.
Trusting userId, role or plan from the request
The code reads who the user is (userId, ownerId, role, isAdmin, plan) from the body, form data or query string. That is attacker-controlled: anyone can send userId of another account or role: "admin".
usually because
- The client already knows the user id, so the agent posts it along
- A role or plan flag was added to the form to make the UI work
- zod validates the shape of userId but not whose it is
fix
- 1.Take the user id only from the session: session.user.id
- 2.Read role and plan from your database on the server, never from the request
- 3.Remove the field from the client payload and the zod schema
export async function POST(req: Request) {removed: const { userId, title } = await req.json();removed: const note = await db.note.create({ data: { userId, title } });added: const session = await auth();added: if (!session?.user?.id) return Response.json({ error: "Unauthorized" }, { status: 401 });added: const { title } = await req.json(); // validate with zodadded: const note = await db.note.create({ data: { userId: session.user.id, title } }); return Response.json(note);}Search the codebase for any userId, ownerId, authorId, role, isAdmin or plan read from req.json(), formData, searchParams or Server Action arguments. Replace them with `session.user.id` from `await auth()`, and read role/plan from the database on the server. Remove those fields from client payloads and zod schemas.
401 response created but not returned
The session check builds a 401 response but doesn't return it, so execution continues and the handler runs for signed-out users anyway.
usually because
- NextResponse.json(...) inside the if without return
- A refactor turned a one-line return into a block
fix
- 1.Write return NextResponse.json(..., { status: 401 })
- 2.redirect() from next/navigation throws by itself and needs no return
const session = await auth();if (!session?.user?.id) {removed: NextResponse.json({ error: "Unauthorized" }, { status: 401 });added: return NextResponse.json({ error: "Unauthorized" }, { status: 401 });}Find every auth guard in route handlers that creates a NextResponse/Response for the unauthorized case without `return`. Add `return` so the handler stops for signed-out users.
Auth only in a client component (UI hiding)
This client component decides access with useSession() or a session prop and hides or redirects in the browser. That's fine for UX, but it protects nothing: the JavaScript, the API routes and the Server Actions it calls are all still reachable directly.
usually because
- The agent used useSession() and router.push("/login") like a SPA
- Pro or admin buttons are hidden instead of the action being blocked
- Next.js docs call return-null-if-unauthorized in a top-level component 'not recommended'
fix
- 1.Keep the UI hiding, but check auth() on the server for the page (redirect) and in every route/action it calls
- 2.Prefer a server page: const session = await auth(); if (!session?.user?.id) redirect("/login")
- 3.Gate Pro features with the plan read from the DB, server-side
removed: "use client";removed: import { useSession } from "next-auth/react";added: import { redirect } from "next/navigation";added: import { auth } from "@/auth";added: import { AdminPanel } from "./admin-panel"; // the "use client" part removed: export default function Admin() {removed: const { data: session } = useSession();removed: if (!session) return null;removed: return <DeleteAllUsersButton />;added: export default async function AdminPage() {added: const session = await auth();added: if (!session?.user?.id) redirect("/login");added: // + check the role from the DB, and again inside every admin actionadded: return <AdminPanel />;}Find client components that decide access with useSession(), a session prop or a role flag (returning null, hiding buttons or router.push to /login). Keep that for UX, but add the real check on the server: in the page (`await auth()` + `redirect("/login")`) and inside every route handler and Server Action the component calls. Read role and plan from the database.
Server page reads data with no session check
This Server Component queries the database (or a private API) without calling auth(). If the page is meant to be private, anyone can open it. If it relies on the layout or proxy to block access, see those checks: neither is a complete guard.
usually because
- The auth check lives in the layout only
- The agent assumed proxy.ts covers this path
- The page started public and grew private data
fix
- 1.Call auth() in the page (or a shared data-access helper it uses) and redirect when signed out
- 2.Scope the query to session.user.id
- 3.With cacheComponents on, keep request-time reads (auth(), DB) inside <Suspense> or mark the page dynamic
added: import { redirect } from "next/navigation";added: import { auth } from "@/auth";added: import { db } from "@/lib/db";added: export default async function Billing() {removed: const invoices = await db.invoice.findMany();added: const session = await auth();added: if (!session?.user?.id) redirect("/login?callbackUrl=/billing");added: const invoices = await db.invoice.findMany({ where: { userId: session.user.id } }); return <InvoiceList invoices={invoices} />;}List every page.tsx that reads user data. Each private page must call `await auth()` itself (or through a data-access helper that does) and `redirect("/login")` when `!session?.user?.id`, then scope its queries to `session.user.id`. Don't rely on the layout or proxy.ts alone.
Auth check only in a layout
Next.js docs: layouts don't re-render on client navigation, so the session isn't re-checked on every route change, and a layout doesn't stop nested pages or Server Actions from running. A redirect in layout.tsx is a nice first gate, not the guard.
usually because
- One check in app/dashboard/layout.tsx looks like it covers every child page
- SPA habit: return null from a top-level component when signed out
fix
- 1.Keep the layout check if you like, but repeat auth() in each page, route and action (or in one data-access helper they all call)
- 2.In the layout, fetch user data for the nav; do the auth check close to the data
removed: export default async function DashboardLayout({ children }: { children: React.ReactNode }) {added: import "server-only";added: import { cache } from "react";added: import { redirect } from "next/navigation";added: import { auth } from "@/auth";added: added: /** Call from every private page, action and route, not just the layout. */added: export const requireUserId = cache(async () => { const session = await auth();removed: if (!session) redirect("/login");removed: return <Shell>{children}</Shell>;removed: }added: if (!session?.user?.id) redirect("/login");added: return session.user.id;added: });The dashboard auth check is only in layout.tsx. Create src/lib/dal.ts with a `requireUserId()` helper (import "server-only", React cache, `await auth()`, redirect to /login when signed out) and call it in every private page, Server Action and route handler under that layout. Keep the layout check as a first gate only.
Admin code checks sign-in but not the role
This looks like admin code, and it checks that someone is signed in, but never checks a role or permission. Every signed-in user is then an admin.
usually because
- auth() was copied from a user route and the role check never added
- The role check exists only in the client (hidden admin link)
fix
- 1.Read the role from the database (or a session field you set server-side in the Auth.js callbacks)
- 2.Return 403 / notFound() when the user isn't an admin, in every admin route and action
export async function DELETE(req: Request) { const session = await auth(); if (!session?.user?.id) return Response.json({ error: "Unauthorized" }, { status: 401 });added: const me = await db.user.findUnique({ where: { id: session.user.id }, select: { role: true } });added: if (me?.role !== "ADMIN") return Response.json({ error: "Forbidden" }, { status: 403 }); await db.user.deleteMany({ where: { banned: true } }); return Response.json({ ok: true });}Every route handler, Server Action and page under admin must check the user's role on the server, read from the database by `session.user.id`, and return 403 (or notFound()) for non-admins. Add a `requireAdmin()` helper and use it everywhere admin code runs.
Proxy matcher leaves routes uncovered
The matcher skips paths you may think are protected: a negative lookahead that excludes api, a '/dashboard' entry without ':path*' (so /dashboard/settings isn't matched), or a non-literal matcher (Next.js ignores dynamic values). Server Actions are POSTs to the page they're used on, so excluding a path also skips its actions.
usually because
- The Auth.js example matcher excludes api
- '/dashboard' was written instead of '/dashboard/:path*'
- The matcher was built from a variable or array spread
fix
- 1.Use literal matcher strings with :path* for every protected section
- 2.Treat proxy as an optimistic redirect only: Next.js docs say it should not be your only line of defense
- 3.Check auth() inside every route handler and Server Action as well
export const config = {removed: matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)", "/dashboard"],added: // literal strings only; :path* covers nested pages (and their Server Actions)added: matcher: ["/dashboard/:path*", "/settings/:path*", "/api/projects/:path*"],};added: // Still check auth() inside every route handler and Server Action.Review src/proxy.ts. Make the matcher literal strings that cover every protected section with :path* (including private /api routes). Keep proxy as an optimistic redirect only and confirm each route handler and Server Action in those sections also checks `await auth()` itself.
middleware.ts / middleware export in Next.js 16
Next.js 16 deprecated the middleware file and export name in favor of proxy (proxy.ts, export function proxy). Deprecated middleware still runs, but auth code copied from older Auth.js docs often still says auth as middleware.
usually because
- Code or docs written for Next.js 15 and earlier
- The agent followed an older Auth.js snippet
fix
- 1.Run npx @next/codemod@latest middleware-to-proxy . or rename the file to proxy.ts and the export to proxy
- 2.proxy always uses the Node.js runtime; if you need edge, the docs say keep middleware for now
removed: // middleware.tsremoved: export { auth as middleware } from "@/auth";added: export { auth as proxy } from "@/auth";added: // needs callbacks.authorized in auth.ts to actually block (see above)We're on Next.js 16. Rename middleware.ts to proxy.ts and the middleware export to proxy (npx @next/codemod@latest middleware-to-proxy .). Make sure the Auth.js proxy actually blocks signed-out users and that routes and actions still check auth() themselves.
03Sweep the whole repo
One prompt for every file.
This page checks one file at a time. To audit everything, give your agent this prompt, then paste the files it changes back here.
Audit this Next.js 16 App Router app (Auth.js v5) for missing access control. For every route handler (src/app/**/route.ts), every Server Action ("use server") and every private page:
1. It calls `const session = await auth()` from @/auth itself and stops (return 401 / redirect("/login") / throw) when `!session?.user?.id`. Don't count proxy.ts or a layout as the check.
2. Every query by id includes the owner: `where: { id, userId: session.user.id }` (findFirst, updateMany, deleteMany). Return 404 when nothing matches.
3. userId, role and plan never come from the body, form data, query string or action arguments; take the id from the session and read role/plan from the DB.
4. Admin code checks the role server-side.
5. proxy.ts uses literal matchers with :path* and actually redirects when req.auth is empty.
Skip only the Auth.js handlers and webhooks that verify a signature. List each file you changed and why, and add a test for any ownership check you add.Keep it fixed: access-control.mdc in the free Cursor Rules Pack tells Cursor to take the user id from the session, scope every query to it and return 404 for records the user doesn't own.
04Or start from a protected base
Auth checks your agent is told to keep.
Skip the debugging. The VibeBoot SaaS Starter ships with this already handled: src/lib/authz.ts ownership helpers with tests, an example route that returns 401 signed out and 404 for someone else's data, and CLAUDE.md and .cursor/rules telling your agent to check auth() on every page, Server Action and route. Plus Stripe and a database on Next.js 16.4.3. $49, one-time.
14-day money-back guarantee · lifetime updates
See everything that's inside →- Agent rules · CLAUDE.md: protect every page, Server Action and route server-side with auth(); scope every DB query to session.user.id
- Authz helpers · src/lib/authz.ts: currentUserId() and isOwner(), with a Vitest test
- Example route · src/app/api/users/[id]/route.ts returns 401 signed out and 404 for someone else's id
- Protected pages · the dashboard calls auth() and redirects to /login
- Billing from the DB · Pro is gated with isSubscribed() read from the database, never the client
FAQ
Is my code sent anywhere?
No. The checker is plain JavaScript in this page: nothing you paste is uploaded, logged or stored. Strip secrets from code before sharing it anywhere else anyway.
Isn't proxy.ts (middleware) enough to protect my app?
No. The Next.js docs describe proxy checks as optimistic and say proxy should not be your only line of defense. A matcher that skips a path also skips the Server Actions on it, and a refactor can silently drop coverage. Check auth() inside every route handler and Server Action too.
Are Server Actions protected if the page is?
No. Next.js docs: Server Actions are reachable via a direct POST request, not only through your UI. Treat each one like a public API endpoint and check the session and ownership inside it.
How accurate is this?
It's a pattern checker for one file at a time, not a full security audit. It can miss a check done in a helper it can't see, and it can flag code that's fine. Use each finding as a question to answer, and use the Cursor prompt to sweep the whole repo.
Does it work with Clerk, Better Auth or Supabase Auth?
The ownership, trusted-userId, Server Action and matcher checks apply to any auth library. The fixes and prompts use Auth.js v5's auth() helper; swap in your library's server-side session call.
Where should the session check live?
As close to the data as possible. A small server-only helper (for example requireUserId()) called from every private page, route and action keeps it in one place; the query itself should still include userId: session.user.id.
Sources: Next.js docs on authentication, data security, proxy and the version 16 upgrade guide, and Auth.js's protecting resources guide. Checked October 26, 2026 against Next.js 16.4.3 App Router · Auth.js v5 (next-auth 5.0.0-beta.32). Spotted a mistake? Email [email protected].
> More free tools
All tools →- VibeBoot DoctorNewPaste any error or your package.json: diagnosis, fix and the rule that prevents it.
- middleware.ts → proxy.tsNewConvert Next.js middleware to Next.js 16 proxy.ts, Auth.js v5 included.
- AGENTS.md checkerNewCheck AGENTS.md for the Next.js 16 managed block, commands and guardrails.
- Prisma error fixerP2002, P1001, P2021, did not initialize yet: cause and fix.
- Next.js error fixerServer Component, Suspense and hydration errors: cause and fix.
- Env leak checkerPaste your .env, see which secrets would ship to the browser.
- Stripe webhook fixerPaste a Stripe webhook error, get the cause and the fix.
- Auth.js error fixerMissingSecret, UntrustedHost, redirect_uri: cause and fix.
- AGENTS.md generatorA ready AGENTS.md and CLAUDE.md for your Next.js stack.
- Cursor Rules PackFree downloadScoped .mdc rules for Next.js, Stripe webhooks, access control and more.
- Cursor rules generatorBuild one project.mdc rule for your exact stack.
- AI chatbot cost calculatorCost per message, Pro margin and a safe free-tier cap.