Without a TO clause, every policy is public
A policy without a TO clause applies to PUBLIC, which includes anon. That made KabutarBaazi's listings and profiles readable with a curl and the key inside the APK.
KabutarBaazi is a marketplace app for pigeon keepers, and its Android client talks to Postgres directly through PostgREST, with the Supabase anon key compiled into the APK. No API server of mine sits between the app and the tables it reads, so row-level security is the only boundary between one user and everybody else's data. I wrote 61 policies for its 26 tables, and not one had a TO clause. In PostgreSQL a policy without one applies to PUBLIC, every role, including anon, the role of a request with no login. So the listings feed and the entire profiles table could be read with a plain curl and the key from the app. The local test suite could not catch it without one deliberate line, because its throwaway database is otherwise stricter than production. The lesson is narrower than "test your policies": a test environment more locked down than production cannot catch a production leak.
The key in the APK is public
app/build.gradle.kts reads SUPABASE_ANON_KEY from local.properties into BuildConfig, and SupabaseFactory.kt hands it to the client. Anyone with the APK can read the string. Supabase's API keys page, read on 12 October 2026, says its publishable key is "Safe to expose online: web page, mobile or desktop app, GitHub actions, CLIs, source code" and that "Anyone can read it, so it only reaches what Row Level Security allows." The same page now calls the anon key the "Legacy version of publishable keys" and says Supabase "is deprecating the anon and service_role keys by the end of 2026". The comfortable reading of that sentence is that RLS protects you. The accurate one is that RLS is all that protects you.
The key chooses a database role. The PostgREST authentication docs say that "When a request contains a valid JWT with a role claim PostgREST will switch to the database role with that name for the duration of the HTTP request". The anon key is itself such a JWT, "JWT (long-lived)" on the API keys page, and the copy in docs/delete-account.html decodes to "role":"anon". A request carrying only that key runs as anon, which Supabase's RLS guide describes as "an unauthenticated request"; a signed-in user's request runs as authenticated, "an authenticated request". KabutarBaazi requires a login for every screen, so all but three of the policies were written with a signed-in user in mind. The policies were about which user. The leak was about which role.
Sixty-one policies and no TO clause
supabase/migrations/0003_rls.sql enables RLS on all 26 tables from 0001_schema.sql and creates 61 policies. Only the parent table carries the visibility predicate; a child gates on an exists against its parent, which RLS filters in turn, unless its rows are the user's own, like saved listings. The profiles policy reads:
create policy profiles_read on profiles for select
using (id = (select auth.uid()) or is_admin()
or (not is_suspended and not is_blocked_with(id)));
The listings policy has the same shape: admin, or the seller, or not hidden, not removed and not is_blocked_with(seller_id).
is_blocked_with, in 0002_functions_triggers.sql, is a security definer function that looks in blocks for a row pairing auth.uid() with the other user in either direction. Run it with only the anon key, no user JWT. auth.uid() is null, blocker_id = null is never true, no row matches, and not is_blocked_with(seller_id) is true for every seller. In profiles_read the first branch is null, is_admin() is false, and the third branch is true for every profile that is not suspended: the whole table. In listings_read, every listing not hidden or removed. The policy was correct for every signed-in user and wide open for the one caller I had not pictured.
The PostgreSQL manual's CREATE POLICY page says it plainly. For TO role_name: "The default is PUBLIC, which will apply the policy to all roles." Supabase's RLS guide has a heading for this, "Specify roles in your policies", whose first line is: "Always name the role a policy applies to, using the to clause." I had read both. Writing the content policies I was thinking about users, and a rule about roles did not feel like it was about me. The comment at the top of 0006_restrict_anon.sql records how I found out: against the live project, "a plain curl with only the anon key returned both", the feed and the profiles.
One migration and two lines of defence
The fix, supabase/migrations/0006_restrict_anon.sql, is 43 lines. A do block reads every policy in the public schema from pg_policies, skips a keep list of three, and runs alter policy ... to authenticated on the other 58. The three left on PUBLIC are regions_read and breeds_read, reference data, and deletion_insert_public, insert-only, for the public account-deletion page Play requires. Then the second line: revoke all on all tables in schema public from anon, and grant anon exactly select on regions and breeds and insert on deletion_requests, plus execute on username_available(text), for a signup form before a session exists; SessionRepository.kt wraps it, and no screen calls it yet. A role with no privilege on a table cannot reach it, so on the tables that existed when the revoke ran, a policy written later without a TO clause fails closed for anon. A new table gets the automatic grants again, which is why the README's first invariant is a rule and not a mechanism.
The test that could not fail
The reason a local suite misses it is the harness. supabase/tests/run_tests.sh runs initdb into a throwaway directory, starts a cluster on port 55432 with listen_addresses='' so it answers only on a socket, applies a harness that stubs what Supabase provides (auth.users, auth.uid() and the three roles), then applies every migration and runs the assertions. No Docker, and nothing touches the live project.
On a bare Postgres a new role can read nothing: the manual's privileges page says "No privileges are granted to PUBLIC by default on tables, table columns, sequences" and the rest. The harness's anon would have been refused by the missing grant before any policy was consulted. A hosted Supabase project is the opposite. Its hardening guide, read on 12 October 2026, says that on existing projects "tables created in public receive SELECT, INSERT, UPDATE, and DELETE privileges" for "anon, authenticated, and service_role by default". The page says that default is changing to opt-in; my project has the old one, or the curl would have been refused. Production handed anon a privilege on every table and relied on the policies; my test database handed anon nothing.
supabase/tests/01_grants.sql is the correction. It grants all on all tables in schema public to anon, with a comment saying why: "without this line the local suite is more restrictive than production and cannot catch an anon leak." It then re-applies the profiles column whitelist that the file's blanket grant to authenticated undoes, without which any user could run update profiles set role = 'admin'. Only once the test database is as open as production does rls_test.sql run. Its last block, a regression guard headed "ANONYMOUS ACCESS (the anon key ships inside the APK, so it is effectively public)", switches to the anon role with an empty sub claim and asserts that profiles, listings, reels, community posts, messages and private contacts come back empty, and that regions, breeds and username_available() still work. Nine assertions out of 53, and the only nine that would have caught the leak.
As open as production, then test for closure
The rule I take from this is about fixtures, not assertions. Every stub in a test harness is a place where the test environment can be stricter than production without anyone noticing, because strictness never fails a test; it only makes passes meaningless. The test-side fix was nine assertions and a less safe fixture, and the fixture is the half that mattered, commented so nobody tidies it away: without it the CANNOT assertions would have met a missing grant, not a policy. The 94 JVM tests in domain/ could never have found this, because the bug was not in a code path. It was in a default.
The README carries the rule as its first invariant: never write a policy without a TO clause. A boundary that depends on a default is only as safe as your memory of what the default is. Migrations, harness and the curl-shaped hole are all in the KabutarBaazi repository.
Get new posts by email
Occasional essays on engineering, AI, and building for the people technology leaves behind.
Subscribe with RSS