GuardVibe
Blog
· 14 min read

Securing a Next.js + Supabase App Your AI Agent Wrote

Keys, RLS, policies, views and server code: a five-check audit for the Next.js + Supabase app your agent built, with SQL and grep you can run today.

Your agent built the app in an afternoon. Sign-up works, the dashboard loads each user's projects, the admin page shows totals, and avatars upload to a storage bucket. You open the Supabase dashboard to check something and notice the profiles table has no lock icon. Then you notice .env.local contains a variable called NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY.

Neither of those broke a test. Both mean that anyone with your site's URL can read data that belongs to other users, and in the second case, read and write every table in the project.

This is a walkthrough of how to audit a Next.js + Supabase app that an AI agent wrote, in the order that matches blast radius: keys first, then tables, then policies, then the database objects that skip policies entirely, then the server code. Every check comes with a command you can run today.

Why AI-written Supabase apps fail in the same places

Supabase's security model is unusual. With a typical backend, your server sits between the browser and the database, and every query passes through code you wrote. With Supabase, the browser talks to the database through the Data API using a key that ships in your JavaScript. The publishable key is meant to be public. What protects your data is Row Level Security: Postgres policies that decide, per row, what the caller's role and JWT may see.

That model is sound, but it moves authorization out of the code the agent is looking at and into SQL that lives in migrations or the dashboard. An agent working on a React component sees supabase.from("profiles").select() return the right rows in development and has no signal that the same query returns everyone's rows. Three habits make it worse:

  • It optimizes for "it works." When a query fails because RLS blocks it, the quickest fix the model has seen thousands of times is a more powerful key or a using (true) policy. Both make the error go away. We covered this reward pattern in why AI coding agents keep writing the same vulnerabilities.
  • It fixes the error message, not the cause. Next.js replaces non-NEXT_PUBLIC_ environment variables with an empty string in client code, per the Server and Client Components docs. An agent that reads the service-role key in a client component gets an "invalid API key" error, and the obvious fix is to add the prefix.
  • It writes SQL without the platform defaults in mind. A create table in a migration file does not, by itself, enable RLS on that table.

This is not hypothetical. CVE-2025-48757 describes "insufficient database Row-Level Security policy" in apps generated by Lovable through 2025-04-15, allowing unauthenticated reads and writes to arbitrary tables. The vendor disputes it, on the grounds that each customer is responsible for their own data, and that dispute is the point: the platform will not secure your tables for you.

Check 1: the secret key never reaches the browser

Supabase has two kinds of keys. The publishable key (sb_publishable_..., or the legacy anon JWT) is safe to ship. The secret key (sb_secret_..., or the legacy service_role JWT) maps to the service_role Postgres role, which has BYPASSRLS. The API keys guide puts it plainly: a secret key "bypasses every Row Level Security policy you have."

The vulnerable shape usually spans two files:

// src/lib/supabase-admin.ts
import { createClient } from "@supabase/supabase-js";

export const admin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY!,
);
// src/components/admin-table.tsx
"use client";
import { admin } from "@/lib/supabase-admin";
// ...

The NEXT_PUBLIC_ prefix tells Next.js to inline the value into the JavaScript bundle at build time. The environment variables guide is explicit that it "will be inlined into any JavaScript sent to the browser." Anyone can open DevTools, copy the key, and call the Data API as service_role. Every table, every row, read and write, no policy consulted.

Supabase now adds a safety net: a request carrying a secret key with a browser User-Agent gets a 401. That stops the key from working inside your page. It does nothing for an attacker who copies the key into curl or a script. Treat a secret key that reached a bundle as compromised and rotate it.

The fix is a module that cannot be imported from the client:

// src/lib/supabase-admin.ts
import "server-only";
import { createClient } from "@supabase/supabase-js";

export function createAdminClient() {
  return createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SECRET_KEY!, // no NEXT_PUBLIC_ prefix
    { auth: { persistSession: false, autoRefreshToken: false } },
  );
}

With import "server-only", Next.js fails the build if a Client Component imports this module, directly or through another file. That transitive case matters: a per-file check sees admin-table.tsx importing a local module and nothing else.

Find candidates in your repo:

# secret-ish names behind a public prefix, including .env files
grep -rhoE "NEXT_PUBLIC_[A-Z0-9_]*(SECRET|SERVICE|ROLE|PRIVATE)[A-Z0-9_]*" . \
  --exclude-dir=node_modules --exclude-dir=.next | sort -u

# client components that reference the admin client or a secret key
grep -rlE "^[\"']use client[\"']" src \
  | xargs grep -lE "supabase-admin|SERVICE_ROLE|SUPABASE_SECRET_KEY"

# after `next build`: did a new-format secret key end up in static assets?
grep -rl "sb_secret_" .next/static

