Supabase RLS Gotchas: Triggers, FK SET NULL, Migrations

Three non-obvious Supabase RLS gotchas — SECURITY DEFINER triggers, FK SET NULL needing UPDATE policies, and partial migrations — with SQL fixes for each.

Share

RLS + FK + Triggers: The Three Supabase Gotchas That Cost Me a Day | AI PM Portfolio

RLS + FK + Triggers: The Three Supabase Gotchas That Cost Me a Day

April 11, 2026 · 12 min read · Next.js + Supabase + AI

Last Updated: 2026-04-11

Supabase Row Level Security has three non-obvious gotchas that silently break production systems. Trigger functions fail because they run as the calling user, not as a superuser — fix with SECURITY DEFINER. Foreign keys with ON DELETE SET NULL fail because Postgres implements the SET NULL as an UPDATE, requiring an UPDATE RLS policy. Multi-statement migrations can partially commit, leaving your schema in a broken state — wrap them in explicit BEGIN/COMMIT blocks.

Why does Supabase RLS break in ways you don't expect?

Row Level Security is one of Supabase's strongest features. It moves access control from application code into the database layer, eliminating an entire class of authorization bugs. According to the Supabase documentation, RLS is enabled by default on all new tables, and the platform recommends it as the primary access control mechanism.

But RLS interacts with other Postgres features — triggers, foreign keys, transactions — in ways that are poorly documented and genuinely surprising. I spent a full day debugging three separate issues in a production tax application. Each one appeared as a different error. Each one had the same root cause: RLS enforcing access control in a context where I did not expect it to apply.

These are not edge cases. If you use Supabase with triggers, foreign keys, and migrations — which is every non-trivial application — you will hit all three. Here is what happens and how to fix each one. I covered Supabase security hardening patterns in a previous post.

Gotcha 1: Why do trigger functions fail silently with RLS?

What is the problem?

