When an AI tool builds your Next.js app, it creates route handlers like app/api/projects/route.ts that read and write data. The UI only shows these features to logged-in users, so it's easy to assume the API is protected too. It isn't. Every route handler is a public URL that anyone can call with curl.
This guide shows how to lock route handlers down properly, using Supabase Auth as the example. The same ideas apply to Auth.js, Clerk or any other provider.
The two questions every handler must answer
- Authentication: who is making this request? If nobody, return
401. - Authorization: is this user allowed to touch this specific record? If not, return
403or404.
Most vulnerabilities in AI-built apps come from skipping the second one. The route checks that someone is logged in, then trusts an id from the request body.
An unprotected handler (what AI tools often generate)
// app/api/projects/[id]/route.ts (don't ship this)
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
await db.project.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
Anyone who can guess or find a project ID can delete it. No login needed.
Step 1: Get the user on the server
With Supabase, create a server client using @supabase/ssr. In Next.js 15, cookies() is async:
// lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (list) => {
try {
list.forEach(({ name, value, options }) => cookieStore.set(name, value, options));
} catch {
// called from a context where cookies can't be set; safe to ignore
}
},
},
}
);
}
Then write a small helper so every handler does the same thing:
// lib/auth.ts
import { createClient } from "@/lib/supabase/server";
export async function requireUser() {
const supabase = await createClient();
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) return { user: null, supabase };
return { user, supabase };
}
Use getUser(), not getSession(), for authorization on the server. getUser() checks the token with Supabase Auth, while getSession() reads the cookie without verifying it.
Step 2: Check auth and ownership in the handler
// app/api/projects/[id]/route.ts
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { user, supabase } = await requireUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { data, error } = await supabase
.from("projects")
.delete()
.eq("id", id)
.eq("owner_id", user.id)
.select("id");
if (error) return NextResponse.json({ error: "Server error" }, { status: 500 });
if (!data?.length) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json({ ok: true });
}
Two things are happening here:
- The
owner_idfilter means a user can only delete their own projects. - Because the query runs through the user's Supabase client, RLS applies as a second layer. If your RLS policies are correct, even a forgotten
.eq("owner_id", ...)won't leak data. Our Supabase RLS checklist covers those policies.
Returning 404 instead of 403 for someone else's record avoids confirming that the ID exists.
Step 3: Never trust user IDs from the request
This pattern is very common and very wrong:
const { userId, name } = await req.json();
await supabase.from("projects").insert({ owner_id: userId, name });
The client can send any userId it likes. Always take identity from the verified session:
const body = await req.json();
await supabase.from("projects").insert({ owner_id: user.id, name: String(body.name ?? "") });
The same goes for roles. Don't read isAdmin from the body, a query string, or user-editable metadata. Look it up from a table or app_metadata on the server.
Step 4: Validate input
Use a schema library like Zod so unexpected fields never reach your database:
import { z } from "zod";
const CreateProject = z.object({ name: z.string().min(1).max(100) });
export async function POST(req: Request) {
const { user, supabase } = await requireUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const parsed = CreateProject.safeParse(await req.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "Invalid input" }, { status: 400 });
const { data, error } = await supabase
.from("projects")
.insert({ owner_id: user.id, name: parsed.data.name })
.select()
.single();
if (error) return NextResponse.json({ error: "Server error" }, { status: 500 });
return NextResponse.json(data, { status: 201 });
}
Picking fields explicitly also prevents mass assignment, where a user sends { "plan": "pro" } and it gets written straight into their row.
Why middleware alone isn't enough
Next.js middleware is useful for redirecting logged-out users away from pages and refreshing auth cookies. But don't make it your only check:
- Matchers are easy to get wrong, and a new route may not be covered.
- Middleware usually only checks that a user exists, not that they own a record.
- In March 2025, a Next.js vulnerability (CVE-2025-29927) let attackers skip middleware on some self-hosted setups by sending a crafted header. It was patched, but it shows why the check should live next to the data.
Treat middleware as a convenience, and put the real check in each handler or in a shared data-access function.
Don't forget the service role
If a handler uses the Supabase service role key, RLS is bypassed, and your code is the only thing protecting the data. Keep service role use to the few places that truly need it (webhooks, admin jobs), and still verify who the caller is. Never expose that key to the browser. See exposed keys in NEXT_PUBLIC_ and VITE_.
Handlers that are public on purpose
Some routes should be callable without a login, like a Stripe webhook or a contact form. Protect them differently:
- Webhooks: verify the signature. See Stripe webhook signature verification in Next.js.
- Forms and AI endpoints: add rate limiting and bot protection. See rate limiting OpenAI API routes.
- Server Actions: these are public endpoints too. See Next.js Server Actions security.
Test it like an attacker
Log out, then call your API directly:
curl -i -X DELETE https://your-app.com/api/projects/SOME_ID
You should get 401. Then sign in as user A, copy a project ID belonging to user B, and try to read, update and delete it from user A's session. Each request should fail.
Check every route at once
Going route by route works, but it's easy to miss one, especially after an AI tool adds three new handlers in a single prompt. PreflightPR flags API routes that read or write data without an auth check, along with leaked keys and missing RLS. Run a free scan on a public repo to see which of your routes need attention. It's an automated best-effort review, so pair it with the manual tests above.