Legacy service_role keys are JWTs, so grepping the bundle for the string service_role will not find them. Search for the key value itself instead: grep -rlF "${SUPABASE_SERVICE_ROLE_KEY:?}" .next/static. The :? makes the shell abort if the variable is empty, rather than letting an empty pattern match every file.

Check 2: every exposed table has RLS enabled

Per Supabase's securing your API guide, on existing projects tables created in public receive SELECT, INSERT, UPDATE and DELETE for anon and authenticated by default. Supabase is moving new projects to opt-in grants, but if your project is older, or your migrations grant access explicitly, a table without RLS is readable by anyone holding the publishable key, which is everyone.

The attacker's exploit is one request, using the key and URL from your own bundle:

curl "https://<project-ref>.supabase.co/rest/v1/profiles?select=*" \
  -H "apikey: <publishable key from the site's JavaScript>"

Run this in the Supabase SQL editor to list tables in public with RLS off:

select c.relname as table_name
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
  and c.relkind in ('r', 'p')
  and not c.relrowsecurity;

Every row in that result needs alter table public.<name> enable row level security; followed by policies. Enabling RLS with no policies is the safe failure mode: the RLS guide notes that "no data is accessible through the API when using a publishable key, until you create policies." Your app will break loudly, which is better than leaking quietly.

The Security Advisor in the dashboard flags the same condition as lint 0013_rls_disabled_in_public, along with 0008_rls_enabled_no_policy for the opposite case. Check it after every migration, not once.

Check 3: policies that exist but don't restrict anything

A lock icon on the table means RLS is on. It says nothing about whether the policies are any good. Agents produce three recurring weak policies.

The policy that allows everything. When a query fails, using (true) makes it pass:

create policy "Enable read access for all users"
  on public.profiles for select
  using (true);

No to clause, so it applies to anon too. Every profile is public.

The update policy with no with check. using decides which rows you may touch; with check decides what the row may look like afterwards. Without it, a user can update their own row in ways you never intended. And if the row has a role or plan column, "update own row" means "promote yourself":

// from the browser console, signed in as an ordinary user
await supabase.from("profiles").update({ role: "admin" }).eq("id", user.id);

The policy that trusts user metadata. auth.jwt() -> 'user_metadata' comes from raw_user_meta_data, which users can edit themselves. The RLS guide warns that a policy relying on the user_metadata claim "can create security issues." Authorization data belongs in app_metadata or in your own table that users cannot write.

Here is the version that holds:

alter table public.profiles enable row level security;

create policy "profiles: read own"
  on public.profiles for select
  to authenticated
  using ((select auth.uid()) = id);

create policy "profiles: update own"
  on public.profiles for update
  to authenticated
  using ((select auth.uid()) = id)
  with check ((select auth.uid()) = id);

-- RLS is row-level. Stop users editing privileged columns with a column grant.
revoke update on public.profiles from authenticated;
grant update (full_name) on public.profiles to authenticated;

Wrapping auth.uid() in (select ...) is Supabase's recommended form: Postgres evaluates it once per statement rather than once per row. Naming the role with to authenticated keeps the policy from silently applying to anon. And remember that an UPDATE needs a matching SELECT policy to work at all, so an agent that "fixes" a failing update by loosening the select policy is undoing your read restriction.

To find permissive policies across the schema:

select tablename, policyname, roles, cmd, qual, with_check
from pg_policies
where schemaname = 'public'
  and (qual = 'true' or with_check = 'true');

The advisor equivalents are 0024_permissive_rls_policy and 0015_rls_references_user_metadata.

Check 4: views and functions that skip RLS entirely

You can have perfect policies on every table and still leak through objects that do not evaluate them.

Views. The RLS guide states that views "bypass RLS by default because they are usually created with the postgres user." An agent asked for "a view of documents with author names" writes a plain create view, and that view returns every row of every table it joins. On Postgres 15 and later, make views run with the caller's permissions:

create view public.document_titles
  with (security_invoker = true)
as select id, title from public.documents;

List views in public that still run as their owner:

select c.relname as view_name
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
  and c.relkind = 'v'
  and not coalesce(
    'security_invoker=true' = any(c.reloptions)
    or 'security_invoker=on' = any(c.reloptions),
    false);

security definer functions. A function marked security definer runs as its owner, typically with RLS bypassed. Agents reach for it when an RPC "doesn't have permission." Postgres grants EXECUTE on new functions to PUBLIC by default, so unless you revoke it, anyone with the publishable key can call it through /rest/v1/rpc/<name>. If the function genuinely needs elevated rights, lock it down:

create function public.admin_stats()
returns int
language sql
security definer
set search_path = ''
as $$ select count(*)::int from public.documents $$;

revoke execute on function public.admin_stats() from public, anon, authenticated;

Then call it only from server code with the secret key, or check the caller's role inside the function. Advisor lints 0010_security_definer_view, 0028_anon_security_definer_function_executable and 0011_function_search_path_mutable cover this ground.

