September 26, 2026
One Column, Full Admin: A Supabase Row-Level Security Story
How a single writable database column let any signed-up user hand themselves the keys to an entire platform, and the small policy change…

By Grimpit
4 min read
How a single writable database column let any signed-up user hand themselves the keys to an entire platform, and the small policy change that closes the door.
A while back, my team ran an authorized penetration test against a SaaS platform: a cybersecurity tutoring app built on a modern, and very common, stack. Next.js on the front end, Supabase for auth and data, Stripe for billing, an LLM tutor in the middle. It is the kind of architecture thousands of startups ship today, which is exactly why the most interesting finding is worth writing up. It was not exotic. It was a default that felt safe and was not.
Names, hosts, keys, and account details are all left out here. This is about the pattern, not the platform.
The stack tells you where to look
Before touching anything, you read what the app already tells you. A Supabase-backed single-page app ships two facts to every visitor in its JavaScript: the project URL and the "publishable" (anon) API key. That is by design. Those two values are meant to be public, because Supabase's security model does not depend on hiding them. It depends on Row-Level Security, or RLS: Postgres policies that decide, per row, what each user is allowed to read and write.
So the anon key is not the vulnerability. The vulnerability is what the anon key is allowed to do once a real user signs in behind it. That is a policy question, and policy is where this platform slipped.
The finding: an UPDATE policy with no column guard
Most Supabase apps keep a profiles table, one row per user, holding things like display name and, critically, a role column that marks who is an admin. The app's own admin API trusted that column: if your profile said role = admin, the admin routes served you.
The RLS policy on that table let authenticated users update their own row. Reasonable so far. People need to change their own display name. The problem is that "update your own row" was written without restricting which columns they could update. Postgres RLS, by default, applies a row policy to the whole row. Nothing stopped a user from including role in that update.
That is the whole bug. Not a zero-day, not a memory corruption exploit. One policy that said "you may edit your row" when it meant "you may edit these specific fields of your row."
The chain, with placeholders
Here is the shape of the exploit. Every real value is replaced with a placeholder; treat it as a diagram, not a script.
1. Read the public config from the bundle. The project URL and anon key are right there in the client JavaScript, as intended.
SB="https://<project>.supabase.co"
KEY="<publishable-anon-key>"SB="https://<project>.supabase.co"
KEY="<publishable-anon-key>"2. Register and log in. Normal signup, normal token. Now you hold a valid JWT for an ordinary, lowest-privilege account.
# POST $SB/auth/v1/token?grant_type=password
# -> returns access_token (JWT) and your user id# POST $SB/auth/v1/token?grant_type=password
# -> returns access_token (JWT) and your user id3. Patch your own row, including the field you should never control. This is the entire attack:
PATCH $SB/rest/v1/profiles?id=eq.<your-uuid>
Authorization: Bearer <your-jwt>
{ "role": "admin" }
-> 200 OKPATCH $SB/rest/v1/profiles?id=eq.<your-uuid>
Authorization: Bearer <your-jwt>
{ "role": "admin" }
-> 200 OKThe row policy allowed the update because it matched your user id. The absence of a column check allowed role to ride along.
4. Walk through the admin API. The app's admin routes read role straight from the database with no second server-side check. Your freshly self-assigned role was honored everywhere. Administrative functions, user management, and system configuration were all reachable.
A related instance of the same root cause let a free-tier account flip a subscription-tier column and unlock paid features with no payment. Same mechanism: a column that should have been server-controlled was writable by the client through the same unguarded policy.
Why this passes every "it works" test
The uncomfortable part is that the app behaved perfectly in normal use. Users edited their display names. Admins were admins. Billing worked. Nothing in day-to-day operation touches the role field from the client, so nothing in testing would ever reveal that the client could touch it. The gap only exists for someone who sends the request the UI never sends.
This is the signature of broken access control, which sits at the top of the OWASP Top Ten for a reason. The failure is not in code that ran wrong. It is in a boundary that was drawn one field too wide.
The fix is small, and it is layered
The remediation is not a rewrite. It is a few lines, applied in more than one place, because defense in depth is the point.
Lock the policy to the columns it means. Revoke column-level update on the sensitive fields, and add a WITH CHECK clause so a user's role can only ever equal the role they already have:
REVOKE UPDATE (role) ON public.profiles FROM authenticated;
-- and on the update policy:
WITH CHECK ( role = (SELECT role FROM profiles WHERE id = auth.uid()) )REVOKE UPDATE (role) ON public.profiles FROM authenticated;
-- and on the update policy:
WITH CHECK ( role = (SELECT role FROM profiles WHERE id = auth.uid()) )Do not trust a client-writable column for authorization. Even with the policy fixed, admin status should be confirmed server-side, using the service-role key, on every privileged route. The database column is a convenience, not a source of truth.
Consider moving privilege out of the profile entirely. A separate user_roles table with no update grant to the authenticated role removes the temptation altogether. Roles change through a controlled server path or not at all.
Derive paid tier from the payment provider, not a local flag. The subscription state should come from verified billing records checked server-side, never from a column the client can set.
The takeaways
- A public anon key is not a leak. In a Supabase app it is supposed to be public. Your security lives entirely in your RLS policies, so that is where your review time goes.
- "Update your own row" is not the same as "update these fields of your row." Row policies default to the whole row. Guard columns explicitly, especially any field that grants privilege or unlocks paid features.
- Never let the client be the source of truth for authorization. Re-check privilege on the server for every sensitive action, even when RLS looks correct.
- The dangerous requests are the ones your own UI never makes. Test the API directly, not just the app, because that is exactly where an attacker starts.
None of this required a clever exploit. It required sending one request the interface would never send, against one policy that was drawn a little too generously. That is how most real-world access-control failures happen, and it is why reading your policies as carefully as you read your code is some of the highest-value security work you can do.
This writeup is based on an authorized engagement. All client, host, and account details have been removed, and the commands above use placeholder values for illustration only.