Skip to content
VibeSafeDaily

Auth

The page checks login, but its Server Action answers anyone

Cataloged October 25, 2026 · v0 · Cursor · Bolt · Replit

Symptom

Your dashboard or admin page redirects logged-out visitors to /login, so the Delete, Update, or Invite button behind it looks safe. But the button calls a Next.js Server Action, and that action is its own public POST endpoint. A request sent straight to it with the action's ID runs the mutation with no session at all, or lets a signed-in user change rows that belong to someone else.

Root cause

AI-generated Next.js apps put the auth check where the screen is: a redirect in page.tsx, a guard in layout.tsx, or a matcher in middleware or proxy.ts. Then they write the mutation as an exported "use server" function with no check inside it. Next.js documents that a Server Action, once created and exported, is reachable via a direct POST request and not just through your UI, and that a page-level authentication check does not extend to the Server Actions defined within it, so you must re-verify inside each action. The authentication guide adds that layouts do not re-run on navigation and do not stop nested segments or Server Actions from being reached, and says to treat Server Actions like public-facing API endpoints. The action ID ships in the client JavaScript, so it is not a secret. Logging in is also not enough: an action like deletePost(postId) must also check that the post belongs to the caller, or any account can delete any row by changing the ID. A public example: NextChat issue 6757 (April 2026) showed an exported Server Action with no auth check that any visitor could call with one POST request; the project later merged fixes to gate and authenticate those actions.

Before and after

The before action trusts that only the protected page can call it. The after action checks the session and ownership itself.

Before

'use server'
export async function deleteProject(id: string) {
  await db.project.delete({ where: { id } })
}

After

'use server'
export async function deleteProject(id: string) {
  const user = await getUser() // server-only session check
  if (!user) throw new Error('Unauthorized')
  const project = await db.project.findUnique({ where: { id } })
  if (project?.ownerId !== user.id) throw new Error('Forbidden')
  await db.project.delete({ where: { id } })
}

Fix prompt

In this project only. Do not test against anyone else's app.

1. List every Server Action: search for "use server" at the top of files and inside functions, in app/, src/, lib/, and actions/ folders. For each exported or inline action, write down what it changes (database rows, files, emails, payments, roles) and which page or component calls it.

2. For each action, check whether it verifies the session INSIDE the action body, before any database call. A redirect or check in the page, layout, or middleware/proxy does not count. Report every action that has no check of its own.

3. Create or reuse one server-only helper (for example lib/dal.ts with import 'server-only') that reads the session from cookies with my existing auth library (Supabase, Auth.js, Clerk, or custom) and throws or returns null when there is no valid user. Call it at the top of every action that changes data or returns private data.

4. Add authorization, not just authentication: when an action takes an id (postId, projectId, userId, orgId), load the row on the server and confirm it belongs to the current user or their team before updating or deleting. Admin-only actions must check the role from the database or app_metadata, never from form data.

5. Validate every argument with a schema (zod or similar). Never accept userId, role, ownerId, or price from the client; derive them from the session on the server.

6. Return only what the UI needs from each action (for example { ok: true }), not whole database records.

7. Tell me how to verify on my own app: sign out, replay one of my own action requests from DevTools, and confirm it now fails; then sign in as a second test account I own and confirm it cannot change the first account's rows.

Do not print keys, tokens, or user data. Do not edit unrelated files.

Paste this into the coding tool that has your own project open.

2-minute self-check

These steps are for an app you own. Do not run them against someone else's project.

  1. In your own project, run grep -rn "use server" app src lib and open each match. For every function that writes, deletes, or invites, look for a session check (auth(), getUser(), verifySession(), supabase.auth.getUser()) on the first lines inside that function. A check only in page.tsx, layout.tsx, or middleware does not protect it.
  2. On your own deployed app, sign in, open DevTools Network, click a button that changes data, and find the POST request with a Next-Action header. Copy it as fetch, sign out in a private window or clear cookies, and resend it. If the change still happens, that action needs its own auth check.
  3. With two test accounts you own, perform an edit or delete as account A, then resend the same request signed in as account B. If B can change A's row by keeping the same id, the action checks login but not ownership.

Common questions

My middleware already blocks logged-out users. Isn't that enough?

No. Next.js says middleware (now proxy.ts) should handle optimistic checks and redirects, not be your only line of defense, and a matcher that skips a path or a misconfigured rule leaves the action open. The check that counts is the one inside the action, right before it touches data, because that is the only code that runs on every call no matter how it arrives.

The action ID is a long random hash. Can someone really find it?

Yes. The ID is in the JavaScript Next.js sends to the browser so the button can call the action, and it shows up in the Next-Action header of every request in DevTools. Next.js says to treat Server Actions as reachable via direct POST requests, so plan as if the ID is public.

Sources

Same symptom

Same platform