← All resources
mobile-security

26,686 trips, no login required

28 September 2026

A ride-sharing app shipped a Supabase anon key and left Row Level Security off one table. 26,686 trip records were readable by anyone who installed the app.

A ride-sharing app. A key it ships to every user. One table.

GET /rest/v1/trips?select=id&limit=1
apikey: <the key already inside the app on your phone>

HTTP 206
content-range: 0-0/26686

26,686 rows, readable by anyone who installs the app.

Each one carried a pickup address and a drop-off address as full street text, GPS coordinates for both ends, the scheduled time, the base fare, the cancellation fare, the total, the trip status, and internal user identifiers tying the trip to an account. No login. No session. No user token. Just the key that was already sitting on the device.

That count is from the day we tested it, and the table grows. Read it as a floor rather than a total.

Without the key, the same request is refused with a 401. So the app's own key was doing real work here, and it was doing that work for anyone who asked.

The red herring

The usual advice about a hardcoded Supabase anon key is that it is not a finding, and most of the time that is right. The key is a JWT with role: anon in it, Supabase documents it as safe to publish, and rotating it on its own accomplishes nothing.

This is the case where that stops being reassuring, and it's worth being exact about why. The key didn't change. What changed is what sat behind it.

The key is an identity. Whether that identity can read your trip table is decided server-side by Row Level Security and by nothing else. Leave RLS off a table in a Supabase project and the anon role reads it. That's the entire bug. There is no clever exploit in this story and no chained vulnerability. A checkbox was left alone.

So the finding was never "a key leaked". It was "nobody turned on the thing that makes the key harmless".

Why reading the app cannot find this

A static scan can recover the project. The project URL is in the bundle, and pulling it out is straightforward.

What no static scan can recover is the answer. Nothing in the binary records whether RLS is switched on. The REST path isn't even a literal in the bundle, because the Supabase client builds /rest/v1/ at runtime from the project URL. So there is no string to grep for and no evidence either way in the artifact.

Two apps can be byte-identical in every relevant respect, and one of them is a breach. The difference lives in a console, behind a login, in a setting someone did or didn't change months ago.

The only way to know is to ask the backend. That means sending a request to somebody else's infrastructure using a key recovered from their app. Which is why this check is not on by default and never runs on an app the customer didn't submit. Reading an app and probing its production backend are different acts with different permissions, and a tool that blurs them will eventually test a stranger's server.

What the fix actually is

Turn on RLS, and write the policy that says who may read what.

/* BEFORE: RLS is off. The anon key reads every row in the table.
   This is the default state of a new table. Nobody has to do
   anything to end up here. */

/* AFTER: RLS on, with a policy only a signed-in owner can satisfy. */
alter table trips enable row level security;

create policy "read own trips" on trips
  for select
  using (auth.uid() = user_id);

Then do it for every table, not just the one you were thinking about. The tables that leak are the ones nobody remembers creating: the profile table, the media table, the pricing table, the lookup table added during a sprint eighteen months ago. RLS is per-table and it defaults to off, so the exposure surface is your schema, not your intentions.

Two traps worth knowing before you start:

Enabling RLS with no policies denies everything, including your own app. The queries that worked a second ago will start returning empty sets, and it will look like you broke the backend. Write the policies first, then enable.

The service_role key bypasses RLS completely. It is an admin credential and it must never ship in a client. If you find one in your bundle, that is a worse day than the one described above, and it is the first thing to rotate.

Finally, verify rather than assume. Enable RLS, then repeat the exact request from the top of this article and require a 401 or a 403. If rows still come back, the policy is wrong, not the setting.

Check your own backend

Take the anon key out of your own app. Query a table you believe is private. If rows come back, RLS is off on that table.

Then do it for the rest. It takes a few minutes per table and it is the only way to know, because the answer is not in your code and it is not in your build. It is in your database, and your database is the thing you are actually protecting.

The key was never the secret. The table was.

Scan your app against these findings

25+ integrated tools, OWASP MASVS mapping, and remediation you can hand to an engineer.

Start a free scan