Storage. Buckets are governed by policies on storage.objects. A public bucket makes every file readable by URL; uploads still need a policy. The storage access control docs show the per-user folder pattern, comparing (storage.foldername(name))[1] to the caller's ID. If your agent's upload policy only checks bucket_id, any signed-in user can overwrite anyone's avatar.

Check 5: server code that trusts the cookie

RLS protects the Data API. Your Next.js server code is a second path to the database, and it has its own failure modes.

The first is getSession(). Supabase's Next.js server-side auth guide says: "Never trust supabase.auth.getSession() inside server code such as Proxy. It reads the session out of the cookie without revalidating it." Use getClaims(), which verifies the JWT signature (locally against the JWKS when the project uses asymmetric signing keys, otherwise by calling the Auth server), or getUser() when you need fresh user data.

// src/app/(app)/projects/actions.ts
"use server";
import { createClient } from "@/lib/supabase/server";

export async function renameProject(projectId: string, name: string) {
  const supabase = await createClient();
  const { data, error } = await supabase.auth.getClaims();
  if (error || !data) throw new Error("Unauthorized");

  // The user-scoped client sends the user's JWT, so RLS still applies.
  const { error: updateError } = await supabase
    .from("projects")
    .update({ name })
    .eq("id", projectId)
    .eq("owner_id", data.claims.sub);
  if (updateError) throw updateError;
}

The second failure is quieter: using the admin client in a Server Action or Route Handler "because RLS was getting in the way." Once you use the secret key, RLS is off for that query, and the ownership check is entirely up to your code. If the agent writes admin.from("projects").delete().eq("id", projectId) with no owner_id filter, any signed-in user can delete any project by ID. Server Actions are reachable by direct POST regardless of which page renders them; our guide to finding the route your AI forgot to protect covers how to inventory them. And a check that runs but whose result is ignored is just as open, as several advisories in this week's fail-open authorization bugs showed.

The rule: default to the user-scoped client in server code, so RLS keeps working. Reserve the secret key for jobs that genuinely act on behalf of the system, such as webhooks and cron, and give each one an explicit authorization check.

# server code reading the unverified session
grep -rnE "auth\.getSession\(" src --include='*.ts' --include='*.tsx'

# every file that can bypass RLS; each one needs a reason to exist
grep -rlE "SUPABASE_SECRET_KEY|SERVICE_ROLE_KEY|createAdminClient" src

What tools catch and what they can't

A source scanner sees your repository. That covers checks 1 and 5 well: a secret key in a "use client" file, a NEXT_PUBLIC_ secret, a getSession() call in server code. GuardVibe flags all three (npx guardvibe scan .), and so will a careful grep. What a per-file scanner misses is the transitive import from check 1, where the client file only imports a local module. server-only catches that at build time, which is why it belongs in the code rather than in a scan report.

Checks 2 through 4 live in the database. If your policies were written in the dashboard rather than in migration files, no tool reading your repo can see them. That is not a gap any scanner closes. Use Supabase's Security Advisor (or the get_advisors tool in Supabase's MCP server), run the SQL queries above, and then test the way an attacker would: sign in as a second user and try to read, update and delete the first user's rows from the browser console.

Prevention, ordered by leverage

  1. Make the safe path the default in code. One server-only admin module, one user-scoped server client, and a lint rule or review checklist that asks why any new file imports the admin client.
  2. Put RLS in migrations, next to the table. Every create table in a migration should be followed by enable row level security and its policies in the same file. Supabase's RLS docs also describe using an event trigger to enable RLS automatically on every new table.
  3. Tell the agent the model up front. A line in your rules file does more than a review comment after the fact: "Supabase tables are reached from the browser. Every table gets RLS and policies scoped with to authenticated and (select auth.uid()). Never use the secret key outside src/lib/supabase-admin.ts. Never use using (true) or security definer to fix a permission error; report it instead."
  4. Fail CI on the advisors. Run the Security Advisor after migrations and treat errors as build failures. Add a source scan to pre-commit for the code-side checks.
  5. Rotate after any exposure. If a secret key ever reached a bundle, a commit or a log, rotate it. Supabase is deprecating the legacy anon and service_role keys by the end of 2026, so if you still use them, migrating to publishable and secret keys is a good moment to rotate anyway.

The short version

In a Supabase app, the browser is a database client, so the database is where authorization lives. An agent that only sees your React components cannot see that, and its instinct when a permission error appears is to remove the permission. Audit in this order: no secret key in the bundle, RLS on every exposed table, policies that actually scope by user and protect privileged columns, no views or definer functions that route around them, and server code that verifies the JWT and keeps RLS on.

None of it takes long. The SQL queries above run in seconds, and the second-user test takes ten minutes. If you want the code-side checks on every commit, npx guardvibe hook install adds a pre-commit scan; the database side still needs the advisor and a second account.

Get the next one in your feed reader

Follow GuardVibe in your feed reader. No account, no email.