Securing a Next.js + Supabase App
Security in a Next.js + Supabase app is almost entirely a question of where the trust boundary sits and which database key runs each query. Get those two things right and most vulnerabilities disappear; get them wrong and a single mistake can expose every tenant's data. This guide walks through the model end to end — the why behind each rule — and the checklist at the bottom is generated from the enforceable rules so it never drifts from what the scanner actually checks.
The mental model: three clients, one boundary
Supabase gives you three ways to talk to the database, and choosing the wrong one is the root cause of most leaks:
- The anon client runs in the browser (and in public server code) using the
anon key. Every query it makes is filtered by Row Level Security. It is safe to expose because, on its own, it can only see what your RLS policies allow an unauthenticated visitor to see.
- The server client is created per-request from the user's auth cookie. It runs as the logged-in user, so RLS still applies — but now scoped to that user's identity. This is what you use in Server Components and Server Actions for normal reads and writes.
- The admin (service-role) client uses the
service_role key, which bypasses RLS entirely. It can read and write every row in every table. It is effectively a master key.
The single most important rule follows directly: the service_role key must never reach the browser, and must never be used for user-driven reads. It belongs only in trusted server code, only for writes you have already authorized yourself, and only after you have re-checked who the caller is.
Secrets and the NEXT_PUBLIC_ boundary
Next.js inlines any environment variable prefixed with NEXT_PUBLIC_ into the client bundle. That prefix is the trust boundary for configuration. The anon key and project URL are fine there — they are designed to be public. Anything else (the service_role key, third-party API secrets, signing keys) must be stored without the prefix so it stays server-only. Keep .env.local out of git, keep .env.example documenting the variable names with no values, and never log secrets in error messages, where they have a habit of ending up in your observability pipeline.
To make this structural rather than a matter of discipline, mark genuinely server-only modules with import "server-only". If anything imports them into a client component, the build fails instead of silently shipping a secret to the browser.
RLS is the real security boundary
It is tempting to think of access control as something your application code does. In a Supabase app, the durable boundary is Row Level Security in the database — because it holds even if an attacker bypasses your UI and hits the API directly. Two principles make RLS trustworthy:
- Deny by default. Enable RLS on every table and write restrictive policies. A table with RLS enabled and no policy denies all access; a table with
USING (true) on a private table is wide open. The dangerous middle ground is a half-written policy, so review them deliberately.
- Scope by tenant, consistently. In a multi-tenant app, almost every policy reduces to "this row belongs to an org the current user is a member of." Pull that into one helper (e.g.
is_org_member(organization_id)) and use it everywhere, so the rule lives in one place and can be audited at a glance. And because RLS is the boundary, test the policies explicitly — a passing app does not prove a policy is tight.
Server Actions: authorize, then validate, then scope
A Server Action is a public endpoint with a friendly syntax. Treat it like one:
requireAuth() (or your equivalent) is the first line of every action. Never assume the caller is who the UI implies.
- Validate every input at the boundary with a schema (Zod or similar). The client is untrusted; a hidden field or a hand-crafted request will send you whatever it likes.
- Scope every mutation to the authenticated organization. Set
organization_id explicitly from the server's notion of the user, never from a value the client supplied. This is the difference between "update my row" and "update any row."
- Don't leak internals in error messages. A stack trace or raw database error returned to the client is reconnaissance for an attacker.
If you reach for the admin client inside an action, pause: it should only ever be for a write you have already authorized, never for reads (reads through the admin client silently skip RLS and are the classic way tenant data leaks).
Below the database, the usual web hardening still applies and is easy to forget in a framework app: a Content-Security-Policy to constrain script sources, SSRF protection on any URL the user can influence, rate limiting on public endpoints, and HTTPS-only cookies for sessions. In middleware, call auth.getUser() immediately after creating the server client and return the supabaseResponse object unchanged — getting this wrong silently breaks session refresh and can leave stale auth in place.
Common mistakes (and the edge cases)
- Using the anon key for an admin task because "it was already imported." If the data should be filtered by who's asking, that's the server client; if it's a privileged write, that's the admin client after an auth check.
- A public page that needs the service role. It almost never does. Public pages should read through the anon key and rely on RLS; if a public page needs privileged data, fetch it in a server context and pass down only what's safe.
- Passing server-only data as props to a client component. Anything you hand a client component is shipped to the browser. Compute on the server and pass down the rendered result, not the raw privileged data.
None of this is exotic — it is the same boundary applied consistently at every layer. The checklist below is derived from the enforceable rules in this skill, so working through it is the same as making your code pass the scan.