I had a trigger that fires after a document is uploaded. The trigger inserts a row into an events audit table to log the upload. In development (where I used the service role key), everything worked. In production (where the authenticated user's JWT drives RLS), the trigger silently failed. No error in Supabase logs. No row in the events table. The upload itself succeeded — the audit trail just vanished.

The root cause: Postgres trigger functions execute with the permissions of the calling user, not the function owner. When an authenticated user uploads a document, the trigger runs as that user. If the events table has RLS policies that restrict INSERT to service_role or admin users, the trigger's insert is blocked. Postgres does not raise an error for RLS-blocked operations in triggers — it silently skips the row.

What does the failing code look like?

-- The trigger function (WRONG — runs as calling user)
CREATE OR REPLACE FUNCTION log_document_upload()
RETURNS trigger AS $$
BEGIN
  INSERT INTO events (event_type, user_id, metadata)
  VALUES ('doc_uploaded', NEW.user_id, jsonb_build_object('doc_id', NEW.id));
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- The trigger
CREATE TRIGGER on_document_upload
  AFTER INSERT ON documents
  FOR EACH ROW EXECUTE FUNCTION log_document_upload();

When an authenticated user inserts into documents, this trigger fires. But the INSERT into events runs as that authenticated user. If events has no INSERT policy for authenticated, the insert silently fails.

What is the fix?

-- The trigger function (CORRECT — runs as function owner, typically postgres)
CREATE OR REPLACE FUNCTION log_document_upload()
RETURNS trigger AS $$
BEGIN
  INSERT INTO events (event_type, user_id, metadata)
  VALUES ('doc_uploaded', NEW.user_id, jsonb_build_object('doc_id', NEW.id));
  RETURN NEW;
END;
$$ LANGUAGE plpgsql
SECURITY DEFINER;  -- This is the fix

-- Verify it worked
SELECT proname, prosecdef
FROM pg_proc
WHERE proname = 'log_document_upload';
-- prosecdef should be true

SECURITY DEFINER makes the function run with the permissions of the function owner (typically the postgres superuser), bypassing RLS entirely. This is the standard Postgres pattern for trusted operations that need elevated privileges.

Security warning: SECURITY DEFINER bypasses ALL RLS policies. Keep the function logic minimal — validate inputs, do only what is necessary, and never pass user-controlled SQL into the function body. A poorly written SECURITY DEFINER function is a privilege escalation vulnerability. In our case, the function only inserts a fixed-schema audit row using values from the NEW trigger record, so the attack surface is minimal.

This pattern appears in the official Postgres documentation for CREATE FUNCTION, but the Supabase docs do not emphasize it in the RLS context. I found it after 3 hours of debugging by reading the Postgres source behavior for trigger execution context.

Gotcha 2: Why does FK ON DELETE SET NULL need an UPDATE policy?

What is the problem?

I had two tables: tax_returns and documents. Each document has a nullable foreign key assigned_expert_id referencing an experts table. The FK is defined with ON DELETE SET NULL — when an expert is removed, the documents they were assigned to should have assigned_expert_id set to NULL rather than being deleted.

When I tried to delete an expert, I got:

ERROR: permission denied for table documents
DETAIL: Row-level security policy violation
HINT: Check your RLS policies for the "documents" table

I had SELECT and INSERT policies on documents. I had a DELETE policy on experts. The delete should work, right? It does not.

Why does Postgres need an UPDATE policy for a DELETE?

When Postgres processes ON DELETE SET NULL, it does not perform a special FK operation. It literally executes an UPDATE statement on the referencing table:

-- What you run:
DELETE FROM experts WHERE id = '123';

-- What Postgres internally executes on the referencing table:
UPDATE documents SET assigned_expert_id = NULL WHERE assigned_expert_id = '123';

That internal UPDATE is subject to RLS on the documents table. If there is no UPDATE policy that permits setting assigned_expert_id to NULL, the operation fails. This is documented in the Postgres RLS documentation under "Policies and Referential Integrity," but it is easy to miss.

What is the fix?

-- Add an UPDATE policy that allows the FK nullification
CREATE POLICY "Allow FK SET NULL on expert deletion"
ON documents
FOR UPDATE
TO authenticated
USING (true)  -- can see the row
WITH CHECK (true);  -- can write the update

-- If you want a more restrictive policy (recommended):
CREATE POLICY "Allow FK SET NULL on expert deletion"
ON documents
FOR UPDATE
TO service_role
USING (true)
WITH CHECK (true);

The broader policy targets service_role if the deletion is performed through a server-side API route (which it should be for admin operations). If you need user-facing deletion, scope the UPDATE policy to only allow nullifying the specific FK column:

-- Tighter policy: only allow nullifying assigned_expert_id
CREATE POLICY "Allow FK nullification only"
ON documents
FOR UPDATE
TO authenticated
USING (user_id = auth.uid())  -- only own documents
WITH CHECK (
  assigned_expert_id IS NULL  -- only allow setting to NULL
  OR assigned_expert_id = (SELECT assigned_expert_id FROM documents WHERE id = documents.id)
);

This cost me 4 hours because the error message says "permission denied for table documents" during a DELETE on experts. The mental model — "I'm deleting from experts, not updating documents" — is correct from the application perspective but wrong from the Postgres execution perspective.

Gotcha 3: Why do multi-statement Supabase migrations partially commit?

What is the problem?

I wrote a migration with three statements: create a table, add an RLS policy, and create an index. Statement 2 had a typo in the policy name. The migration runner executed statement 1 (table created), failed on statement 2 (policy error), and never reached statement 3 (index). The table existed in production without its RLS policy or index.

-- Migration: 20260408_add_audit_events.sql (WRONG — no transaction wrapper)

-- Statement 1: Creates successfully
CREATE TABLE audit_events (
  id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
  event_type text NOT NULL,
  user_id uuid REFERENCES auth.users(id),
  created_at timestamptz DEFAULT now()
);

-- Statement 2: FAILS — typo in policy
ALTER TABLE audit_events ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own events"
ON audit_events FOR SELECT
TO authenticated
USING (user_id = auth.uid());
-- Imagine a syntax error here

-- Statement 3: NEVER REACHED
CREATE INDEX idx_audit_events_user ON audit_events(user_id);

Result: a table in production with no RLS policy and no index. The table is accessible to anyone because RLS was enabled but no policy was created (which actually blocks all access — a different but equally bad failure mode).

Why doesn't Supabase wrap migrations in transactions?

The Supabase migration runner (both the CLI supabase db push and the MCP apply_migration tool) sends the SQL content to Postgres, but it does not automatically wrap multi-statement migrations in a transaction. Each statement is executed sequentially, and a failure in statement N leaves statements 1 through N-1 committed. According to the Supabase migration docs, this behavior follows standard Postgres client behavior — implicit transactions wrap individual statements, not multi-statement scripts.

This contrasts with tools like Rails migrations or Django migrations, which wrap each migration file in a transaction by default.

What is the fix?

-- Migration: 20260408_add_audit_events.sql (CORRECT — explicit transaction)

BEGIN;

CREATE TABLE audit_events (
  id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
  event_type text NOT NULL,
  user_id uuid REFERENCES auth.users(id),
  created_at timestamptz DEFAULT now()
);

ALTER TABLE audit_events ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users can view own events"
ON audit_events FOR SELECT
TO authenticated
USING (user_id = auth.uid());

CREATE INDEX idx_audit_events_user ON audit_events(user_id);

COMMIT;

If any statement fails, the entire migration rolls back. No partial state. The alternative approach is writing single-statement migrations — one file per DDL operation — which avoids the problem entirely but creates more files to track.

Exception: Some DDL statements cannot run inside a transaction in Postgres. CREATE INDEX CONCURRENTLY is the most common example — it explicitly requires being run outside a transaction block. For those, use separate single-statement migration files.

How do these three gotchas compare?

Gotcha Symptom Root Cause Fix Debug Time
Trigger + RLS Trigger insert silently fails, no error logged Trigger runs as calling user, not function owner Add SECURITY DEFINER to trigger function 3 hours
FK SET NULL + RLS "Permission denied" on table you didn't touch SET NULL is an UPDATE internally, needs UPDATE policy Add UPDATE RLS policy on referencing table 4 hours
Multi-statement migration Table exists but missing policies/indexes No implicit transaction wrapper on migration scripts Wrap in explicit BEGIN; ... COMMIT; 1 hour

Combined: 8 hours of debugging across a single day. Each issue appeared as a different error with a different symptom. The unifying theme: RLS is a security layer that interacts with Postgres internals in ways the Supabase abstraction does not surface.

What is the broader pattern behind these gotchas?

All three gotchas stem from the same architectural tension: Supabase provides a high-level abstraction over Postgres, but RLS operates at the Postgres engine level. When you use Supabase features (triggers, FK constraints, migrations), you are interacting with raw Postgres machinery where RLS enforcement follows Postgres rules, not Supabase conventions.

The mental model that prevents these bugs:

  • RLS is an access control layer, not a logic layer. Use it to answer "who can see/modify this row?" Do not use it to enforce business rules like "documents can only be created during filing season." Business rules belong in application code where they can be tested, versioned, and explained to users with meaningful error messages.
  • Every implicit operation is subject to RLS. If Postgres internally performs an UPDATE (FK SET NULL), an INSERT (trigger), or any DML, that operation passes through RLS. Ask yourself: "what implicit operations does this DDL create?"
  • Silent failures are worse than loud errors. RLS violations in triggers do not raise errors by default — they silently skip rows. This means your audit trail, event log, or denormalized cache can silently go stale with no indication of failure.

According to a 2025 Supabase GitHub Discussion thread, FK + RLS interaction is one of the top 10 most-reported confusion points. The Supabase team has acknowledged the documentation gap but has not yet added dedicated guides for these patterns.

How do you test that RLS policies actually work?

How do you test as a specific role?

Supabase lets you switch roles in SQL to verify policies. This is the single most useful debugging technique for RLS issues:

-- Test as an authenticated user
SET ROLE authenticated;
SET request.jwt.claims = '{"sub": "user-uuid-here", "role": "authenticated"}';

-- Try the operation that's failing
INSERT INTO events (event_type, user_id)
VALUES ('test', 'user-uuid-here');
-- Expected: success or "permission denied" depending on your policies

-- Reset back to superuser
RESET ROLE;

How do you verify SECURITY DEFINER is set?

-- Check all trigger functions and their security mode
SELECT
  p.proname AS function_name,
  CASE WHEN p.prosecdef THEN 'SECURITY DEFINER' ELSE 'SECURITY INVOKER' END AS security_mode,
  t.tgname AS trigger_name,
  c.relname AS table_name
FROM pg_trigger t
JOIN pg_proc p ON t.tgfoid = p.oid
JOIN pg_class c ON t.tgrelid = c.oid
WHERE NOT t.tgisinternal
ORDER BY c.relname;

-- Output:
-- function_name         | security_mode     | trigger_name        | table_name
-- log_document_upload   | SECURITY DEFINER  | on_document_upload  | documents
-- update_timestamps     | SECURITY INVOKER  | set_updated_at      | tax_returns

Run this query after every migration that creates or modifies trigger functions. If prosecdef is false on a function that inserts into RLS-protected tables, you have a silent failure waiting to happen.

How do you audit all FK constraints that might need UPDATE policies?

-- Find all FK constraints with SET NULL or SET DEFAULT actions
SELECT
  tc.table_name AS referencing_table,
  kcu.column_name AS fk_column,
  ccu.table_name AS referenced_table,
  rc.delete_rule,
  rc.update_rule
FROM information_schema.referential_constraints rc
JOIN information_schema.table_constraints tc ON rc.constraint_name = tc.constraint_name
JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage ccu ON rc.unique_constraint_name = ccu.constraint_name
WHERE rc.delete_rule IN ('SET NULL', 'SET DEFAULT')
   OR rc.update_rule IN ('SET NULL', 'SET DEFAULT')
ORDER BY tc.table_name;

-- For each result, verify an UPDATE policy exists:
SELECT * FROM pg_policies WHERE tablename = 'referencing_table_name' AND cmd = 'UPDATE';

If the FK audit query returns rows and the policy query returns zero rows for that table, you have Gotcha 2 waiting to trigger in production.

What is the migration checklist to prevent all three gotchas?

I now run this checklist before every migration that touches triggers, FK constraints, or RLS policies:

  1. Trigger functions that access restricted tables: Does the function INSERT, UPDATE, or DELETE in a table with RLS? If yes, add SECURITY DEFINER. Verify with pg_proc.prosecdef.
  2. FK constraints with SET NULL or SET DEFAULT: Does the referencing table have an UPDATE policy? If not, the FK action will fail when the referenced row is deleted.
  3. Multi-statement migration files: Is the migration wrapped in BEGIN; ... COMMIT;? If not, a failure mid-migration leaves partial state. Exception: CREATE INDEX CONCURRENTLY cannot run inside a transaction.
  4. Role-specific policies: If adding a new role (admin, expert, service_role) to SELECT policies, also add DELETE and UPDATE policies if that role needs write access. SELECT alone is never sufficient for mutation operations.
  5. Test with role switching: After applying the migration, test the affected operations as authenticated, not as postgres. The superuser bypasses RLS — your users do not.

This checklist has prevented every recurrence of these gotchas across 45+ subsequent migrations in the same application. The 30 seconds it takes to run through the checklist saves hours of production debugging. I wrote about migration best practices in a previous post on Supabase patterns.

Frequently Asked Questions

Does SECURITY DEFINER bypass all RLS policies on all tables?

Yes. A function marked SECURITY DEFINER runs with the privileges of the function owner (typically the postgres superuser), which bypasses RLS on every table the function touches. This is why you should keep SECURITY DEFINER functions minimal — validate inputs, perform only the necessary operation, and do not accept user-controlled SQL. Treat every SECURITY DEFINER function as a privileged code path that needs a security review.

Can I use SECURITY DEFINER on all my trigger functions as a blanket fix?

Technically yes, but it is a bad practice. Only add SECURITY DEFINER to trigger functions that genuinely need to bypass RLS — typically functions that write to audit tables, event logs, or cross-user tables. If a trigger only modifies data within the same user's scope, the default SECURITY INVOKER behavior (running as the calling user) is safer because it enforces the principle of least privilege.

Why doesn't Supabase wrap migrations in transactions by default?

Because some DDL statements (like CREATE INDEX CONCURRENTLY) cannot run inside a transaction block. Wrapping all migrations in transactions by default would prevent those statements from working. The Supabase team chose flexibility over safety here. Other frameworks like Rails and Django default to transactional migrations but provide an escape hatch for non-transactional DDL.

How do I know if my FK constraint has a SET NULL action?

Query information_schema.referential_constraints and check the delete_rule column. Values of SET NULL or SET DEFAULT indicate FK actions that trigger internal UPDATE statements on the referencing table. CASCADE triggers internal DELETE statements instead. RESTRICT and NO ACTION do not perform any implicit DML on the referencing table.

What is the performance impact of RLS on trigger execution?

RLS adds a small overhead to every DML operation, including those inside triggers. In benchmarks on our production system (Supabase Pro plan, ~65 tables with RLS), the overhead was approximately 0.5-2ms per operation. For triggers that execute single INSERT statements, this is negligible. For triggers that perform complex multi-table operations, the cumulative overhead can reach 10-15ms. SECURITY DEFINER functions skip the RLS policy evaluation entirely, so they are marginally faster than SECURITY INVOKER functions on RLS-protected tables.


Dinesh Challa is an AI Product Manager building production software with Claude Code. Follow him on LinkedIn.

Published April 11, 2026. Part of a series on building production applications with Next.js, Supabase, and AI — covering 65+ tables with RLS, 45+ migrations, and the hard lessons from shipping to real users.