October DealsAmazon USOctober deal check: compare before you payAmazon US: current deals, useful picks and tech finds.Check DealsClean PCRecommendedOne scan can reveal what keeps slowing WindowsLook for cleanup and repair opportunities.Run ScanOctober DealsAmazon USDeal season is back - check today's better picksAmazon US: current deals, useful picks and tech finds.See Picks×
Skip to content
World desk8 min

My Database Is the Referee: Enforcing a Fair Exchange with Supabase RLS

Supabase RLS can make Postgres refuse operations that break your ownership and exchange rules for every client. Here is how to set grants and per-operation policies, put multi-step changes in a transaction, and test allowed and denied cases.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Supabase Row Level Security (RLS) can decide which rows a user may read, insert, update, or delete, and it applies those decisions inside Postgres no matter which client sends the query. That is what makes the database a referee. It is not a complete referee, though. RLS does not define what a fair exchange is, does not make several separate client requests succeed or fail together, and does not prove that your policies are correct. Those jobs belong to your schema, your database functions, and your tests.

What RLS enforces and what it leaves to you

Supabase describes RLS as a way to secure direct access to the database. Policies are Postgres rules attached to a table, and they are evaluated each time that table is accessed. In practice a policy works like an automatically applied filter on which rows an operation may touch, and it can also validate rows that a write is about to create or change.

As an Amazon Associate I earn from qualifying purchases.

Three things sit outside RLS and need deliberate design:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  • The fairness rule. Deciding which actor may move an exchange from one state to another, and which combinations of states are allowed, is an application decision. The database can encode that rule, but it cannot invent it.
  • Atomicity across requests. Each supabase-js call is its own request. The JavaScript client does not give you a transaction handle that spans several queries.
  • Correctness of the policies themselves. A policy that reads plausibly can still allow the wrong thing. Only tests run as real roles show what the database actually permits.

Grants and policies are two separate checks

PostgreSQL first checks whether a role has a privilege on the table at all. Only then do RLS policies decide which rows that privilege reaches. Supabase’s RLS documentation makes the point directly: adding policies does not take back table grants. A role that already has UPDATE on a table keeps it, and a policy that looks restrictive will not remove it.

This has two practical consequences. First, grant only the operations each exposed role needs. Second, check the existing privileges on older projects, where roles may already hold default table privileges. Revoking what you do not need is part of the setup, not an optional hardening step.

alter table public.listings enable row level security;

revoke all on public.listings from anon;
grant select, insert, update, delete on public.listings to authenticated;

Ownership policies: one per operation

Write a separate policy for each operation and name the role it applies to with TO. The example below follows the owner-scoped pattern in Supabase’s documentation, using a hypothetical listings table with a user_id column. It is illustrative, not a tested policy for your schema.

create policy 'owners can read own listings'
on public.listings for select to authenticated
using (auth.uid() = user_id);

create policy 'owners can insert own listings'
on public.listings for insert to authenticated
with check (auth.uid() = user_id);

create policy 'owners can update own listings'
on public.listings for update to authenticated
using (auth.uid() = user_id)
with check (auth.uid() = user_id);

create policy 'owners can delete own listings'
on public.listings for delete to authenticated
using (auth.uid() = user_id);

The clauses do different jobs:

  • USING decides which existing rows an operation may see or target.
  • WITH CHECK validates the row an insert or update would leave behind.
  • An update that needs both clauses should use both. Without the WITH CHECK clause, a user who owns a row could change its user_id to someone else.
  • Supabase notes that an update also needs a matching SELECT policy to behave as expected, because the update has to read the row it changes.

auth.uid() returns the caller’s user ID and returns null for an unauthenticated request. Because null never equals a stored ID, an unauthenticated request does not match an owner-scoped policy. Keep the policy explicit about the role anyway, so that the intent is readable to the next developer.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Encoding exchange rules without RLS doing too much

Take a simple trade between two users. A proposer creates an offer, a recipient accepts or declines it, and the proposer may withdraw it while it is still pending. The rules are: only the recipient accepts, only the proposer withdraws, and only a pending offer can change.

Row policies can express who may touch the row. They are awkward for expressing from which state to which state, because a policy on an update sees the old row through USING and the new row through WITH CHECK, but it cannot compare the two directly. Two patterns handle this cleanly:

  • Put the old state in the filter. The update matches only rows whose current status is pending. If the status has already moved on, zero rows change.
  • Use a trigger for rules that compare old and new values. A BEFORE UPDATE trigger can read both OLD and NEW and reject illegal transitions outright.

Either way, the rule lives in the database rather than in the browser.

Multi-step changes belong in a database function

When an exchange touches several rows, for example moving a balance from one user to another and marking the offer accepted, those writes must succeed or fail together. Supabase’s documented pattern for this is a Postgres function called through RPC. A function runs inside one transaction. If any statement raises an error, everything the function changed is rolled back.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
create or replace function public.accept_offer(p_offer_id bigint)
returns void
language plpgsql
security invoker
set search_path = ''
as $$
begin
  update public.trade_offers
     set status = 'accepted'
   where id = p_offer_id
     and status = 'pending'
     and recipient_id = auth.uid();

  if not found then
    raise exception 'offer is not pending, or caller is not the recipient';
  end if;

  -- further writes for the same exchange go here; any error rolls back the whole call
