playbooks / auth

The Server Believes No One

Every route and server action verifies the caller itself, because client side checks are decoration.

25 minutes▲ severity if skipped: critical
The duck weighs in

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.

1

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.

2

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.

ts
// 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);
}
3

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.

ts
"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);
}
4

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.

5

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.

ts
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/5

receipts · every claim has a source