DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • Sharing SBOMs Securely Without Giving Too Much Away
  • C/C++ Is Where Vulnerability Programs Go to Guess
  • PostgreSQL 12 End of Life: What to Know and How to Prepare
  • On SBOMs, BitBucket, and OWASP Dependency Track

Trending

  • Your Codename One App, Now A Native Mac App
  • Building a Self-Correcting GraphRAG Pipeline for Enterprise Observability
  • What Is Platform Engineering?
  • What Is Plagiarism? How to Avoid It and Cite Sources
  1. DZone
  2. Software Design and Architecture
  3. Security
  4. Fix Circular Dependencies in PostgreSQL Row-Level Security With SECURITY DEFINER Functions

Fix Circular Dependencies in PostgreSQL Row-Level Security With SECURITY DEFINER Functions

Circular RLS policy dependencies in PostgreSQL silently return no rows. Here is why it happens and how SECURITY DEFINER functions fix it.

By 
Lex Mulier user avatar
Lex Mulier
·
Jul. 20, 26 · Analysis
Likes (0)
Comment
Save
Tweet
Share
505 Views

Join the DZone community and get the full member experience.

Join For Free

Row-level security in PostgreSQL is one of the more useful features for multi-tenant applications. The idea is straightforward: define a policy on a table that tells PostgreSQL which rows a given user is allowed to see or modify, and the database engine enforces it on every query, regardless of which application code issued the request.

The trouble comes when your policies form a cycle. This is more common than it sounds, and it produces one of the more confusing failure modes in PostgreSQL: a query that should return data returns nothing, with no error.

This article walks through how circular RLS dependencies arise, why they silently eat your data, and how to break the cycle using SECURITY DEFINER functions.

How the Circular Dependency Happens

Consider a simple multi-tenant schema. You have a properties table and a property_members table that tracks which users have access to which properties:

SQL
 
CREATE TABLE public.properties (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name text NOT NULL,
  slug text UNIQUE NOT NULL
);

ALTER TABLE public.properties ENABLE ROW LEVEL SECURITY;

CREATE TABLE public.property_members (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  property_id uuid NOT NULL REFERENCES public.properties(id) ON DELETE CASCADE,
  user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  role text NOT NULL DEFAULT 'member',
  accepted_at timestamptz,
  UNIQUE(property_id, user_id)
);

ALTER TABLE public.property_members ENABLE ROW LEVEL SECURITY;


Now you write your policies. A user should be able to see a property if they are an accepted member of it:

SQL
 
CREATE POLICY "properties_select_members" ON public.properties
  FOR SELECT TO authenticated USING (
    EXISTS (
      SELECT 1 FROM property_members
      WHERE property_id = properties.id
        AND user_id = auth.uid()
        AND accepted_at IS NOT NULL
    )
  );


And a user should be able to see other members of a property if they are also a member:

SQL
 
CREATE POLICY "property_members_select_comembers" ON public.property_members
  FOR SELECT TO authenticated USING (
    EXISTS (
      SELECT 1 FROM property_members pm2
      WHERE pm2.property_id = property_members.property_id
        AND pm2.user_id = auth.uid()
        AND pm2.accepted_at IS NOT NULL
    )
  );


This looks reasonable. In fact, it compiles without error. Then you run a query, and it returns zero rows.

Why This Silently Returns Nothing

Here is the execution path PostgreSQL follows when an authenticated user queries properties:

  1. Apply properties_select_members. This requires checking property_members. 
  2. To read property_members, apply property_members_select_comembers. This requires checking property_members again. 
  3. To check property_members in step 3, apply property_members_select_comembers. This requires checking property_members again.

PostgreSQL does not raise an error here. Instead, when it detects the recursive RLS evaluation, it short-circuits and evaluates the recursive reference as returning no rows. The result is that the policy conditions that depend on property_members always see an empty set, every EXISTS(...) check returns false, and no rows are visible.

This is consistent with how PostgreSQL handles RLS recursion to prevent infinite loops, but the silent behavior makes it genuinely difficult to diagnose. You add your membership record, you enable RLS, you query your table, and you get nothing. No error message. No warning. Just an empty result.

The Fix: SECURITY DEFINER Functions

The solution is to introduce a layer of indirection. Instead of having your policies query property_members directly (which triggers RLS on that table), you wrap the membership check in a function that runs with elevated privileges and bypasses RLS entirely.