end;
$$;

Calling it from the client is a single request:

const { error } = await supabase.rpc('accept_offer', { p_offer_id: 42 })

Two cautions. A function declared with security invoker runs with the caller’s privileges, so RLS still applies. A function declared security definer runs with its owner’s privileges and bypasses RLS for the statements inside it. If you use that mode, the function must check auth.uid() itself for every operation it performs, and it should pin search_path as shown.

Views and privileged keys sit outside the policy path

Views deserve their own review. Supabase documents that a view bypasses the RLS of its underlying tables by default unless it is configured safely. On Postgres 15 and later, setting the view option security_invoker = true makes the view apply the caller’s permissions and policies. Confirm the Postgres version of your project before relying on that option, and check each view you expose.

Secret or service-role keys bypass RLS. Supabase’s security guidance says they must stay on trusted servers and must not appear in a frontend bundle. Publishable keys are designed for client use, and they are only as safe as the grants and policies behind them. Keep administrative scripts, background jobs, and any code that needs to bypass policies on the server, and make them perform their own authorization checks where the business rules require it.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Testing allowed and denied cases

A policy is only verified when you have seen it allow what it should and refuse what it should not. Supabase’s RLS guidance recommends testing, and it documents two routes: application-level tests that call the API with real user sessions, and SQL tests written with pgTAP and run with supabase test db. The SQL route is useful for checking policies directly, and it lets you impersonate a role and a user ID.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
begin;
select plan(3);

set local role authenticated;
select set_config('request.jwt.claims',
  '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true);

select is(
  (select count(*)::int from public.listings where user_id != '11111111-1111-1111-1111-111111111111'),
  0,
  'user cannot read another user''s listings');

select throws_ok(
  $$insert into public.listings (user_id, title) values ('22222222-2222-2222-2222-222222222222', 'x')$$,
  '42501', null,
  'user cannot insert a row for another user');

select lives_ok(
  $$insert into public.listings (user_id, title) values ('11111111-1111-1111-1111-111111111111', 'mine')$$,
  'user can insert their own row');

select * from finish();
rollback;

Expect denied updates to behave differently from denied inserts. An update that the USING clause filters out usually affects zero rows and raises no error, so the test should check that the row is unchanged rather than waiting for an exception. Denied inserts and updates that fail WITH CHECK raise an error. Write your denied-case assertions with that difference in mind.

Supabase’s own testing guidance states the principle plainly: “Until the suite passes, you don’t know whether the policies do what you intended.”

Implementation checklist

  1. List every exposed table and the database roles that touch it. Decide which operations each role actually needs.
  2. Enable RLS on each exposed table. Revoke grants you do not need, including those from anon, and check existing default privileges on older projects.
  3. Create one policy per operation, naming the role with TO. Use USING for existing rows and WITH CHECK for proposed rows, and use both on ownership-preserving updates.
  4. Write the exchange rules as state filters in update statements or as triggers, not as client-side checks.
  5. Wrap every multi-step state change in a database function called through RPC. Choose security invoker unless you have a specific reason to bypass RLS.
  6. Review views for security_invoker = true on Postgres 15 and later, and keep service-role keys out of frontend code.
  7. Test each role’s allowed and denied cases in SQL with pgTAP and, separately, through the API with real user sessions.

What is and is not established

The behavior described here comes from Supabase’s current documentation, reviewed in October 2026. The documentation pages reviewed did not carry a publication date, so confirm the details against the live docs before you rely on a specific option. The sources do not include a published statistic about how often RLS misconfigurations occur or how much they cost, so this article does not quote one. The example schema and policies are illustrative. They have not been run against any particular application, and a policy is secure only after you test it against your own roles and data.

The short answer to the title question is yes, with the limits above. RLS makes the database refuse what the rules forbid, for every client that reaches it. The exchange rules, the transaction boundary, and the test suite are what make that refusal match the fairness you actually intend.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Leave a Reply

Your email address will not be published. Required fields are marked *

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

More from the Wire

  1. World desk4 min
    How to Spot an AI Voice Scam Before Sending MoneyDon’t rely on how a caller sounds. Pause, call back through a known number, and verify the emergency with another trusted person before sending money.
  2. Mountain View desk4 min
    Google’s SynthID Detector: How to Check AI-Generated Images, Video and AudioGoogle’s SynthID Detector looks for an embedded watermark in supported images, video and audio. Here is what its results do—and do not—show.
  3. Redmond desk20 min
    How to create a link to File or Folder in Windows 11Windows 11 gives you several ways to point to a file or folder without moving or duplicating it. You can create a desktop shortcut,…
Recommended PC Tool
Recommended PC Tool
Windows Errors? Fix Them Before They SpreadFree repair scan
Crashes, No Sound, or Screen Glitches?Free driver scan

Two free Windows tools

One Free Minute Could Fix That PC

Before you go - each of these free tools takes about a minute and tackles what quietly slows a Windows PC down.

Special offer. View Outbyte info, uninstall instructions, EULA, and Privacy Policy.