List every surface that answers without a login
Write down each table or collection, storage bucket, API route, server action, webhook, admin page and tool server. For each, record who may read it and who may write it. Test the ones you cannot answer for first.
Deny by default at the data layer
OWASP's guidance is to configure the application to deny access by default and to prefer explicit configuration over framework defaults. In Postgres on Supabase, that means RLS on, no grant for anon, and one explicit policy for each access you want. In Firebase, start from locked rules (allow read, write: if false) and add per-user rules such as request.auth.uid == userId.
alter table public.reviews enable row level security;
revoke all on table public.reviews from anon;
create policy "owner only" on public.reviews
for all to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
Check identity and ownership inside every handler
Do not rely on a page redirect or on middleware alone. Each action or route confirms who is calling and that the object belongs to them. It returns the same refusal whether the object is missing or someone else's.
"use server";
export async function deleteReview(id: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const review = await db.review.findUnique({ where: { id } });
if (!review || review.authorId !== session.user.id) throw new Error("Forbidden");
await db.review.delete({ where: { id } });
}
Put authentication in front of tool servers and admin UIs, then rotate
A tool server or self-hosted web UI reachable over HTTP needs token validation on every request, and an invalid or expired token should get a 401. If an endpoint ever returned environment variables, an admin token or files containing credentials, assume they were read and rotate them. Remove world-readable permissions from logs and scripts that hold credentials, too.
Verify the bot challenge on the server and fix the recipient
Call Turnstile's siteverify from your backend for every submission. Tokens are single-use and valid for 300 seconds, so a replayed token is refused. Never read the 'to' address of an outgoing email from the request body.
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET, response: token }),
});
const { success } = await res.json();
if (!success) return new Response("Challenge failed", { status: 400 });
Rate limit and dedupe every public write
Limit requests per IP and per address at your host's firewall or in code. The Next.js guide recommends rate limiting expensive operations such as sending email or writing to a database. Let the database refuse duplicates, so a retry or a bot loop cannot create a second row or burn storage.
create unique index waitlist_email_key on waitlist (lower(email));
insert into waitlist (email) values ($1)
on conflict (lower(email)) do nothing;
Stop caches serving one person's data to another
Mark personalized responses Cache-Control: no-cache, private, as MDN recommends. A cookie alone does not make a response private. Include the user's ID in every client-side cache key and clear that cache on sign-out. When you unpublish content, purge its path from the CDN as well as removing it from the origin.
res.setHeader("Cache-Control", "no-cache, private");