SQL
 
CREATE OR REPLACE FUNCTION public.is_property_member(p_property_id uuid, p_user_id uuid)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
  SELECT EXISTS (
    SELECT 1 FROM property_members
    WHERE property_id = p_property_id
      AND user_id = p_user_id
      AND accepted_at IS NOT NULL
  );
$$;


The SECURITY DEFINER attribute tells PostgreSQL to run the function as the user who defined it (typically a superuser or the role that owns the schema), not as the calling user. Inside the function body, RLS on property_members is not applied, because the function owner has full access.

You can add role-specific variants for the same pattern:

SQL
 
CREATE OR REPLACE FUNCTION public.is_property_admin(p_property_id uuid, p_user_id uuid)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
  SELECT EXISTS (
    SELECT 1 FROM property_members
    WHERE property_id = p_property_id
      AND user_id = p_user_id
      AND role IN ('owner', 'admin')
      AND accepted_at IS NOT NULL
  );
$$;


Now rewrite your policies to call the function instead of querying the table directly:

SQL
 
DROP POLICY IF EXISTS "properties_select_members" ON public.properties;
DROP POLICY IF EXISTS "property_members_select_comembers" ON public.property_members;

CREATE POLICY "properties_select_members" ON public.properties
  FOR SELECT TO authenticated USING (
    public.is_property_member(id, auth.uid())
  );

CREATE POLICY "property_members_select_comembers" ON public.property_members
  FOR SELECT TO authenticated USING (
    public.is_property_member(property_id, auth.uid())
  );


The cycle is broken. properties policies call is_property_member. property_members policies also call is_property_member. But is_property_member is a function that executes with SECURITY DEFINER privileges, so when PostgreSQL evaluates it, it does not apply RLS to the property_members table inside the function body. There is no loop.

Applying the Pattern Consistently

Once you have your helper functions in place, the pattern composes cleanly across your entire schema. Every table in your multi-tenant application can reference the same small set of helper functions in its policies:

SQL
 
-- Bookings: members can read, admins can write
CREATE POLICY "bookings_select" ON public.bookings
  FOR SELECT TO authenticated USING (
    public.is_property_member(property_id, auth.uid())
  );

CREATE POLICY "bookings_update" ON public.bookings
  FOR UPDATE TO authenticated USING (
    public.is_property_admin(property_id, auth.uid())
  );

-- Board posts: members can read and post, owner or author can delete
CREATE POLICY "board_posts_delete" ON public.board_posts
  FOR DELETE TO authenticated USING (
    user_id = auth.uid()
    OR public.is_property_admin(property_id, auth.uid())
  );


The policies stay readable and short. The access logic lives in one place. When your membership rules change (say, you add a new role), you update the functions rather than hunting through every policy across every table.

A Few Things to Keep in Mind

SET search_path = public in the function definition is not optional. Without it, a malicious user could create objects in a schema earlier in the search path and potentially redirect function calls. PostgreSQL's own documentation recommends this for any SECURITY DEFINER function.

Marking functions as STABLE (rather than VOLATILE, the default) lets PostgreSQL cache the result within a single query. A single SELECT that reads many rows from properties will call is_property_member once per row, and the STABLE declaration allows the planner to optimize those calls. If your membership table changes mid-transaction, this is worth thinking about, but for most access-control use cases, STABLE is the right choice.

Finally, grant EXECUTE on these functions only to the roles that need them. For a Supabase project, that typically means the authenticated role. The function runs as the owner, but you still control who can call it.

SQL
 
GRANT EXECUTE ON FUNCTION public.is_property_member(uuid, uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.is_property_admin(uuid, uuid) TO authenticated;


The circular dependency problem is a good example of why it pays to understand what your framework is doing underneath. Supabase makes RLS easy to enable. It does not protect you from cycles in the policies you write. But once you understand the pattern, the fix is clean, and it scales to a large schema without adding complexity.

Dependency security PostgreSQL

Opinions expressed by DZone contributors are their own.

Related

  • Sharing SBOMs Securely Without Giving Too Much Away
  • C/C++ Is Where Vulnerability Programs Go to Guess
  • PostgreSQL 12 End of Life: What to Know and How to Prepare
  • On SBOMs, BitBucket, and OWASP Dependency Track

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook