playbooks / auth
The Server Believes No One
Every route and server action verifies the caller itself, because client side checks are decoration.

duck@duckaudit
You hid the delete button from non admins. The endpoint is still there. I can smell it.
how it burns you
Hiding the admin button only hides the button; the endpoint behind it still answers whoever calls it with curl. AI assistants love generating beautiful client side permission checks and calling the job done. The server is where truth lives, and it has to check every single time.
Treat client checks as UX, not security
Conditional rendering decides what people see, never what they can do. Anyone can call your API directly, replay requests, or edit the JavaScript. Every protection you care about must exist again on the server.
Verify identity inside every route handler
First line of every private route: who is this. Not the middleware, not the layout, the route itself. Sessions get checked where the data gets touched.
// app/api/notes/route.ts
import { createClient } from "@/lib/supabase/server";
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("notes").select("*").eq("user_id", user.id);
return Response.json(data);
}Server actions are public endpoints too
A server action is an HTTP endpoint wearing a function costume. It needs the same identity check as any route, because anyone can invoke it with a crafted request.
"use server";
export async function deleteNote(id: string) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error("Unauthorized");
// scope the mutation to the caller, never trust the id alone
await supabase.from("notes").delete().eq("id", id).eq("user_id", user.id);
}Check authorization, not just authentication
Signed in is not the same as allowed. User A must not fetch user B's invoice by changing the id in the URL. Scope every query by the caller's id or role, in the query itself.
Make admin mean something on the server
An is_admin flag in the browser is a suggestion. Keep roles in the database, check them server side, and let RLS enforce the same rule one layer deeper so a forgotten check does not become a breach.
const { data: profile } = await supabase
.from("profiles").select("role").eq("id", user.id).single();
if (profile?.role !== "admin") return new Response("Forbidden", { status: 403 });Make your AI do it
Paste this into Cursor or Claude Code from your project root.
Map the authorization surface of this app. 1) List every API route and every server action. 2) For each, show the exact lines where it authenticates the caller and where it authorizes the specific resource being touched; flag any that rely only on middleware, layouts, or client checks. 3) Flag any query that trusts an id from the request without scoping it to the caller. 4) Produce a fix diff for the three riskiest gaps, using the session check pattern already present in this codebase.
The 10 minute check
0/5receipts · every claim has a source
next playbook
Raise Your AI Right →