One of the more dangerous sentences in a Supabase project is:
“RLS is enabled.”
It sounds like a security conclusion.
It is actually a configuration fact.
I was reminded of the distinction by a Music Kite production smoke test. The application offered a Festival context to a user. The server checked whether that user had the appropriate Festival capability. The check passed. Everything up to that point agreed that the user was authorised.
Then the application tried to save the selected context.
The database said no.
HTTP 403. PostgreSQL 42501. New row violates row-level security policy.
Not especially pleasing.
Extremely useful.
Because the failure exposed something that is easy to miss as a permission model evolves: every layer of the system can be behaving correctly according to its own rules while the product as a whole is still wrong.
The application had moved on. The policy had not.
The failure was not random.
Music Kite's Festival authority model had become more flexible. A person could legitimately have access to a Festival Edition through its parent Festival Brand rather than needing one particular direct membership row on the Edition itself.
The application understood that.
Its capability resolver understood it.
The context switcher understood it.
The row-level security policy did not.
The policy was still asking an older question: does this person have the direct membership relationship we used to require?
So the application was correct according to the current product model, and the database policy was correct according to a previous one.

The system, taken together, was wrong.
That is the lesson I care about.
Enabling RLS is the beginning, not the proof
Supabase Row Level Security is PostgreSQL row security underneath. That is one of the reasons I like it. The permission boundary sits in the database instead of relying entirely on application code remembering to behave.
A policy can restrict which existing rows a user can see or act on and can separately decide whether a new or changed row is permitted.
Powerful.
Also very easy to misunderstand from a dashboard.
The little indication that RLS is enabled tells you that the table is subject to row-level policies.
It does not tell you those policies express the correct product rules.
They can be wrong in two directions.
The frightening version is too permissive: somebody can reach data or actions they should not have.
The quieter version is too restrictive: the product says a legitimate user is authorised, but the database rejects them anyway.
Our Festival context problem was the quieter kind. The database failed closed, which is considerably preferable to leaking somebody else's data. But “securely broken” is still broken if an authorised user cannot complete the workflow the product has offered them.
Permission models grow up even when old policies do not
Early permission models are usually pleasantly simple.
A user belongs to an account. The account owns an entity. Check the membership. Done.
Then the product acquires reality.
A parent organisation owns several things. Brands own editions. Managers inherit rights. Some people can view but not edit. Some rights are delegated. Historic relationships need to keep working. Administrators exist. Different parts of the product need different capabilities.
At that point, “does a matching row exist in this one table?” may no longer be the authorisation model.
It is simply one source of authority.
This is where RLS can drift from application logic without anyone doing anything obviously reckless. The feature work tends to happen in the visible parts of the system. You update the server helper. You update the UI. You update the capability resolver. Tests around the new path go green.
Meanwhile a policy written three architectural decisions ago is still sitting in PostgreSQL doing exactly what you asked it to do.
Very obediently.
That is why I increasingly think of row-level policies as application code that happens to live in the database.
They need versioning, review and behavioural tests just like TypeScript does.
A policy can be syntactically perfect and conceptually stale
One trap with security SQL is that familiar ingredients create confidence.
You see auth.uid(). You see a membership check. You see USING and WITH CHECK. The policy looks sensible.
It may be sensible.
For a product that no longer exists.
If authority later means direct owner or delegated manager or parent-brand member or another recognised capability, a direct-membership policy may remain perfectly valid SQL while becoming incorrect product logic.
That is what happened here.
The repair was not “make RLS less strict”. It was to make the Festival branch of the policy ask the same canonical capability question the rest of the application now asked, while retaining the narrower rules for other contexts where they were still appropriate.
The point is not to make every policy clever.
The point is to make each policy describe the real authority contract.
Complexity should be earned.
Test the awkward user, not the friendly one
This kind of bug can survive testing for a surprisingly long time if your test user is too convenient.
Imagine testing a Festival user who happens to possess both the new Brand-derived authority and the old direct Edition membership row.
Everything works.
The application passes.
The RLS policy passes.
Everybody goes home feeling secure.
The failure only appears for the legitimate user whose authority comes through the path the old policy does not know about.

That is exactly the user I now want represented in permission tests.
Not simply:
authorised user succeeds;
unauthorised user fails.
But:
direct authority succeeds;
inherited authority succeeds where intended;
delegated authority succeeds where intended;
wrong entity fails;
wrong capability fails;
anonymous access fails where it should;
insert and update behaviour are both intentional;
upsert paths do not accidentally exercise a policy branch nobody remembered to test.
Security bugs love the path nobody thought was interesting enough to put in the fixture.
Inspect the database that is actually running
Another useful lesson was much less philosophical.
Look at production truth.
Do not assume the migration you remember writing is the policy the hosted database currently has. Do not assume the repository and production are aligned because they are supposed to be.
Inspect the actual policy. Inspect the helper function it calls. Look at the production error. Correlate that with the application path that produced it.
For this failure, the useful chain was beautifully boring:
the product offered a Festival context;
the server capability check succeeded;
the preference write returned 403;
PostgreSQL reported an RLS violation on the relevant table;
the running policy still expressed the older direct-membership rule;
the application expressed the newer capability rule.
Once those facts were next to each other, the bug stopped being mysterious.
It was a contract mismatch.
I like bugs that become boring when explained properly.
Do not repair permissions by going around permissions
There is a dangerous moment in any security failure where the fastest route to a green test is to bypass the thing objecting.
Use a more powerful service role. Move the write somewhere privileged. Add an over-broad exception. Make a helper return true for the account you happen to be holding.
Bosh. Green test.
Possibly not the desired kind of bosh.
There are legitimate reasons for privileged helpers and SECURITY DEFINER functions. The problem is not that they exist. The problem is using extra authority as a way to avoid reconciling the permission model.
A permissions failure is a reason to ask which rule is actually intended.
Who should be allowed to do this?
Where does that authority come from?
What must still be denied?
Then encode that rule as narrowly as possible.
The database should not stop objecting because we became annoyed with it.
It should stop objecting because the legitimate action now matches the policy.
The checkbox I actually want is behavioural
RLS being enabled matters.
Of course it does.
But the useful confidence comes from a more specific question:
Can this exact user, through this exact authority path, perform this exact operation on this exact row — while the people who should not be able to do it still cannot?
That question survives architecture changes much better than “did we remember to switch RLS on?”
The database in this incident did its job perfectly.
It enforced the policy we had given it.
We had simply changed the meaning of authorised somewhere else.
RLS was enabled.
RLS was working.
The policy was wrong.
That distinction is worth testing for.
