Guide
Securing Next.js API routes and server actions
Check auth in every route and action, verify the user on the server, check ownership, validate input and avoid open redirects.
Every route handler and server action is a public URL. Anyone can call it with curl, whatever your UI shows. These six checks cover the usual gaps. Examples use Supabase, where createClient() returns a client acting as the signed-in user, and zod for validation.
1. Check auth in the route, not only in middleware
// Bad: middleware.ts protects /dashboard, so the API route doesn't check
export async function GET() {
const { data } = await supabaseAdmin.from("invoices").select("*");
return Response.json(data);
}
// Good
export async function GET() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return new Response("Unauthorized", { status: 401 });
const { data } = await supabase.from("invoices").select("*"); // RLS limits it to this user
return Response.json(data);
}
Middleware matchers miss routes, and Next.js middleware has had real bypass bugs, such as CVE-2025-29927. Every route should check for itself.
2. Server actions need the same check
// Bad
"use server";
export async function deleteProject(projectId: string) {
await supabaseAdmin.from("projects").delete().eq("id", projectId);
}
// Good
"use server";
import { z } from "zod";
export async function deleteProject(projectId: string) {
const id = z.string().uuid().parse(projectId);
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error("Not signed in");
const { error } = await supabase.from("projects").delete().eq("id", id).eq("owner_id", user.id);
if (error) throw new Error("Could not delete project");
}
A server action is a public POST endpoint. Only your delete button calls it, but anyone can call it with any ID.
3. Use getUser() on the server, not getSession()
// Bad
const { data: { session } } = await supabase.auth.getSession();
if (session?.user) { /* trusted */ }
// Good
const { data: { user } } = await supabase.auth.getUser(); // or getClaims()
if (!user) return new Response("Unauthorized", { status: 401 });
On the server, getSession() reads the cookie without verifying it, so a forged cookie passes. getUser() checks with Supabase Auth, and getClaims() verifies the token's signature.
4. Check the record belongs to the caller
// Bad: app/api/orders/[id]/route.ts
const { data } = await supabaseAdmin.from("orders").select("*").eq("id", params.id).single();
// Good
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return new Response("Unauthorized", { status: 401 });
const { data } = await supabase
.from("orders").select("*")
.eq("id", params.id).eq("user_id", user.id)
.single();
if (!data) return new Response("Not found", { status: 404 });
Changing /orders/123 to /orders/124 is the oldest attack on the web. Being logged in isn't the same as owning the record. Return 404 rather than 403, so you don't confirm the record exists.
5. Only accept the fields you expect
// Bad
export async function updateProfile(data: any) {
await supabase.from("profiles").update(data).eq("id", user.id);
}
// Good
const ProfileUpdate = z.object({
full_name: z.string().trim().min(1).max(100),
bio: z.string().max(500).optional(),
});
export async function updateProfile(input: unknown) {
const data = ProfileUpdate.parse(input);
await supabase.from("profiles").update(data).eq("id", user.id);
}
The bad version updates whatever fields are sent, including plan, is_admin or credits if they're on that table.
6. Don't redirect to any URL after login
// Bad
const next = searchParams.get("next") ?? "/";
return NextResponse.redirect(new URL(next, request.url));
// Good: resolve the URL first, then insist it stays on your site
const here = new URL(request.url);
const target = new URL(searchParams.get("next") ?? "/", here);
return NextResponse.redirect(target.origin === here.origin ? target : new URL("/", here));
?next=https://evil.example turns your login page into a phishing link that starts on your real domain. Checking the string (for example "starts with /") isn't enough: browsers ignore tabs and backslashes in URLs, so values like /\t/evil.example slip through. Comparing the resolved origin catches all of them.
Checking it automatically
Seaworthy flags route handlers and server actions with no auth check (SW-A01), records loaded by ID with no ownership check (SW-A02), and SQL built from request input (SW-A05). It doesn't check sections 3, 5 or 6 yet. Run it with:
npx seaworthy-cli
These fixes reduce common risks. They are not a security audit or a guarantee.