Supabase Security Checklist for AI-Built Apps (2026): The Complete Guide
Mr. Ballaz- Focus
- Supabase
- Risk
- Critical
- Stack
- Supabase
- Detection
- Ubserve Runtime Simulation
The most complete Supabase security checklist for AI-built apps. Covers RLS policies, service role exposure, storage, edge functions, auth hooks, realtime, Vault secrets, multi-tenancy, and AI-specific misconfigurations.
70% of AI-built Supabase apps ship with critical security gaps. This checklist covers every layer — from service role isolation and RLS policy logic to realtime subscriptions, auth hooks, and Vault secrets management.
A Supabase security checklist for AI-built apps must cover eleven layers: service role isolation, RLS enablement, RLS policy logic, storage bucket access, RPC execution scope, edge function authentication, JWT and auth hook integrity, realtime subscription security, secrets management with Vault, multi-tenant data isolation, and AI-specific misconfiguration patterns. Checking only some of these is not enough. CVE-2025-48757 affected 170+ production apps built with AI tools in 2025 — all through a single missing RLS policy that compiled cleanly and passed every manual code review.
This guide covers every layer in sequence, with working code examples, the failure mode each check prevents, and a complete pre-launch verification checklist at the end.
Why AI-Built Supabase Apps Are Especially Vulnerable
The security failure rate in AI-built Supabase apps is not random. It follows a pattern rooted in how large language models generate code.
AI tools optimize for code that compiles, migrates, and returns data. They do not optimize for authorization correctness — a fundamentally different problem that requires testing denied-access scenarios, not just successful ones. The result is code that works in development (where the developer is always the authenticated user) and silently leaks data in production.
The scale of this problem is documented and growing:
- 70% of Lovable-built apps shipped with RLS disabled on at least one table, according to a 2025 analysis. When RLS is off, any authenticated user can read, modify, or delete any other user's data through a standard Supabase client query.
- CVE-2025-48757 affected 170+ applications in 2025 — all through missing or misconfigured RLS in AI-generated migrations.
- The Moltbook breach in early 2025 exposed 1.5 million API authentication tokens and 35,000 email addresses within three days of launch through a misconfigured Supabase database with no row-level security.
- A study of 100 vibe-coded apps found 41% had exposed secrets or API keys, 21% had no authentication on API endpoints, and 12% had exposed Supabase credentials directly readable from the frontend JavaScript bundle.
These are not edge cases. They are the default output of AI code generation applied to Supabase without a security review layer.
Check 1: Service Role Key Isolation
This is the highest-severity check. Get it wrong and every other security measure becomes irrelevant.
What the service role key does: The service_role key bypasses all Row Level Security policies by design. It was built for server-side administrative operations — migrations, background jobs, admin dashboards. When it reaches a browser, every visitor to your app has full database access: read any table, write any row, delete any record, across all users.
How AI tools create this vulnerability: AI coding assistants frequently scaffold a single Supabase client used everywhere — both in API routes and in client components. When this client is initialized with the service role key, and then imported into a component that bundles for the browser, the key ships inside the JavaScript payload. Any user who opens DevTools → Network → Sources can read it.
Service Role Audit Checklist
- Search the entire codebase for
SUPABASE_SERVICE_ROLE_KEY. It must appear only in server-side files: API routes, server actions, server components, edge function environment variables. - Search for
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY. This must return zero results. TheNEXT_PUBLIC_prefix exposes any variable to the browser bundle. - Check every file that imports
createClientwith the service role key. Verify none of those files are also imported by client components. - Add
import 'server-only'to any file that creates a service role client. This causes a build error if the file is accidentally imported in a client context. - Before every production deploy, search your built JavaScript bundle for the service role key string:
grep -r "eyJ" .next/static(service role keys begin with a JWT-styleeyJprefix).
// ✗ Always wrong — service role key reaches the browser
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY! // bypasses all RLS
);
// ✓ Correct — isolated server-only admin client
import 'server-only'; // build error if imported in client code
const supabaseAdmin = createClient(
process.env.SUPABASE_URL!, // no NEXT_PUBLIC_ prefix
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
// ✓ Correct — public client for browser use
// Uses anon key only, respects RLS
const supabaseClient = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
New API key format (2025): Supabase introduced sb_publishable_xxx (replaces anon) and sb_secret_xxx (replaces service_role) key formats. The naming convention itself signals intent — publishable keys are safe to expose, secret keys are not. Migrate to the new format and use the naming as an enforcement mechanism in code reviews.
Check 2: Row Level Security — Enablement
RLS is Supabase's primary data authorization mechanism. It is disabled by default on all tables.
Why this matters: When RLS is disabled on a table, any client with your anon key can query, modify, or delete every row in that table — regardless of which user is authenticated. This is not a misconfiguration in the traditional sense. It is the default behavior Supabase ships with, and AI tools frequently do not enable it during scaffolding.
RLS Enablement Checklist
- Open the Supabase Dashboard → Table Editor. Every table that contains user data, business data, payment information, or any row that belongs to a specific user must have the RLS toggle enabled.
- Run this query in the SQL Editor to find all tables without RLS:
-- Find all tables with RLS disabled in the public schema
SELECT
schemaname,
tablename,
rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
AND rowsecurity = false
ORDER BY tablename;
- For tables where RLS is intentionally disabled (lookup tables, public reference data), document the decision explicitly in a migration comment.
- Remember: enabling RLS with zero policies blocks all access — including your own authenticated queries. You must add at least one policy after enabling RLS, or all queries will return empty results.
Check 3: RLS Policy Logic — The Hardest Part
This is where AI-built apps fail most often. A policy can compile, migrate cleanly, run without errors, and still authorize the wrong actor.
The three failure patterns in AI-generated RLS policies:
Failure 1: Missing auth.uid() scope on a join
-- ✗ AI-generated: compiles and runs, but authorizes every authenticated user
-- Any authenticated user can read any team's documents
CREATE POLICY "users can read team docs"
ON team_documents FOR SELECT
USING (
team_id IN (
SELECT team_id FROM memberships
-- Missing: WHERE user_id = auth.uid()
)
);
-- ✓ Correct: scoped to the current authenticated user's memberships only
CREATE POLICY "users can read own team docs"
ON team_documents FOR SELECT
USING (
team_id IN (
SELECT team_id FROM memberships
WHERE user_id = auth.uid()
)
);
Failure 2: USING used alone on UPDATE (bypasses write validation)
-- ✗ Wrong: USING alone on UPDATE only checks which rows the user can see
-- It does not validate that the modified data stays within their scope
CREATE POLICY "users update their profile"
ON profiles FOR UPDATE
USING (auth.uid() = user_id);
-- ✓ Correct: USING checks read access, WITH CHECK validates the written data
CREATE POLICY "users update their profile"
ON profiles FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
-- Both clauses required: USING to identify the row, WITH CHECK to validate the new values
Failure 3: null equality for unauthenticated users
-- ✗ Silently fails for logged-out users — null = user_id is always false
-- No error thrown; just returns no rows and appears to work
CREATE POLICY "authenticated users see own data"
ON records FOR SELECT
USING (auth.uid() = user_id);
-- ✓ Explicit null check — makes the failure mode visible
CREATE POLICY "authenticated users see own data"
ON records FOR SELECT
TO authenticated -- explicitly target the authenticated role
USING (auth.uid() IS NOT NULL AND auth.uid() = user_id);
RLS Policy Audit Checklist
- Every
SELECTpolicy on a user-owned table must includeWHERE user_id = auth.uid()or equivalent direct ownership check. - Every
UPDATEandINSERTpolicy must include bothUSINGandWITH CHECKclauses. - Every policy should specify an explicit
TOrole (TO authenticatedorTO anon) rather than relying on defaults. - Test every policy with a denied-access scenario: query the table using a JWT token for a different user and verify zero rows are returned.
- Use Supabase Studio's User Impersonation feature to test policies as specific users without writing custom test scripts.
- Check that your SQL Editor is not being used to verify policy correctness — the SQL Editor bypasses RLS entirely and will always return data.
RLS Performance: What AI Code Gets Wrong
Unoptimized RLS policies can slow queries by 10–100x. This is a security issue as much as a performance issue — slow policies create pressure to disable RLS.
-- ✗ Slow: auth.uid() called per-row (not wrapped in SELECT)
CREATE POLICY "users see own rows"
ON large_table FOR SELECT
USING (user_id = auth.uid());
-- ✓ Fast: wrapping in SELECT forces a single evaluation
CREATE POLICY "users see own rows"
ON large_table FOR SELECT
USING (user_id = (SELECT auth.uid()));
-- ✓ Always add an index on the column used in the policy
CREATE INDEX ON large_table(user_id);
Supabase's documented performance improvements from optimization:
| Optimization | Performance Gain |
|---|---|
| Index on policy column | Up to 99.94% |
Wrap auth.uid() in SELECT |
94–99.99% |
Add TO authenticated role |
99.78% |
| Client-side filter matching policy | 94.74% |
| Security-definer function for complex logic | Up to 99.993% |
Check 4: The user_metadata Authorization Trap
This is the most commonly misused Supabase auth feature in AI-generated code, and it creates a direct privilege escalation vulnerability.
The vulnerability: raw_user_meta_data (accessible as auth.jwt() -> 'user_metadata' in RLS policies) is user-writable. Any authenticated user can modify their own metadata using the Supabase client SDK:
// Any authenticated user can run this — no admin rights required
await supabase.auth.updateUser({
data: { role: 'admin', plan: 'enterprise' }
})
If your RLS policies or server-side authorization checks use user_metadata to determine role or plan, every user can escalate their own privileges.
What to use instead:
-- ✗ User-writable — exploitable
CREATE POLICY "admins can read all"
ON admin_data FOR SELECT
USING (
(auth.jwt() -> 'user_metadata' ->> 'role') = 'admin'
);
-- ✓ Server-writable only — set via admin client or auth hook
CREATE POLICY "admins can read all"
ON admin_data FOR SELECT
USING (
(auth.jwt() -> 'app_metadata' ->> 'role') = 'admin'
-- app_metadata can only be modified via the service role or auth hooks
);
-- ✓ Best practice: join a dedicated roles table, do not trust JWT claims for critical authorization
CREATE POLICY "admins can read all"
ON admin_data FOR SELECT
USING (
EXISTS (
SELECT 1 FROM user_roles
WHERE user_id = auth.uid()
AND role = 'admin'
)
);
user_metadata Audit Checklist
- Search your codebase for
user_metadatain RLS policy definitions: any occurrence is a potential privilege escalation. - Search for
auth.jwt() -> 'user_metadata'in SQL files and migrations. - Replace all authorization logic using
user_metadatawith eitherapp_metadata(set server-side only) or a dedicateduser_roles/user_planstable. - If you use
app_metadatafor roles, confirm it is only set via the service role client or a Custom Access Token Hook — never viasupabase.auth.updateUser().
Check 5: Custom Access Token Hooks and RBAC
Supabase Auth Hooks allow you to inject custom claims into JWTs before they are issued. This is the correct pattern for RBAC — but it has security requirements that AI tools miss.
How it works: The Custom Access Token Hook runs a Postgres function every time a JWT is issued. You can query your user_roles table and inject the role into app_metadata, making it available in RLS policies as auth.jwt() -> 'app_metadata' ->> 'role'.
-- Custom Access Token Hook: inject role from database into JWT
CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
RETURNS jsonb
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
claims jsonb;
user_role text;
BEGIN
-- Fetch the user's role from a controlled table
SELECT role INTO user_role
FROM public.user_roles
WHERE user_id = (event ->> 'user_id')::uuid;
claims := event -> 'claims';
-- Inject into app_metadata (not user_metadata)
IF user_role IS NOT NULL THEN
claims := jsonb_set(
claims,
'{app_metadata, role}',
to_jsonb(user_role)
);
END IF;
RETURN jsonb_set(event, '{claims}', claims);
END;
$$;
-- Grant execute permission to supabase_auth_admin only
GRANT EXECUTE ON FUNCTION public.custom_access_token_hook TO supabase_auth_admin;
REVOKE EXECUTE ON FUNCTION public.custom_access_token_hook FROM authenticated, anon, public;
Security requirements for auth hooks:
- The hook function must be
SECURITY DEFINERor run with restricted grants — never callable byauthenticatedoranonroles directly. - Only query tables that are protected by RLS or readable only via service role.
- The
STABLEattribute is important for performance — the function should not have side effects. - Test by decoding the JWT on the client and verifying the injected claims match the database state.
Auth Hook Audit Checklist
- Confirm your auth hook function has
REVOKE EXECUTE FROM authenticated, anon, public. - Confirm roles injected by the hook originate from a server-controlled table, not from
user_metadata. - Test that a role change in the database is reflected in new JWTs after the user signs in again.
- Test that a user cannot inject a role claim by calling
supabase.auth.updateUser().
Check 6: Supabase Auth Configuration Security
AI tools scaffold the Supabase Auth configuration for speed, not security. Several defaults are insecure for production.
Email & OTP Security
- Email confirmations off by default: AI-scaffolded apps frequently ship without email confirmation enabled. This means anyone can create accounts with fabricated email addresses. Enable email confirmation in Authentication → Providers → Email.
- OTP expiry too long: The default OTP expiry is 24 hours. Reduce it to 3600 seconds (1 hour) or lower in Authentication → Configuration.
- Custom SMTP not configured: Supabase's default email provider rate-limits and does not use your domain. Set up your own SMTP (SendGrid, AWS SES, Resend) to control deliverability and avoid your auth emails going to spam.
OAuth & Social Login Security
- For each OAuth provider enabled, confirm the redirect URLs are restricted to your actual domain — not wildcards like
*orhttp://localhost. - Check that OAuth callback routes validate the
stateparameter to prevent CSRF during the OAuth flow. - Test that disabling a social provider in the dashboard prevents existing OAuth users from re-authenticating (it does not automatically revoke existing sessions).
Password & Session Security
- Set a minimum password length of at least 8 characters (12+ recommended). Supabase defaults to 6.
- Enable "Leaked password protection" in the Auth settings — this checks passwords against the HaveIBeenPwned database.
- Configure session duration appropriately for your risk model. Supabase defaults to very long sessions.
- Enable MFA for your Supabase project account. Any team member with dashboard access can modify auth configuration, RLS policies, and storage settings.
Supabase Auth Audit Checklist
- Email confirmations: enabled
- OTP expiry: 3600 seconds or lower
- Custom SMTP: configured
- OAuth redirect URLs: restricted to production domain
- Password minimum length: 8+ characters
- Leaked password protection: enabled
- Project MFA: enforced for all dashboard users
Check 7: Storage Bucket Security
AI tools scaffold file upload flows without separating public marketing assets from private user files. The default bucket configuration in most scaffolded apps is public.
The failure mode: A storage bucket set to Public means any URL is accessible without authentication or a signed URL. User invoices, profile photos, export files, and uploaded documents are freely downloadable by anyone who guesses or discovers the URL pattern.
Storage Security Checklist
- Set every storage bucket to Private unless it exclusively holds intentionally public content (logos, marketing images, open documentation).
- For private buckets, require signed URLs with short expiry (15 minutes for downloads, shorter for sensitive documents):
// Generate a signed URL with 15-minute expiry
const { data, error } = await supabaseAdmin.storage
.from('user-documents')
.createSignedUrl(`${userId}/${fileName}`, 900); // 900 seconds = 15 minutes
- Create storage RLS policies that scope access to the file owner. The standard pattern uses folder structure to encode
user_id:
-- Users can only read/write files in their own folder (/{user_id}/*)
CREATE POLICY "users access own files only"
ON storage.objects FOR ALL
USING (
bucket_id = 'user-uploads'
AND auth.uid()::text = (storage.foldername(name))[1]
)
WITH CHECK (
bucket_id = 'user-uploads'
AND auth.uid()::text = (storage.foldername(name))[1]
);
- Test private bucket access without a signed URL — the response must be
403 Forbidden, not the file content. - Test that a signed URL for one user cannot be used to download a different user's file.
- Set maximum file size limits to prevent storage abuse. Do this at both the bucket level (Supabase dashboard) and in your upload handler.
- Enable virus scanning for user-uploaded files if your app accepts documents (requires a third-party integration — Supabase does not scan uploads natively).
This checklist has 15 checks. Most apps only fail 3–5 of them.
The problem with a checklist this long is that half of it may not apply to your app. You might not use pg_cron. You might not have a multi-tenant architecture. You might not have Realtime enabled. Going through all 15 checks manually — writing test queries, auditing migrations, checking bundle output — takes hours, and most of it won't surface anything.
The faster path: run an Ubserve audit first.
Ubserve scans your actual codebase and Supabase configuration, not a generic list of things that could go wrong. It finds the checks you're failing — service role key in the wrong place, an RLS policy missing auth.uid() scope, a storage bucket that's still public — and skips the ones that don't apply. Every finding comes with a plain-English explanation of what's exposed, where it is in your code, and a step-by-step prompt you can paste into your AI agent directly to fix it.
Instead of spending an afternoon on 15 checks, you spend 10 minutes on the 3 that are actually broken in your app.
Run the audit — find what's actually insecure in your app
If you prefer to work through the checklist manually, or you want to understand the reasoning behind each check before running the audit, continue below.
Check 8: RPC Functions and Database Functions
Supabase RPC functions (PostgreSQL functions callable via the PostgREST API) are a frequent source of authorization bypasses in AI-built apps.
The failure modes:
- RPC functions granted
EXECUTEtoanonwhen they should require authentication - Functions that accept a
user_idparameter and trust it instead of validating againstauth.uid() SECURITY DEFINERfunctions that escalate privileges without validating the caller- Functions that return more data than the caller should access
RPC Security Checklist
-- ✗ Trusts a client-supplied user_id — any authenticated user can query any user's data
CREATE OR REPLACE FUNCTION get_user_records(target_user_id uuid)
RETURNS json
LANGUAGE plpgsql
AS $$
BEGIN
RETURN (
SELECT json_agg(r)
FROM records r
WHERE user_id = target_user_id -- ← client controls this value
);
END;
$$;
-- ✓ Validates caller identity against auth.uid()
CREATE OR REPLACE FUNCTION get_my_records()
RETURNS json
LANGUAGE plpgsql
SECURITY DEFINER -- runs as function owner, not caller
AS $$
BEGIN
-- Validate caller is authenticated
IF auth.uid() IS NULL THEN
RAISE EXCEPTION 'Authentication required';
END IF;
RETURN (
SELECT json_agg(r)
FROM records r
WHERE user_id = auth.uid() -- ← server-enforced
);
END;
$$;
-- Revoke from public, grant only to authenticated
REVOKE EXECUTE ON FUNCTION get_my_records() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION get_my_records() TO authenticated;
- Check every function for
SECURITY DEFINER. This setting makes the function run as its owner (typicallypostgres), bypassing RLS. EverySECURITY DEFINERfunction must include explicit authorization checks at the start. - Audit
GRANT EXECUTEstatements in your migrations. Most functions should be granted only toauthenticated, not toanonorpublic. - List all callable RPC functions and verify each one:
-- List all functions accessible via PostgREST with their security settings
SELECT
p.proname AS function_name,
p.prosecdef AS security_definer,
r.rolname AS owner,
array_agg(a.rolname) AS grantees
FROM pg_proc p
JOIN pg_roles r ON r.oid = p.proowner
LEFT JOIN pg_shdepend d ON d.objid = p.oid
LEFT JOIN pg_roles a ON a.oid = d.refobjid
WHERE p.pronamespace = 'public'::regnamespace
GROUP BY p.proname, p.prosecdef, r.rolname
ORDER BY p.proname;
Check 9: Realtime Subscriptions Security
Supabase Realtime is one of the least-understood security surfaces in AI-built apps. The default configuration in most scaffolded code does not enforce RLS on subscription channels.
How Realtime security works (correctly): When a client subscribes to a table channel, Supabase Realtime can perform a security check before broadcasting each change — temporarily assuming the subscriber's JWT identity and running an internal RLS check. If the policy evaluates to true for that user, the event is sent; if not, it is withheld.
How AI tools configure it (incorrectly): AI scaffolded realtime code almost never includes the JWT authentication step. Without it, the Realtime server broadcasts changes to all subscribers regardless of RLS policies on the underlying tables.
// ✗ Unauthenticated subscription — receives all changes regardless of RLS
const subscription = supabase
.channel('public:messages')
.on('postgres_changes', { event: '*', schema: 'public', table: 'messages' }, handler)
.subscribe();
// ✓ Authenticated subscription with RLS enforcement
const {
data: { session },
} = await supabase.auth.getSession();
const subscription = supabase
.channel('authenticated:messages')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'messages',
filter: `user_id=eq.${session?.user.id}`, // client-side filter
},
handler
)
.subscribe();
// Also ensure Realtime RLS is enabled for the table in the Supabase dashboard:
// Database → Replication → Tables → Toggle RLS for each table
Realtime Security Checklist
- In the Supabase Dashboard → Database → Replication, verify that RLS is enabled for every table you expose via Realtime.
- Confirm all Realtime channel subscriptions include a valid authenticated JWT session.
- Use
filterparameters in subscriptions to scope client-side listening to the authenticated user's data (in addition to server-side RLS — defense in depth). - Test by subscribing as an unauthenticated client and verifying that changes to other users' rows are not broadcast.
- Note the known compatibility issue: when RLS blocks a row change from being broadcast, the client receives no event — it does not receive an error. Design your UI to handle silent gaps in the realtime stream.
Check 10: Edge Functions Security
Edge Functions run in a Deno environment isolated from your database, but they frequently receive the service role key as an environment variable — making their security critical.
Edge Function Authentication
// ✓ Secure edge function — validates JWT before processing
Deno.serve(async (req) => {
// Verify the Authorization header exists
const authHeader = req.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return new Response('Unauthorized', { status: 401 });
}
const token = authHeader.replace('Bearer ', '');
// Validate the token using Supabase Auth
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!,
{ global: { headers: { Authorization: `Bearer ${token}` } } }
);
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) {
return new Response('Unauthorized', { status: 401 });
}
// Process the authenticated request
// Use the user's JWT-scoped client (respects RLS)
// Only escalate to service role client when absolutely necessary
});
Edge Function Security Checklist
- Every edge function that performs data operations must validate the Authorization header before processing.
- Do not use the
SUPABASE_SERVICE_ROLE_KEYin edge functions unless the operation genuinely requires bypassing RLS. Use the JWT-scoped client (with the caller's token) for user-data operations — it automatically respects RLS. - Store any third-party API keys used inside edge functions in Supabase Vault, not as plain environment variables in the dashboard.
- Do not log request bodies or Authorization headers — these contain JWTs and potentially sensitive payloads.
- Validate webhook signatures in edge functions that receive external webhooks:
// ✓ Validate Stripe webhook signature before processing
const signature = req.headers.get('stripe-signature');
const body = await req.text();
try {
const event = stripe.webhooks.constructEvent(
body,
signature!,
Deno.env.get('STRIPE_WEBHOOK_SECRET')!
);
// Process verified event
} catch (err) {
return new Response('Invalid signature', { status: 400 });
}
Check 11: Secrets Management with Supabase Vault
AI tools hardcode secrets everywhere — in function bodies, migration files, and .env files committed to version control. Supabase Vault is the correct alternative.
What Vault is: A Postgres extension using pgsodium that stores secrets encrypted at rest using authenticated encryption. Secrets are accessible as a decrypted view within SQL functions, triggers, and webhooks — without the plaintext ever appearing in your source code or migration history.
-- Store a secret in Vault
SELECT vault.create_secret(
'my_third_party_api_key_value',
'third_party_api_key',
'API key for external service'
);
-- Use the secret in a database function (decrypted only inside SQL)
CREATE OR REPLACE FUNCTION call_external_api()
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
api_key text;
BEGIN
-- Retrieve from Vault — never hardcoded
SELECT decrypted_secret INTO api_key
FROM vault.decrypted_secrets
WHERE name = 'third_party_api_key';
-- Use api_key in pg_net HTTP call or similar
PERFORM net.http_post(
url := 'https://api.example.com/endpoint',
headers := jsonb_build_object('Authorization', 'Bearer ' || api_key),
body := '{}'::jsonb
);
END;
$$;
Secrets Management Checklist
- Search your codebase and migration files for hardcoded API keys, signing secrets, and credentials. Any found in SQL files are permanently in your migration history — rotate them immediately.
- Move all secrets used inside database functions, triggers, and pg_cron jobs to Vault.
- Check
.envfiles — confirm they are in.gitignoreand verify viagit log --all -- .envthat they were never committed. - For edge functions, use Supabase's secrets management (
supabase secrets set KEY=value) rather than hardcoding in function bodies. - Rotate secrets after any team member departure, any environment variable exposure, or any git history audit that finds a committed secret.
Check 12: pg_cron and Scheduled Jobs Security
pg_cron runs scheduled SQL jobs inside your Postgres database. AI tools that scaffold background jobs frequently do so with overly broad permissions and no authentication on any edge functions they call.
pg_cron Security Checklist
-- ✗ Wrong: pg_cron job calling an edge function with a hardcoded service role key
SELECT cron.schedule(
'nightly-cleanup',
'0 2 * * *',
$$
SELECT net.http_post(
url := 'https://project.supabase.co/functions/v1/cleanup',
headers := '{"Authorization": "Bearer hardcoded_service_key_here"}'::jsonb
);
$$
);
-- ✓ Correct: retrieve auth token from Vault
SELECT cron.schedule(
'nightly-cleanup',
'0 2 * * *',
$$
SELECT net.http_post(
url := current_setting('app.edge_function_url'),
headers := jsonb_build_object(
'Authorization',
'Bearer ' || (
SELECT decrypted_secret
FROM vault.decrypted_secrets
WHERE name = 'cron_auth_token'
)
)
);
$$
);
- List all active cron jobs and verify each one's SQL scope:
SELECT jobname, schedule, command, active
FROM cron.job
ORDER BY jobname;
- Cron jobs run as the database role they were created under — typically
postgres(superuser). Any SQL in a cron job body runs without RLS. Scope queries explicitly withWHEREclauses rather than relying on RLS. - Store all tokens used by cron jobs in Vault.
- Test that cron jobs fail safely — log errors, do not silently swallow exceptions.
Check 13: Multi-Tenant Data Isolation
Multi-tenant applications are the highest-risk Supabase architecture. A single RLS policy error leaks data across every customer in the system simultaneously.
The failure mode in AI-generated code: AI tools generate multi-tenant RLS policies that look correct in isolation but fail under real tenant switching — typically because they rely on a tenant_id stored in user_metadata (user-writable) rather than a server-verified source.
Correct Multi-Tenant RLS Pattern
-- organizations table: each user belongs to one or more organizations
CREATE TABLE memberships (
user_id uuid REFERENCES auth.users NOT NULL,
organization_id uuid REFERENCES organizations NOT NULL,
role text NOT NULL DEFAULT 'member',
PRIMARY KEY (user_id, organization_id)
);
-- Documents belong to organizations, access via verified membership
CREATE POLICY "members access org documents"
ON documents FOR SELECT
USING (
organization_id IN (
SELECT organization_id
FROM memberships
WHERE user_id = auth.uid() -- ← server-enforced, not from JWT claim
)
);
-- Index is critical for performance at scale
CREATE INDEX ON memberships(user_id);
CREATE INDEX ON documents(organization_id);
Multi-Tenant Security Checklist
- The tenant identifier (
organization_id,workspace_id, etc.) must be verified through a database join against a server-controlled memberships table — not from a JWT claim that the user can set. - Test cross-tenant data isolation explicitly: sign in as User A in Tenant 1, attempt to query data belonging to Tenant 2. Expect zero rows.
- Verify that
INSERTandUPDATEpolicies includeWITH CHECKthat validates the targetorganization_idmatches the authenticated user's membership. - For soft-delete patterns (
deleted_at), confirm RLS policies exclude soft-deleted rows — AI-generated policies frequently omit this. - When switching tenants in the UI, confirm the active JWT still gates data correctly. Tenant switches that update a cookie or local storage value without a new auth session are an authorization bypass.
Check 14: PostgREST and Direct API Exposure
Supabase exposes your Postgres tables as a REST API via PostgREST. Without RLS, this API is an open database endpoint.
PostgREST Security Checklist
- Every table in the
publicschema is accessible via PostgREST unless RLS is enabled. Re-run the RLS enablement check specifically with this in mind. - Test unauthenticated
SELECTon sensitive tables using only the anon key:
curl 'https://yourproject.supabase.co/rest/v1/users?select=*' \
-H "apikey: YOUR_ANON_KEY"
# Must return: 200 OK with empty array (RLS filters all rows)
# Not: 200 OK with all user records
- Test unauthenticated
POST(write) to sensitive tables — expect403 Forbidden. - Use Supabase's Security Advisor (Database → Security Advisor) to run automated checks for tables with RLS disabled and overly permissive policies.
- Consider moving sensitive tables to a non-public schema and explicitly granting access — by default, PostgREST only exposes the
publicschema.
Check 15: AI-Specific Misconfiguration Patterns
Beyond the standard security checks, AI-built apps carry a set of vulnerabilities that are specific to how AI coding tools generate Supabase code.
Pattern 1: Supabase MCP Server Prompt Injection
Security researcher Simon Willison documented in 2025 how AI coding assistants with Supabase MCP server access (service_role level) can be manipulated via prompt injection in database content. A malicious row value in a database table can instruct the AI to execute unintended SQL — including dropping tables or exfiltrating data.
Mitigation: Do not give AI coding assistants MCP access configured with the service role key. If using Supabase MCP for AI-assisted development, use a read-only database role scoped to non-sensitive tables.
Pattern 2: Migration History Contains Secrets
AI tools frequently generate migrations that include hardcoded credentials in SQL comments, function bodies, or seed data. These are permanently in your git history.
# Audit your migration history for potential secrets
git log --all -p -- supabase/migrations/ | grep -i \
-e "password" -e "secret" -e "api_key" -e "service_role" \
-e "sk_live" -e "pk_live" -e "eyJ"
If you find secrets in git history, rotate them immediately — squashing commits does not remove the data from all git hosting platforms.
Pattern 3: .env.local Committed
AI tools sometimes create and populate .env.local files without checking .gitignore. Verify:
git log --all -- .env .env.local .env.production
# If this returns any commits, those secrets are permanently in history
Pattern 4: Test Mode Service Role Usage Persisting to Production
AI tools scaffold testing utilities using the service role key for convenience. These utilities get copy-pasted into production code paths without the test context being removed.
Search for service role usage in non-server files:
grep -rn "service_role\|SERVICE_ROLE" \
--include="*.ts" --include="*.tsx" \
--exclude-dir=".next" \
--exclude-dir="node_modules" \
. | grep -v "server\|api\|actions\|admin"
Any result outside of server-side directories is a potential exposure.
Pattern 5: Broad authenticated Role RLS Policies
AI tools frequently generate policies that allow any authenticated user to see all data — treating "is logged in" as equivalent to "is authorized":
-- ✗ Wrong: any authenticated user can read all profiles
CREATE POLICY "authenticated users can read profiles"
ON profiles FOR SELECT
TO authenticated
USING (true); -- ← this means "all rows for all authenticated users"
-- ✓ Correct: users can only read their own profile
CREATE POLICY "users read own profile"
ON profiles FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
Search your migrations for USING (true) on non-intentionally-public tables.
The Complete Pre-Launch Supabase Security Checklist
Before shipping any Supabase-backed app, verify all of the following:
Critical (Ship Blockers)
- No service role key in client code or
NEXT_PUBLIC_environment variables - RLS enabled on all tables containing user or business data
- Every RLS policy validated with a denied-access test using a different user's JWT
- No RLS policies using
auth.jwt() -> 'user_metadata'for authorization decisions - No
USING (true)policies on non-public tables - Private storage buckets verified with 403 test on unsigned URLs
- No hardcoded secrets in migration files or function bodies
-
.envfiles confirmed absent from git history
High Priority
- RPC functions scoped to minimum required roles (
authenticated, notanon) - Every
SECURITY DEFINERfunction includes explicit auth validation - Edge functions validate Authorization header before processing
- Realtime subscriptions use authenticated clients with RLS enabled
-
UPDATEpolicies include bothUSINGandWITH CHECKclauses - Email confirmation enabled in Auth settings
- OTP expiry set to 3600 seconds or lower
Production Hardening
- Custom SMTP configured
- Secrets moved to Supabase Vault
- pg_cron job tokens sourced from Vault, not hardcoded
- Multi-tenant cross-tenant isolation tested explicitly
- Supabase Security Advisor reviewed and all critical findings resolved
- SSL enforcement enabled in Database settings
- Project MFA enforced for all team members with dashboard access
- Performance-optimized RLS (indexed policy columns,
SELECT auth.uid()wrapping)
Related Guides
If your Supabase app was built with a specific AI tool, the platform-specific guides cover the additional risks introduced by each tool's scaffolding:
- Lovable security risks before launch — RLS gaps in Lovable-generated code, exposed credentials in public repositories, and auth configuration defaults
- Cursor security risks in production apps — IDE-level risks, inline secret generation, and test utility leakage
- Pre-deploy security checklist for vibe-coded apps — platform-agnostic launch checklist covering auth, secrets, headers, and data access
The Supabase breach you want to avoid is quiet. It is a USING (true) policy on a user_files table, quietly serving every user's documents to every other user, for weeks before a customer mentions they can see someone else's invoices. It is a service role key in a utils/supabase.ts file that was imported into a client component two months ago and has been sitting in the JavaScript bundle ever since.
Run the checklist. Test the denied-access scenarios. Fix what it flags before your users find it.
— Mr. Ballaz, Founder of Ubserve
Related resources
FAQs
How do I secure a Supabase app built with AI tools like Cursor or Lovable?+
What is the biggest Supabase security mistake in AI-built apps?+
Is enabling RLS on Supabase tables enough to secure my app?+
Can user_metadata be used in Supabase RLS policies?+
Do Supabase Realtime subscriptions respect RLS policies?+
What is Supabase Vault and when should I use it?+
How do I test Supabase RLS before shipping?+
Can AI tools like Cursor or Lovable generate insecure Supabase RLS policies?+
Turn this resource into a real security check.
Review the guidance, then run Ubserve to validate whether this issue is actually exploitable in your app and get fix-ready output.