Every Server Action must verify authentication as its first operation. Server Actions compile to public HTTP POST endpoints — anyone on the internet can call them directly with a simple fetch request, bypassing your UI entirely. Even if you have middleware or layout-level auth checks, the action itself must independently verify the user because external guards can be misconfigured, incomplete, or bypassed. Without per-action auth, an attacker can invoke privileged operations like deleting data, changing settings, or accessing resources they should never reach.
Why This Matters
Server Actions are exposed as public HTTP POST endpoints. Anyone with the endpoint URL can call them directly, bypassing your UI, middleware, and layout-level auth checks. Without explicit authentication inside each action, any unauthenticated user can invoke privileged operations.
Next.js Server Actions compile down to public HTTP POST endpoints. They are not protected by:
middleware.ts route matching
Layout-level auth guards
Client-side redirects
Anyone who discovers (or guesses) the action ID can call it directly with fetch() or curl. If the action does not verify authentication internally, it is an unauthenticated API endpoint.
The rule
Every Server Action that reads or writes data must verify authentication as its first operation, before any business logic runs.
Bad example
"use server";export async function updateProfile(data: ProfileData) { // No auth check! Anyone can call this endpoint. const supabase = await createClient(); await supabase .from("profiles") .update(data) .eq("id", data.userId); // userId comes from the client — can be forged}
Good example
"use server";import { createClient } from "@/lib/supabase/server";export async function updateProfile(data: ProfileData) { const supabase = await createClient(); const { data: { user }, error } = await supabase.auth.getUser(); if (error || !user) { throw new Error("Unauthorized"); } // Use the verified user.id, not the client-supplied one await supabase .from("profiles") .update({ name: data.name, bio: data.bio }) .eq("id", user.id);}
Common mistakes
Trusting client-supplied user IDs — always derive the user ID from getUser(), never from function arguments
Checking auth in middleware only — middleware does not protect Server Actions
Checking auth in the layout — layouts are not security boundaries; the action endpoint is independently callable
Forgetting to check authorization — authentication (who are you?) is not the same as authorization (are you allowed to do this?)
How to detect
Search for Server Action files that don't call getUser():
# Find all "use server" filesgrep -rl '"use server"' app/ --include="*.ts"# Check which ones lack auth verificationfor f in $(grep -rl '"use server"' app/ --include="*.ts"); do if ! grep -q "getUser" "$f"; then echo "MISSING AUTH: $f" fidone
Remediation
Create a shared requireAuth() helper that calls getUser() and throws on failure
Call requireAuth() as the first line of every Server Action
Use the returned user.id for all authorization and data queries
Add a CI check to detect Server Actions without auth verification