Checklist
Next.js and Supabase: the authorization check before launch
Before launch, check four places: Row Level Security on every table the browser can reach, policies that scope by organization, every use of the secret key, and an authorization check inside each server action and route handler. A worked example of the most common gap.
Tristan Roth,
Before launching a Next.js app on Supabase, check four places. One: Row Level Security is enabled on every table in an exposed schema. Two: each policy scopes rows by the user's organization, not just by being logged in. Three: every server-side use of the secret (service role) key, which bypasses Row Level Security, carries its own organization check. Four: every server action and route handler checks authentication and authorization inside itself, because a server action is reachable by a direct POST request. The most common gap is three and four together, shown below.
Why this stack needs its own list
A Supabase app has two doors to the data. The browser talks to the database directly with the publishable (anon) key, so the database itself enforces who sees what, through Row Level Security (RLS): rules written in Postgres that decide, row by row, which rows a request may read or change. The server talks to the database too, from server actions and route handlers, and often with the secret key, which Supabase documents as bypassing RLS entirely.
So the rules live in two places, and each door can be left open on its own. Supabase's own documentation is direct about the first: a table in an exposed schema without RLS is readable and writable by any role with a grant on it. Next.js is direct about the second: an exported server action is reachable via a direct POST request, not just through your application's UI, so authentication and authorization belong inside each one.
The four checks
RLS on every exposed table. List the tables in the public schema (the default exposed schema) and confirm RLS is on for each. A table added last week by a migration is the usual one without it.
Policies that scope by organization. A policy that says using (true) for the authenticated role lets any logged-in user read every row. For a multi-tenant app, each policy should tie the row's org_id to an organization the user is a member of.
Every use of the secret key. Search the code for the admin client. Each call runs with RLS off, so each one needs the organization filter the policy would have applied. The secret key never goes to the browser.
Every server action and route handler. Each one checks who is calling and whether that caller may touch this record, inside the function. A middleware redirect protects page navigation; it is not the gate for a POST to an action.
The most common gap, in one screen of code
A delete action written with the admin client, because the RLS policy was getting in the way during development. It checks that someone is logged in. It never checks that the project belongs to their organization, and RLS does not step in, because the secret key turned RLS off for this call.
"use server";
export async function deleteProject(projectId: string) {
const user = await getUser();
if (!user) throw new Error("unauthorized");
- const supabase = createAdminClient(); // secret key: RLS does not apply
+ const supabase = await createClient(); // the user's session: RLS applies
const { error } = await supabase.from("projects").delete().eq("id", projectId);
if (error) throw error;
}The policy the fix relies on
Switching to the session client moves the organization check into the database, so the policy has to exist and be right. If the action must keep the admin client, add .eq("org_id", orgId) to the query with an orgId resolved from the user's membership on the server, never from the request. A DELETE with a WHERE clause also needs a matching SELECT policy on the table: without one, Postgres sees no rows to delete and the call silently deletes nothing, with no error.
alter table projects enable row level security;
create policy "members delete their org's projects"
on projects for delete to authenticated
using (
org_id in (
select org_id from memberships where user_id = (select auth.uid())
)
);Keeping it checked after launch
The launch check is a snapshot. The gap comes back in the next pull request: a new table without RLS, a new action on the admin client, a policy loosened to get a feature working. With the Aevral GitHub App installed, each supported pull request gets an advisory Check, live on install, and Aevral looks for access-control shapes like the one above on the diff, with inline comments and a suggested fix when there is a grounded finding. It never blocks a merge. Aevral does not connect to your Supabase project or read your live database policies. It reads the pull request: the diff plus up to 40 changed files at head, so a migration file is read when the pull request changes it.
The published record: on the Aevral receipts page, the PR review runs of 25 September 2026 read "10 of 12" (Run 1) and "11 of 12" (Run 2) on the frozen authorization slice of planted pull requests. In the page's own words: "Each recall number is the result of that named run on that corpus. It is not a product accuracy rate, and results on your code depend on your code."
From install to the first comment
One: install the Aevral GitHub App on the repositories you choose; a GitHub owner or admin approves the install. PR review is live on install: installing the App starts reviews on the next pull request, even before anyone signs in, and the owner can turn them off in Setup.
Two: sign in to the console with GitHub. That connects the install and starts a 14-day trial for private repositories: private reviews are free up to 500, and the trial ends at 14 days or 500 reviews, whichever comes first.
Three: open a pull request, or let your coding agent open one. Aevral reads the diff plus up to 40 changed files at head.
Four: read the result on the pull request. An advisory Check sits on the head commit, and when there is a grounded finding, inline comments sit on the added lines, at most two findings per review. Each finding carries the evidence, a suggested fix, and a Fix with your agent prompt a human can paste into Claude Code, Cursor, or Codex. Aevral never pushes, applies, or merges anything, and it never blocks a merge. You decide what ships.
Sources
Supabase: Row Level Security; Supabase: API keys; Next.js: data security; OWASP API1:2023 Broken Object Level Authorization.
Read next
Spotting a missing tenant check in a pull request; Reviewing pull requests for IDOR with a GitHub app; Install the GitHub App; Pricing.
More guides
- What a whole-repo authorization scan reads
- PR security review and SAST are different questions
- Handing a security finding to your coding agent
- The Aevral launch-updates list, explained
- Reviewing a pull request for access control
- Working a scan report of access-control leads
- How IDOR happens in multi-tenant code
- Where access control hides in business logic
- Reviewing a pull request that wires in an LLM
- Security review priced per pull request, explained
- Reviewing pull requests for IDOR with a GitHub app
- Spotting a missing tenant check in a pull request