Supabase RLS policies: why enabled is not the same as secure
RLS can be enabled while customers change protected prices or roles. See the Supabase permissions and write-path checks a developer-led audit should cover.
Sep 28, 2026 · 9 min read
Field Notes from Thunkle, a studio that takes AI-built apps from prototype to secure, production-ready software.
By Thunkle · Worked example, platform references checked 28 September 2026
An ownership-only Supabase RLS policy can let a customer edit their booking without protecting its price or payment status. RLS decides which rows an operation may access and can constrain proposed row values. But "RLS is enabled" does not prove the rules are correct, and "this row belongs to the customer" does not mean every field is theirs to change.
This is an illustrative worked example for a Supabase-backed rental app, not a disclosed Supabase customer incident. It shows where an ownership-only row level security policy stops, what a field-level rule looks like, and the tests that check the intended behaviour. If you are still on the basics, start with our Supabase RLS guide and come back.
If your app takes payments or assigns roles, a screenshot of enabled policies is not an adequate review. Thunkle's paid Supabase security audit traces what customers can read and write through every in-scope path, including backend code that bypasses ordinary RLS checks. We can scope the fixes as well as the review.
The Supabase RLS policy that passes and still fails
Imagine a customer updating their own booking. The row belongs to them. The request passes the ownership policy. They change the delivery note and, in the same request, set the price to one dollar.
The first change should be allowed. The second should not. If the caller has table-wide update privileges and no other rule restricts those fields, an ownership-only policy allows both changes.
The intended product rules are simple. Customers request a booking. The business decides the charge. A verified payment confirmation changes the payment state. Writing those rules down exposes the question that "RLS is on" leaves open.
One row, five decisions, different owners
- Requested dates. The customer decides, subject to availability rules.
- Delivery note. The customer decides, within the allowed editing window.
- Final price. Trusted server-side pricing logic decides.
- Payment state. The verified payment-processing path decides.
- Owner. Established from the authenticated customer, never from an ID submitted in the request.
All five values live in one record. They do not carry the same authority. Supabase's RLS documentation explains row filtering and checks on proposed data. Field restrictions, trusted calculations and privileged operations need their own design. RLS can take part in enforcing them, but an ownership-only policy is not a complete implementation.
The same distinction applies to profile roles, account verification and subscription entitlements. Owning your profile should not let you approve yourself as an administrator. That is the broken access control pattern in a different costume.
Saving an input does not make it trustworthy
During a Base44 application review we found a payment function that looked careful. It did not trust the price sent at checkout. It read the price from the database instead.
The browser had written that price into the booking earlier.
The payment function trusted the saved value without resolving who had been allowed to choose it. That was a finding in one application, not a Supabase vulnerability. It is why we follow the complete transaction in an audit rather than inspect the payment function alone. Moving the same customer-supplied amount behind a server endpoint would preserve the mistake. The server has to derive or verify the amount from trusted pricing inputs.
Review creation before you review edits
It is easy to focus on update policies and miss the first write. A protected price field is little help if a customer can set its initial value when creating the booking.
List every write path: browser database calls, backend functions, administrator actions and scheduled jobs. For each one, identify the identity used and the fields accepted.
- A customer edit might accept a delivery note and nothing else.
- A booking creation might accept listing and date choices, then calculate the amount server-side.
- A payment operation sets paid state only after verifying the provider's evidence and mapping it to the right booking.
Those paths may run under different privileges. Code with elevated database access, such as a function using the service role key, must enforce the caller's permissions before acting on their behalf. "It runs on the server" is not a permission check. Our explainer on every Supabase key covers which identity each path actually uses.
Five Supabase RLS tests that describe the rule
Use synthetic bookings in an isolated environment you own. Keep the ordinary customer operation working while you test the protected values.
- Customer changes an allowed delivery note. Expected: permitted.
- Customer supplies a different final price. Expected: rejected, or ignored in favour of the trusted calculation.
- Customer tries to set paid state. Expected: refused.
- Customer chooses another account as owner. Expected: refused.
- Legitimate verified payment is processed. Expected: the correct booking and account are updated.
Exercise both creation and update routes where they exist. Inspect the stored result, not just the HTTP response or the screen. Blocking every write would stop the unwanted action while breaking checkout, which is why the permitted case belongs beside the refused ones. The two-account RLS test gives you the fixtures for the cross-account cases.
When customers belong to teams, ownership is the wrong question
Ownership is a useful starting point for a personal app. A workspace product usually needs a more precise rule.
Consider a customer who can edit bookings for their organisation but cannot issue refunds. They may read a booking, change its delivery note and invite another viewer. That does not mean they may assign themselves the billing role.
List the actions separately. "Has access to the workspace" is too broad to answer all of them. Include what happens when membership ends, when someone changes role and when a booking moves between authorised teams.
Then inspect where the server obtains that role or membership. A role submitted by the browser is a request, not evidence. A cached permission needs an intentional lifetime: a removed colleague should not keep access because one part of the app has not refreshed.
Keep privileged shortcuts narrow
Sometimes a backend legitimately needs broader access than the caller. Processing a verified payment, running a scheduled task or supporting an administrator can require a different database identity.
That privilege should have a specific purpose. It should not turn a general customer update endpoint into "take any submitted object and save it". The endpoint resolves the caller, selects the allowed action and constructs the permitted changes. For the booking example, the customer sends a booking ID and a new delivery note. The server looks up the booking under the ownership or membership rule, confirms editing is still allowed and changes only that note. It rejects or discards unexpected fields on the server, even if a caller adds them to a request outside the interface.
There are several ways to enforce field-level rules in Supabase: column privileges, policies and functions designed for the purpose, and constrained server operations. The right combination depends on which data paths the app exposes. Adding a server endpoint is not enough if an unrestricted direct write remains available elsewhere. Ask whoever reviews the app to explain the effective permissions across every path, not to paste a universal snippet.
Supabase's column-level security guide shows a detail worth checking: removing a column-level grant does not restrict a caller who still has the broader table-level grant. Review the effective privileges together. A policy's USING expression controls which existing rows qualify, while WITH CHECK constrains proposed row values. Neither is a generic comparison of old and new fields; use an appropriate design for protected transitions.
Test the account you are worried about
An administrator's successful test tells you little about an ordinary customer's restrictions. The credentials the test runner uses matter as much as the test's name.
Keep separate fixtures for an ordinary customer, an unrelated customer and each relevant workspace role. Record which identity executes the request and which identity the backend uses for the database operation. A test that accidentally runs with elevated access throughout never exercises the protection you meant to validate. Our controlled one-account test experiment demonstrates why the second account matters; it does not measure how often this happens in production.
Keep the failure precise too. A request failing because a fixture was missing does not demonstrate that authorisation worked. A protected update should fail for the intended permission reason, while the same authorised operation succeeds with valid inputs.
The useful question before launch: "Show me the test that stops a customer changing this business decision." It is hard to answer with a screenshot of enabled RLS and easy to answer with an enforced rule.
What a Supabase security audit adds
For a founder, the useful result is an explanation of who can make each consequential decision, where that is enforced and which tests demonstrate it. That is what a Thunkle Supabase security audit produces. If your app handles payments, credits or sensitive records, we start with those transactions.
A green policy check is evidence about a policy. The product still has to enforce the rest of its agreement with its customers. Our paid audit provides prioritised findings, evidence and remediation guidance, with a free re-review of agreed fixes. Implementation is quoted separately. Request a Supabase audit quote if you want those rules traced and tested in your app. Send the scope and app URL, not secrets or customer records.
Common questions
Does enabling Supabase RLS make my app secure?
No. Enabled RLS with no applicable policy denies row access for roles subject to RLS; privileged roles can bypass it. An ownership-only policy may allow updates to sensitive fields if grants and other controls permit them. Security depends on effective permissions and trusted logic across the application, tested from the relevant accounts.
Can Supabase RLS policies restrict individual columns?
RLS filters rows and can constrain proposed values, but it is not a column-level grant system. Use suitable column privileges, constrained operations or triggers where required. Review all direct and privileged write paths, not just the frontend's normal request.
Should prices ever be written by the client?
The client can submit an estimate or a requested amount where the product allows it. It must not establish the authoritative charge. Derive or verify that amount on the server from trusted pricing inputs before creating the payment.
Is the service role key the problem?
Not by itself. The problem is server code that uses elevated access without first checking what the caller is allowed to do. Keep the service role key out of the browser and make every privileged path enforce the caller's permissions.
How is this different from an IDOR?
An IDOR lets one user reach another user's record. This example is the owner reaching fields inside their own record that they should not control. Both are access control failures and both need a test from the account that should be refused.
Get your Supabase project audited.
Have a senior developer review your app's access rules, backend and critical workflows. Our paid audit includes an agreed scope, evidence-backed findings, remediation guidance and a free re-review of agreed fixes. Fix implementation is scoped separately.
