back to research
LARP-2026-005web5 min read

A public anon key with open signup into an internal expenses database

An internal expenses portal shipped its Supabase project URL and anon key in the JS bundle, as Supabase apps do. The anon key mapped the whole schema, signup was open to any email, and the API answered any origin, so the only thing keeping strangers out of expense records was row-level security.

The target was an internal expenses portal: employees submit expenses, managers approve them. It was a React and Vite front end on a Supabase back end, and the login page was public.

Supabase apps put two things in the browser bundle, the project URL and the anon key. That is how Supabase is meant to work. The anon key is a public key, and what keeps data private is row-level security (RLS) on every table. So finding the key only starts the test. The question is what that key and the project’s settings let a stranger do.

The key

The bundle is one file, and the key is a JWT, so it’s easy to pull out:

shellpulling the anon key out of the bundle
curl -sk https://[portal]/assets/index-[hash].js \
  | grep -oP 'eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+'
jsondecoded payload
{"iss":"supabase","ref":"[project-ref]","role":"anon","exp":2092517832}

That expiry is in 2092. Rotating it is the only way this key stops working.

Anon role access

With the key in both apikey and Authorization, the PostgREST endpoint answered for three tables:

shellanonymous reads
curl -sk "https://[project-ref].supabase.co/rest/v1/expenses" \
  -H "apikey: $ANON" -H "Authorization: Bearer $ANON"
table columns
expenses id, created_at, updated_at, user_id, amount, description, category_id, status, date, approved_at, currency
expense_categories id, created_at, updated_at, icon, sort_order, is_active
user_roles id, created_at, user_id, role

All three returned 200 with an empty array. RLS was doing its job and filtering every row out for the anon role. The tables and their columns were still there to read, which gives anyone the data model of the company’s expense system.

The RPC functions came out the same way. PostgREST tries to help when you call a function with the wrong arguments:

shellcalling an RPC with no arguments
curl -sk -X POST "https://[project-ref].supabase.co/rest/v1/rpc/get_expense_approval_chain" \
  -H "apikey: $ANON" -H "Content-Type: application/json" -d '{}'
# ... Perhaps you meant to call public.get_expense_approval_chain(_expense_id)

That error named get_expense_approval_chain(_expense_id), and the same trick gave has_role(_user_id, _role). Function names in an approval system tell you where the interesting checks live.

Open signup

An internal portal shouldn’t take sign-ups from the internet. This one did:

shellsignup with the anon key
curl -sk -X POST "https://[project-ref].supabase.co/auth/v1/signup" \
  -H "apikey: $ANON" -H "Content-Type: application/json" \
  -d '{"email":"[test-inbox]","password":"[test-only]"}'

# auth settings: {"disable_signup": false, "mailer_autoconfirm": false}

The account was created with no invite. Email confirmation was on, so a confirmed account needs an inbox the attacker controls, which costs nothing. I created one test account to prove registration worked, never confirmed or used it, and asked for it to be deleted in the report.

This is the part that changes the risk. RLS policies are usually written in two halves, one for anon and one for authenticated, and the authenticated half is where the mistakes are, because the developer assumes everyone who is authenticated is an employee. Open signup turns “authenticated” into “anyone with an email address”.

CORS

The API reflected any Origin:

shellCORS check
curl -skI "https://[project-ref].supabase.co/rest/v1/expenses" \
  -H "Origin: https://evil.test" -H "apikey: $ANON"
# access-control-allow-origin: https://evil.test

The key is public anyway, so this matters less than it looks. What it means in practice is that a hostile page can drive the whole sequence from a visitor’s browser and read the answers.

Limitations

RLS held. What I could read was schema and metadata, and no expense record ever came back. I didn’t get past RLS, and the confirmed impact is the data model plus a way into the authenticated role that shouldn’t exist.

Fix

Turn signup off for an internal tool, or restrict it to the company’s domain. Restrict CORS to the portal’s own origin. Then read every RLS policy written for authenticated as if a stranger holds that role, because with open signup a stranger does. Rotate the anon key if the project’s settings were ever this open, since this one was valid until 2092.

Disclosure

Reported to the vendor and fixed. The company, portal host and project reference are removed. Table, column and function names are kept because they are generic and the post is about them.

F4XD
back to research