Platform Guides

Supabase Security Checklist for AI-Built Apps (2026): The Complete Guide

Mr. BallazMr. Ballaz
April 25, 202627 min read
Focus
Supabase
Risk
Critical
Stack
Supabase
Detection
Ubserve Runtime Simulation
Supabase security dashboard with RLS policy coverage and access audit annotations

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. The NEXT_PUBLIC_ prefix exposes any variable to the browser bundle.
  • Check every file that imports createClient with 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-style eyJ prefix).
// ✗ 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 SELECT policy on a user-owned table must include WHERE user_id = auth.uid() or equivalent direct ownership check.
  • Every UPDATE and INSERT policy must include both USING and WITH CHECK clauses.
  • Every policy should specify an explicit TO role (TO authenticated or TO 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_metadata in 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_metadata with either app_metadata (set server-side only) or a dedicated user_roles / user_plans table.
  • If you use app_metadata for roles, confirm it is only set via the service role client or a Custom Access Token Hook — never via supabase.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 DEFINER or run with restricted grants — never callable by authenticated or anon roles directly.
  • Only query tables that are protected by RLS or readable only via service role.
  • The STABLE attribute 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 * or http://localhost.
  • Check that OAuth callback routes validate the state parameter 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:

  1. RPC functions granted EXECUTE to anon when they should require authentication
  2. Functions that accept a user_id parameter and trust it instead of validating against auth.uid()
  3. SECURITY DEFINER functions that escalate privileges without validating the caller
  4. 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 (typically postgres), bypassing RLS. Every SECURITY DEFINER function must include explicit authorization checks at the start.
  • Audit GRANT EXECUTE statements in your migrations. Most functions should be granted only to authenticated, not to anon or public.
  • 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 filter parameters 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_KEY in 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 .env files — confirm they are in .gitignore and verify via git log --all -- .env that 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 with WHERE clauses 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 INSERT and UPDATE policies include WITH CHECK that validates the target organization_id matches 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 public schema is accessible via PostgREST unless RLS is enabled. Re-run the RLS enablement check specifically with this in mind.
  • Test unauthenticated SELECT on 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 — expect 403 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 public schema.

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
  • .env files confirmed absent from git history

High Priority

  • RPC functions scoped to minimum required roles (authenticated, not anon)
  • Every SECURITY DEFINER function includes explicit auth validation
  • Edge functions validate Authorization header before processing
  • Realtime subscriptions use authenticated clients with RLS enabled
  • UPDATE policies include both USING and WITH CHECK clauses
  • 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)

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:


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?+
Run six checks before launch: confirm the service role key is server-only, enable RLS on every table with user or business data, validate each RLS policy with a deny test using a different user token, set storage buckets to private, restrict RPC and edge function execution scope, and verify JWT custom claims cannot be set by the client. Then scan with Ubserve to catch what manual review misses.
What is the biggest Supabase security mistake in AI-built apps?+
Using the service role key in frontend code or in a NEXT_PUBLIC_ environment variable. The service role bypasses all RLS policies by design. Any page that loads this key gives every anonymous visitor admin-level database access — read, write, and delete across all tables.
Is enabling RLS on Supabase tables enough to secure my app?+
No. RLS must be backed by correct policy logic. The most dangerous failure is a policy that compiles and runs but authorizes the wrong actor — typically because it is missing a WHERE user_id = auth.uid() scope on a join. Always test each policy with a denied-access scenario using a token from a different user.
Can user_metadata be used in Supabase RLS policies?+
No. raw_user_meta_data is user-writable — authenticated users can modify it through the Supabase client SDK. Using it in RLS policies means users can manipulate their own authorization claims. Use raw_app_meta_data (set server-side only) or a dedicated roles table instead.
Do Supabase Realtime subscriptions respect RLS policies?+
Only if Realtime is configured with RLS enforcement enabled and the subscribing client authenticates with a valid JWT. Without this, Realtime broadcasts changes to all subscribers regardless of RLS. AI-generated realtime setup almost never includes this configuration.
What is Supabase Vault and when should I use it?+
Vault is a Postgres extension that stores encrypted secrets in your database using authenticated encryption. Use it for any secret referenced inside database functions, triggers, pg_cron jobs, or edge functions — API keys, webhook signing secrets, third-party credentials. Never hardcode these values in function bodies.
How do I test Supabase RLS before shipping?+
Test three scenarios for every protected table: an unauthenticated request with the anon key (expect rejection or empty result), an authenticated request as the correct user (expect owned data), and an authenticated request as a different user (expect zero rows). Use Supabase Studio's user impersonation feature or write pgTAP unit tests.
Can AI tools like Cursor or Lovable generate insecure Supabase RLS policies?+
Yes, routinely. CVE-2025-48757 confirmed 170+ production apps with exploitable RLS gaps traced directly to AI-generated SQL. AI models optimize for syntactically correct, runnable code — not for authorization correctness. The policy passes the migration, the app works in development, and the data leak is invisible until someone exploits it.
Next step

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.