Supabase Security: Lessons from Real Pentests
Harden Supabase with the following cheat-sheet with clear steps for RLS, schemas, Edge Functions, Storage, CORS and tokens. Built from real audits.

Introduction
This article will be updated over time as the Pentestly Research Team finds new quirks during ongoing engagements. Check back for rolling changes. (Last Updated: 10/09/2025)
Over the past 12 months of building production workloads on Supabase and reviewing client's Supabase deployments & configurations, we kept tripping over the same handful of security footguns. Not dramatic zero-days, more like sharp corners that slice teams in real life. This guide turns those repeat offenders into practical fixes you can paste into migrations, Edge Functions and CI. We will keep the tone plain, add just enough theory to make the why obvious, then jump straight to what to change.
How Supabase pieces fit together, in plain English
Before the deep dive: During our testing we repeatedly found common misconfigurations across the core Supabase components below. Use this quick map to orient yourself, then tighten each area with the checks and fixes that follow.
| Component | Overview | Common findings & misconfigurations we see in audits |
|---|---|---|
| Authentication | Issues JWTs that power auth.uid() and custom claims. Policies and Edge logic rely on accurate claims and sane token lifetimes. | Long access-token lifetimes and weak refresh rotation- Missing or stale custom claims that policies expect - Lax session revocation and no MFA gating on sensitive RPC |
| PostgREST & RPC | HTTP façade over your DB. Anything the active role can reach in public becomes an endpoint, including RPC. | anon can select views or tables; schema enumeration via /rest/v1/ OpenAPI when not disabled- Over-exposed views in public including materialised views- Loose pagination and filtering enabling low-noise scraping |
| Row Level Security (RLS) | Per-table guardrail for tenant and user isolation. Applies per action: select, insert, update, delete | Tables with RLS disabled or incomplete action coverage- Catch-all helpers that accidentally block admins or pentesters- Unqualified helper calls inside policies creating search_path risk |
| Functions, Triggers, RPC | Server-side code that runs inside Postgres. It includes functions you call on demand (also exposed as HTTP RPC via PostgREST & Supabase when granted) and triggers that run automatically on INSERT/UPDATE/DELETE | SECURITY DEFINER without pinned search_path- Helpers left in public and callable over RPC- Excessive EXECUTE to authenticated or anon- Trigger functions defined in public; duplicate overloads bypassing revoked grants |
| Extensions | Adds capabilities like pgcrypto, pgvector which extend the existing Postgres fuctionality. Network ones such as http and pg_net can make outbound calls. | http or pg_net enabled and callable by non-service roles- RPC wrappers that call http_get leading to full-read SSRF- Extension objects left in public with broad grants |
| Vault and Secrets | Centralised secrets for Edge and database. Resolve at runtime, keep scope tight, and audit usage. | Service-role or API keys accessible to the client bundle or logs- Over-broad read access to secrets; no rotation process- No alerting on secret access anomalies |
| Edge Functions | Server code in Deno using @supabase/supabase-js Treat as production services with strict CORS and rate limits. | Service-role used for user-initiated routes instead of user JWT context- No rate limiting or abuse controls- Reflected or wildcard CORS; no proxy or WAF in front- No server-side captcha verification before actions |
| Storage | Object storage backed by Postgres with RLS on storage.objects Paths are strings; policy enforces who can read or write. | No user or tenant id in path prefix leading to cross-tenant reads- Very long signed URL TTLs; public buckets by default- Policies parsing the wrong folder segment in name |
With that map in mind, hardening decisions get simpler. The rest of this guide follows the same components: PSQL Security, PostgREST Security, Auth Security, Edge Functions and Storage. For each one we summarise the most common production misconfigurations we see, then show a hardened pattern and the exact checks to run.
If you would also like a comprehensive audit, speak to our sales team about a tailored Supabase security review.
For deeper reading alongside this guide, see the Official Supabase Production Checklist. We reference it throughout and call out places where teams still get caught out in the wild. Note that we have intentionally left out most of the recommendations already detailed by Supabase themselves, our focus here is on the additional issues and realworld gaps we have seen in audits. For maximum coverage and peace of mind before pushing to production, we strongly recommend combining both resources and working through each checklist in parallel.
Update from the Supabase team: The Supabase team has highlighted that their Security/Performance advisor should also be part of regular security checks. They provide comprehensive documentation for each advisory with rationale and suggestions to fix issues: Database Advisors
Validate your Supabase security before release
Test row-level security, Edge Functions, Storage policies and application logic with a focused Supabase assessment.
Speak to SalesAuthentication
Introduction
Supabase Auth issues the JWTs that power auth.uid() and, if you choose, any custom claims you rely on in policies. Those tokens are the thread that stitches the browser, Edge Functions and PostgREST together. If a token is long-lived, stale, missing a claim you assumed, or never verified server-side, the whole security story frays fast. Think of Auth as the identity source of truth: get lifetimes, rotation and claims right, and RLS suddenly feels effortless; get them wrong, and you end up compensating with ad-hoc checks in every function.
Our recent assessments showed the same patterns: access tokens that never expire in practice, policies expecting JWT fields that were never actually present, no session revocation on role changes, and sensitive RPCs that did not require a freshly verified user or MFA. Below we break those down and show how to detect and fix them.
Common Misconfigurations
MFA Bypass by Trusting aal1 Tokens for Policies
Supabase upgrades the Authentication Assurance Level (aal) on a session after a successful MFA challenge. A normal login yields aal1; once a TOTP or WebAuthn factor is verified, new tokens carry aal2 In several audits we found sensitive paths & tables such as role assignment, API key creation, billing changes - that accepted any valid aal1 session and never checked for the upgraded aal2 Policies and Edge handlers treated aal1 as enough. In practice this meant a stolen aal1 token (pre-MFA) or leaked user-credential could access areas of the application that the product team believed were "MFA-protected."
We confirmed this by authenticating as a test user without completing the MFA step, capturing the aal1 token (pre MFA auth) and calling the target endpoint. The request succeeded. After enabling MFA and completing the challenge, the refreshed token contained "aal":"aal2", but nothing in the policy or handler actually required it.
# 1) Login to the application and capture access token before providing secondary authentication method (SMS/TOTP/WebAuthn) => payload shows "aal":"aal1"
JWT="$AAL1_TOKEN"; IFS='.' read -r _ P _ <<< "$JWT"; echo "$P" | base64 -d 2>/dev/null | jq .aal
# 2) Call a sensitive endpoint with aal1 => this should be blocked, but it often isn't
curl -sS -H "Authorization: Bearer $JWT" \
https://functions.example.com/functions/v1/create-api-key
Recommendations
Require aal2 for high-risk operations and validate it server-side. There are two good places to enforce this: an Edge guard at the start of the handler, and (for truly critical tables) an RLS clause that also checks the claim. Make sure your frontend actually refreshes the session after MFA so the token carries aal2.
Edge Guard
// In your Edge Function handler
const auth = req.headers.get("authorization") ?? "";
const token = auth.split(" ")[1] ?? "";
const payload = JSON.parse(atob((token.split(".")[1] ?? ""))) as Record<string, unknown>;
if (payload?.aal !== "aal2") {
return new Response(JSON.stringify({ error: "MFA required" }), {
status: 401,
headers: { "content-type": "application/json" }
});
}
// Optional: also require recent token age for step-up freshness (e.g., <= 5 minutes)
const now = Math.floor(Date.now() / 1000);
const age = now - Number(payload?.iat ?? now);
if (age > 300) {
return new Response(JSON.stringify({ error: "Reauthenticate (fresh MFA required)" }), { status: 401 });
}
RLS Hardening for the Crown Jewels
-- Example: only allow admin with aal2 to read/write this table
create policy admin_with_mfa_read
on api_keys
for select
using (
exists (
select 1 from memberships m
where m.user_id = auth.uid() and m.role = 'administrator'
)
and (auth.jwt() ->> 'aal') = 'aal2'
);
create policy admin_with_mfa_write
on api_keys
for insert to authenticated
with check (
exists (
select 1 from memberships m
where m.user_id = auth.uid() and m.role = 'administrator'
)
and (auth.jwt() ->> 'aal') = 'aal2'
);
Hidden Signup Endpoints in “Invite-Only” or “Auth-Gated” Apps
In recent audits we’ve encountered Supabase projects marketed as invite-only or closed registration. The frontend removed the “Sign Up” button and only exposed login, leading teams to believe no one could create new accounts. The problem is that Supabase Auth exposes its own API endpoints, and unless you explicitly disable or intercept them, the /auth/v1/signup route remains public. An attacker can bypass the frontend entirely and call the Auth API directly, creating arbitrary users in a supposedly closed system.
This pattern was recently highlighted in the wild by Harley @disclosedonline in a public disclosure. Even though the app didn’t show a signup option, the attacker hit the Auth API directly, created a new account, and gained access. The flaw wasn’t in hiding the UI — it was in failing to shut the backend door.
The technical path is simple:
# Create a new account even if the frontend "blocks" it
curl -s -X POST \
-H "apikey: $ANON_KEY" \
-H "Content-Type: application/json" \
https://<project>.supabase.co/auth/v1/signup \
-d '{"email":"[email protected]","password":"P@ssword123!"}'
If the endpoint responds with a 200 and a valid access_token, the system is not invite-only — it is open registration with a missing UI button. From there, the attacker can authenticate as the new user and access whatever defaults the policies allow.
Detection can be performed inside the database by querying your users table for unexpected accounts created outside your invitation flow:
-- List all recently created users
select id, email, created_at
from auth.users
order by created_at desc
limit 20;
-- Look for accounts created outside your invite system
-- e.g., missing invitation metadata or not tied to a tenant
select id, email, raw_user_meta_data
from auth.users
where raw_user_meta_data->>'invited_by' is null;
Recommendations
Mitigation requires explicitly blocking signup at the API level, not just hiding it in the UI. Supabase exposes a config flag DISABLE_SIGNUP=true which prevents /auth/v1/signup from creating new users. For projects that must allow limited self-registration, you can enforce constraints by validating a domain or invitation code in a trigger before user rows are committed.
-- Hard block public signups (invite-only model)
alter table auth.users
add constraint enforce_invited
check (raw_user_meta_data ? 'invited_by');
-- Reject signups unless invited_by is populated
create or replace function app_private.prevent_open_signup()
returns trigger language plpgsql as $
begin
if new.raw_user_meta_data->>'invited_by' is null then
raise exception 'Public signup not allowed';
end if;
return new;
end;
$;
create trigger prevent_open_signup
before insert on auth.users
for each row execute function app_private.prevent_open_signup();
The takeaway is simple: hiding a button is not security. If /auth/v1/signup is live, your app is effectively open to the world. To run an auth-gated or invite-only system you must explicitly disable or constrain registration at the backend. Otherwise, as Harley’s disclosure showed, you end up with ghost accounts in a system you thought was closed.
Weak Default JWT Algorithm (HS256) in Supabase Auth
In recent audits we’ve encountered Supabase projects relying on the default HS256 signing algorithm for JWTs. HS256 is symmetric — meaning the same secret is used to both sign and verify tokens. While this works for small projects, it collapses in security posture once tokens need to be validated by multiple services, since the shared secret must be distributed everywhere. Any leak of that secret (through logs, client code, CI/CD, or insider access) allows an attacker not just to verify but to forge arbitrary JWTs with full control over claims such as role or sub.
Modern practice is to move to an asymmetric keypair RS256/ES256, where only the private key can sign and the public key can be safely shared for verification. This isolates trust and reduces blast radius: a verifier cannot mint tokens, even if compromised. Supabase projects left on HS256 default risk treating JWTs as trustworthy across microservices while actually enabling attackers to mint their own.
The technical path is straightforward: an attacker who obtains the JWT_SECRET can mint a fully valid Supabase access token with elevated claims:
# Forge an admin token with HS256 since the secret is known
jwt encode \
--alg HS256 \
--secret "$JWT_SECRET" \
--payload '{"role":"service_role","sub":"[email protected]"}'
If the forged token validates against your Supabase project and grants access, the environment is exposed to full privilege escalation.
Recommendations
Supabase projects should not rely on the default HS256 symmetric signing for production use. Instead, migrate to an asymmetric algorithm such as RS256 or ES256, where a private key signs tokens and a public key verifies them. This ensures verifiers cannot mint new tokens, reducing the impact of a secret leak or insider compromise.
Key actions to take:
Rotate to asymmetric JWT signing: configure Supabase Auth to use RS256, generating a keypair where the private key remains on the auth server and the public key is safely distributed to verifiers.
Limit distribution of secrets: if HS256 must remain temporarily, ensure the JWT_SECRET is never exposed in frontend code, logs, or CI/CD pipelines. Treat it as a highly sensitive credential.
Audit integrations: review every microservice, API, or worker that currently verifies JWTs to ensure they do not require signing ability. With RS256, only verification keys should be shared.
Enable short-lived tokens: reduce risk from key leaks by issuing tokens with minimal lifetime and requiring refresh via secure channels.
Monitor for forged tokens: implement additional checks on aud, iss, and exp claims, and log anomalies such as tokens with unexpected role or sub.
Migrating off HS256 aligns your project with modern JWT best practices and significantly reduces the risk of forged tokens leading to privilege escalation in production Supabase environments.
Lack of Bot & Abuse Protection on Auth Endpoints
During assessment of Supabase projects we’ve observed authentication endpoints exposed without any form of bot or abuse protection. Supabase does ship with an “Attack Protection” feature that integrates Cloudflare Turnstile CAPTCHA on login and signup flows — but it is disabled by default. Unless teams explicitly enable it, the /auth/v1/signup and /auth/v1/token routes can be hit programmatically without friction.
The risk is straightforward: an attacker can automate large volumes of requests against these endpoints to brute force user credentials, attempt credential stuffing, or generate thousands of disposable accounts. With no rate limiting or challenge mechanism, these requests are only bounded by the attacker’s bandwidth.
A basic brute force example looks like this:
for i in $(cat passwords.txt); do
curl -s -X POST \
-H "apikey: $ANON_KEY" \
-H "Content-Type: application/json" \
https://<project>.supabase.co/auth/v1/token \
-d '{"email":"[email protected]","password":"'"$i"'"}'
done
Without a CAPTCHA or rate-limiting layer, the API will process each request, returning error codes on failure and valid tokens on success. This creates a low-barrier environment for attackers to abuse authentication.
Detection can be performed by inspecting the raw auth logs or Postgres audit trails for unusual request patterns:
-- Count failed logins per IP address
select ip_address, count(*) as failures
from auth.audit_log_entries
where action = 'login' and success = false
group by ip_address
order by failures desc
limit 10;
If you see repeated failures from the same IP or CIDR, it indicates brute force activity against your project.
Recommendations
Mitigation requires explicitly enabling Attack Protection in Supabase’s configuration. This feature wires Cloudflare Turnstile into the default auth endpoints, requiring clients to supply a valid CAPTCHA token before a login or signup request is accepted. For sensitive applications, you should:
- Enable Attack Protection in the Supabase dashboard under "Authentication → Attack Protection".
- Combine it with RLS-based detection of failed logins (e.g., lockout after N failed attempts).
- Consider additional upstream rate-limiting (Cloudflare WAF, Nginx ingress) for defense in depth.
The key takeaway is that Supabase leaves bot protection off by default. Unless you enable it, your authentication endpoints are exposed to unmetered brute force and abuse. Teams building production-grade systems must explicitly configure this protection rather than assuming it is active.
PostgREST
Introduction
PostgREST is the bridge that turns your PostgreSQL schema into a REST API. Each HTTP request is mapped to a single SQL statement that runs with the active database role. If that role can select a table, PostgREST can expose it at /rest/v1/<table>?select=* If that role can execute a function in public, PostgREST can expose it at /rest/v1/rpc/<function> Row Level Security still applies, which is great, but RLS only helps if your grants and policies are correct.
Think of PostgREST as a perfectly literal translator. It does not invent new rules. It simply reflects what your database already allows. That is why schema layout, function placement and grants matter so much. Put a helper in public with execute for authenticated and you just published a new endpoint. Leave anon with select on a view and you just made open data. The wins are huge when you get it right, and the cuts are sharp when you forget a single grant.
Common Misconfigurations
Helpers in Public Schema Exposed Over RPC
Policy helpers and internal utilities belonged in a private schema but lived in public Because PostgREST exposes public functions as RPC, any logged in user could call them. The code worked in development. In production it became a shadow API.
We started with a quick inventory of callable functions, then filtered by names that looked like helpers.
-- Inventory callable functions in public
select routine_schema, routine_name, data_type
from information_schema.routines
where routine_schema = 'public'
order by routine_name;
-- Optional helper heuristics
select routine_schema, routine_name
from information_schema.routines
where routine_schema = 'public'
and routine_name ~ '^(is_|has_|check_|count_|verify_|enqueue_)';
We then verified reachability with a minimally scoped user token.
curl -sS -X POST \
-H "apikey: $ANON_OR_SERVICE_KEY" \
-H "Authorization: Bearer $USER_JWT" \
-H "Content-Type: application/json" \
https://api.example.com/rest/v1/rpc/check_verified_assets \
-d '{"tenant_id":"00000000-0000-0000-0000-000000000000"}'
The helper returned a joined dataset that was never intended for direct client access.
Recommendations
Pentestly recommends too relocate helper functions to a private schema such as app_private This ensures that sensitive logic is not inadvertently exposed. If there is a need to make certain functions accessible via RPC, consider creating minimal, validated wrappers in the public schema. These wrappers should strictly handle input validation and delegate the core logic to the private schema, thereby maintaining a secure boundary between public access and sensitive operations.
-- Lock down helper placement
create schema if not exists app_private;
revoke all on schema app_private from public;
-- Move helper out of public
alter function public.check_verified_assets(uuid) set schema app_private;
-- Optional: expose a minimal wrapper with input validation
create or replace function public.check_verified_assets_rpc()
returns setof app_private.asset_check_result
language plpgsql
security invoker
as $
begin
if auth.uid() is null then
raise exception 'not authenticated';
end if;
return query
select * from app_private.check_verified_assets(auth.uid());
end;
$;
-- Grants: never to anon by default
revoke execute on function public.check_verified_assets_rpc() from public, anon;
grant execute on function public.check_verified_assets_rpc() to authenticated;
Anonymous Read Access to Public Schema via REST
In Supabase, anon is the database role tied to your project’s anonymous API key, which is typically exposed client-side and used in queries to your Supabase backend. PostgREST runs each HTTP request with the privileges of the role implied by the key you present. When you call /rest/v1/... with the anon key, PostgREST executes as anon If anon can select from a table or view in public, that relation effectively becomes a public endpoint. Row Level Security (RLS) still applies, but RLS only protects what your policies actually restrict. If you accidentally grant anon broad select or leave helpful catalog-style views in public, you have a shadow "open data" API and a quick path to schema reconnaissance. This exposure is particularly risky because the anon key, being client-side, is easily accessible to anyone who interacts with your application, potentially allowing unauthorized users to exploit any overly permissive grants.
Think of PostgREST here as brutally literal: it never invents access. It translates your grants into endpoints. In several client engagements we found that anon retained select on views created for internal dashboards. Because those views sat in public, they were reachable at /rest/v1/<view> With a few queries and filters, we could enumerate structure and dump slices of data that were never meant to be public.
We start by asking the database a blunt question: what can anon read? Then we try the same from the REST edge to confirm real exposure.
-- 1) Inventory any privileges granted to `anon` on tables and views
select grantee, table_schema, table_name, privilege_type
from information_schema.table_privileges
where grantee = 'anon'
order by table_schema, table_name;
-- 2) Check if `anon` still has schema usage or create
select *
from information_schema.schema_privileges
where grantee = 'anon' and schema_name in ('public','storage');
-- 3) Views and materialised views in `public`
select 'matview' as kind, schemaname as schema, matviewname as name
from pg_matviews
where schemaname = 'public'
union all
select 'view', table_schema, table_name
from information_schema.views
where table_schema = 'public'
order by kind, schema, name;
This behaviour can also be confirmed at the REST edge by issuing the below request.
curl -sS -H "apikey: $ANON_KEY" \ -H "Accept: application/openapi+json" \ "https://examplehost.supabase.co/rest/v1/?apikey={Anon-Key-Here}"
When anon can read "just a view" attackers gain a low-noise enumeration oracle. They learn table names, column names, value ranges, and which filters succeed, all from the public edge. If any mis-scoped policy exists on the underlying tables, the step from "map" to "leak” is one query. Even without direct PII exposure, this reconnaissance compresses the time-to-exploit dramatically. In our client case, the dashboard view enabled rapid discovery of RPC endpoints by hinting at helper naming patterns; chaining that with a permissive function grant gave access to data that should have stayed behind authenticated flows.
Recommendations
Lock anon down to the bone. Disable OpenAPI for roles that should not discover your surface. If you truly need anonymous reads, publish a single minimal view and nothing else.
-- 1) Strip `anon` of table and view access in `public` and `storage`
revoke select, insert, update, delete, references on all tables in schema public from anon;
revoke select, insert, update, delete, references on all tables in schema storage from anon;
-- 2) Remove function EXECUTE for anon
revoke execute on all functions in schema public from anon;
-- 3) Prevent future drift for new objects
alter default privileges in schema public revoke select, insert, update, delete on tables from anon;
alter default privileges in schema public revoke execute on functions from anon;
-- 4) Disable OpenAPI for the roles used at the REST edge
-- `authenticator` is the proxy role PostgREST uses before switching to anon or authenticated.
alter role authenticator set pgrst.openapi_mode to 'disabled';
alter role anon set pgrst.openapi_mode to 'disabled';
-- 5) Instruct PostgREST to reload configuration
notify pgrst, 'reload config';
If you must expose public data, create an explicit view that cannot leak private rows and grant only that one to anon Prefer an Edge Function if you want to layer CORS, rate limits and abuse checks.
-- Minimal public view with a defensive WHERE
create or replace view public.docs_public
with (security_barrier)
as
select id, title, published_at
from public.docs
where is_public = true;
grant select on public.docs_public to anon;
TL;DR: anon is your public internet persona. If it can select something in public, the world can hit it at /rest/v1/.... If OpenAPI is enabled, the world can also fetch a machine readable map of your endpoints at /rest/v1/. Remove the grants, disable OpenAPI for anon and authenticator, move internal views out of public, and publish only tightly scoped surfaces that you actually intend to be public.
Row Level Security (RLS)
Introduction
RLS is the first class guardrail for tenant and user isolation in Supabase. It is evaluated inside Postgres for each statement and for each action. That means select, insert, update and delete each need their own policy logic or a very precise shared helper. When RLS is on everywhere and policies are specific, the rest of your stack becomes much safer. When a single table is left open or a catch all helper is used without care, the whole model gets fuzzy.
Treat RLS like your baseline. Enable it on every user facing table. Write policies that check the exact tenant or user, and test negative cases. Schema qualify any helper calls inside policies to avoid search_path surprises. Add explicit admin bypass paths where you really mean it rather than assuming. The teams that do this well use fewer helpers, more explicit checks and they sleep better.
Common Misconfigurations
RLS Disabled or Not Explictly Forced
Row Level Security in Postgres is table-level and predicate-based: you enable it per table and then add policies that decide which rows a role may SELECT/INSERT/UPDATE/DELETE Two important gotchas: (1) RLS is off by default; if you never run ALTER TABLE … ENABLE ROW LEVEL SECURITY, predicates never run. (2) The table owner bypasses RLS unless you FORCE ROW LEVEL SECURITY In Supabase estates we often see privileged SECURITY DEFINER functions (owned by the table owner) performing writes on "RLS-protected" tables. Without FORCE RLS, those definer functions execute as the owner and the RLS predicates are skipped, turning a helper into a silent bypass. From the REST edge this looks like a normal RPC; the break happens entirely inside the database because RLS was either disabled or bypassed by ownership rules.
We start by asking: which tables don’t have RLS enabled, and which aren’t forced? Then we enumerate definer functions that touch those tables.
-- 1) Inventory RLS state across exposed schemas
select n.nspname as schema,
c.relname as table,
c.relrowsecurity as rls_enabled,
c.relforcerowsecurity as rls_forced,
pg_get_userbyid(c.relowner) as owner
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where c.relkind = 'r'
and n.nspname in ('public','storage')
order by 1,2;
-- 2) List policies present (or missing) per table
select schemaname, tablename, policyname, permissive, roles, cmd, qual as using, with_check
from pg_policies
order by schemaname, tablename, policyname;
-- 3) Definer functions (potential bypass if they write to tables without FORCE RLS)
select n.nspname as schema, p.proname, pg_get_userbyid(p.proowner) as owner, p.prosecdef as is_definer
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where p.prosecdef = true
order by 1,2;
Recommendations
Enable and force RLS on all tenant/user data tables; keep definer helpers honest.
-- Turn on and force RLS (example for two tables)
alter table public.orders enable row level security;
alter table public.orders force row level security;
alter table public.messages enable row level security;
alter table public.messages force row level security;
-- Optional: change function owners so that definer functions run as a non-owner role
-- whose access is constrained by policies.
alter function public.upsert_order(...) owner to app_writer;
RLS on the View (Policy Lives on the Wrong Object)
RLS lives on tables, not views. Views can help shape/limit columns, but they don’t own RLS predicates; access to a view still results in scans of base tables where predicates must exist. We often see teams create a "safe view" and grant it broadly, while forgetting to enable/define RLS on the underlying tables. If those tables lack RLS (or have weak policies), the view grant publishes their data via REST or GraphQL This becomes especially confusing when a view name sounds guarded (e.g., customer_safe_view) and lands in public.
We start by asking: which views are broadly granted, and do their base tables actually have RLS+policies?
-- 1) Views granted to end-user roles
select v.table_schema, v.table_name as view_name, g.grantee
from information_schema.views v
join information_schema.table_privileges g
on g.table_schema = v.table_schema and g.table_name = v.table_name
where g.grantee in ('anon','authenticated')
order by 1,2,3;
-- 2) Base tables lacking RLS that are referenced by those views
with view_deps as (
select distinct
n.nspname as view_schema,
c.relname as view_name,
rn.nspname as base_schema,
rc.relname as base_table
from pg_rewrite r
join pg_class c on c.oid = r.ev_class
join pg_depend d on d.objid = r.oid
join pg_class rc on rc.oid = d.refobjid
join pg_namespace n on n.oid = c.relnamespace
join pg_namespace rn on rn.oid = rc.relnamespace
where c.relkind = 'v'
)
select vd.view_schema, vd.view_name, vd.base_schema, vd.base_table,
cls.relrowsecurity as rls_enabled
from view_deps vd
join pg_class cls on cls.relname = vd.base_table and cls.relnamespace = (
select oid from pg_namespace where nspname = vd.base_schema
)
order by 1,2,3,4;
Over-Broad Command Surface (Policies & Grants Beyond SELECT)
RLS is per command: you write separate policies for SELECT, INSERT, UPDATE, and DELETE PostgREST maps HTTP verbs directly to those commands (GET -> SELECT, POST -> INSERT, PATCH -> UPDATE, DELETE -> DELETE). Two things must be true for a write to succeed: the role must have the SQL privilege (e.g., INSERT), and the table must have an RLS policy that admits the operation. In real estates we often see teams add a tidy SELECT policy - but then also (a) leave broad grants like GRANT INSERT, UPDATE, DELETE on the table (or in default privileges), or (b) create a single policy FOR ALL thinking it "covers reads" unintentionally enabling writes for any role that has the grant. From the REST edge that translates into “your public client can now POST/PATCH/DELETE this table,” even if the business intent was read-only.
Think of it as two sliders: grants and policies. If either slider is pushed wide for writes, the other slider can complete the circuit. A forgotten default grant or an overly broad ALL policy is enough to turn a read-only surface into a write API. This compounds the write-pivot risks we’ve already covered (missing WITH CHECK, identity not bound), but even with perfect WITH CHECK you may simply not want end-user writes at all.
We start by asking three blunt questions: which end-user roles have write grants, where do non-SELECT policies exist, and did anyone set a FOR ALL policy? We also check default privileges, which silently apply to future tables.
-- 1) End-user roles with write privileges on tables (public/storage)
select grantee, table_schema, table_name, privilege_type
from information_schema.table_privileges
where grantee in ('anon','authenticated')
and table_schema in ('public','storage')
and privilege_type in ('INSERT','UPDATE','DELETE')
order by 1,2,3,4;
-- 2) Non-SELECT policies present for end-user access (potential write surface)
select schemaname, tablename, policyname, roles, cmd
from pg_policies
where cmd in ('INSERT','UPDATE','DELETE','ALL')
order by 1,2,3;
-- 3) Policies that use FOR ALL (easy to over-grant)
select schemaname, tablename, policyname, roles, cmd
from pg_policies
where cmd = 'ALL'
order by 1,2,3;
-- 4) Default privileges that would grant future write access by default
select pg_get_userbyid(d.defaclrole) as owner_role,
coalesce(n.nspname, '<all>') as schema,
d.defaclobjtype as objtype,
d.defaclacl as default_acl
from pg_default_acl d
left join pg_namespace n on n.oid = d.defaclnamespace
order by 1,2,3;
Recommendations
Start from read-only by default Only introduce write capability where it’s explicitly required - and then bind it tightly.
-- 1) Strip write privileges from internet-facing roles
revoke insert, update, delete on all tables in schema public from anon, authenticated;
revoke insert, update, delete on all tables in schema storage from anon, authenticated;
-- 2) Replace any FOR ALL policies with command-specific policies
-- (Example: keep SELECT only; drop unintended write policies)
drop policy if exists all_orders on public.orders;
drop policy if exists insert_orders_any on public.orders;
drop policy if exists update_orders_any on public.orders;
drop policy if exists delete_orders_any on public.orders;
create policy select_orders
on public.orders
for select to authenticated
using (tenant_id = (auth.jwt() ->> 'tenant_id')::uuid);
-- 3) Pin safe defaults for future objects (no surprise write grants)
alter default privileges in schema public
revoke insert, update, delete on tables from anon, authenticated;
alter default privileges in schema storage
revoke insert, update, delete on tables from anon, authenticated;
-- 4) (If writes are needed) grant narrowly and pair with tight RLS
-- grant insert on public.orders to authenticated;
-- create policy insert_orders ... with check (...bind to JWT...);
TL;DR (RLS): RLS protects what you enable and force, with the exact predicates you write. Avoid OR-holes, bind writes with WITH CHECK, put RLS on base tables (not just views), get your claims and casts right, and give the planner indexes so you don’t "temporarily" turn it off. As ever, PostgREST is literal: grants and policies become behaviour at /rest/v1/... make those predicates precise, enforced, and performant.
Recommendations
-- Enable/force on the base table powering the view
alter table public.customers enable row level security;
alter table public.customers force row level security;
-- Recreate the view with a barrier to avoid function side-channels
create or replace view public.customer_safe_view
with (security_barrier)
as
select id, name, tenant_id
from public.customers;
grant select on public.customer_safe_view to authenticated;Need to validate a real attack surface?
Scope an AI-augmented penetration test with our in-house team. Every reported issue is reproduced, evidenced and ready for remediation.
Speak to SalesFunctions, Triggers, RPC
Introduction
Functions and triggers are where your server side logic lives. PostgREST will happily expose any function in public as rpc That is a feature when you design for it and a footgun when helpers drift into public by habit. Security context matters. SECURITY INVOKER runs with the caller’s rights and cooperates with RLS SECURITY DEFINER runs with the owner’s rights and ignores RLS unless you are careful. If you must use a definer, pin the search_path and fully qualify every reference.
Adopt a simple pattern. Keep helpers in a private schema. Keep thin wrappers in public when you want RPC on purpose. Keep triggers in a private schema too. Avoid overload sprawl that makes grants inconsistent. The database will do exactly what you told it to do and that is both the strength and the trap. Our audits kept turning up definers without a pinned path and wrappers that exposed far more than the team intended.
Common Misconfigurations
Helpers in Public Exposed over RPC
PostgREST is a perfect mirror: anything in your Exposed Schemas that a role can EXECUTE becomes an HTTP endpoint at /rest/v1/rpc/<function> Teams often leave internal “helper” functions in public (parameter coercers, JSON shapers, bulk upserters). If the authenticated role or worse anon can execute them, those helpers become public RPCs. The impact ranges from clean RLS bypass (if the helper is SECURITY DEFINER owned by a table owner and you didn’t FORCE RLS) to quiet data shaping leaks that reveal structure or join paths. This also overlaps with our SSRF via http_get finding: if a helper forwards to the HTTP extension, the helper becomes a neat SSRF proxy.
We start by asking: what functions live in public, who can call them, and which run as DEFINER? Then we confirm from the REST edge that the risky ones are not callable.
-- 1) Functions in public and who can EXECUTE them
select p.proname as function,
pg_get_userbyid(p.proowner) as owner,
p.prosecdef as is_definer,
r.rolname as grantee
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace and n.nspname = 'public'
join pg_roles r on has_function_privilege(r.oid, p.oid, 'EXECUTE')
order by 1,4;
-- 2) Candidate helpers: writable, networked, or touching secrets (names/volatility hints)
select n.nspname as schema, p.proname, p.provolatile, pg_get_function_arguments(p.oid) as args
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and (p.proname ~* '(upsert|write|migrate|sync|http|secret|vault)');
Recommendations
- Move non-API helpers to a private schema (e.g., app_priv) that is not in Exposed Schemas.
- Revoke EXECUTE on helpers from PUBLIC, anon, authenticated; grant only to a server role if needed.
- For the few functions that are part of your public API, keep them small, SECURITY INVOKER, and bind them to RLS-safe queries; pin search_path.
-- Revoke broad execution
revoke execute on all functions in schema public from public, anon, authenticated;
-- Allow only explicit API functions (example)
grant execute on function public.get_public_docs() to anon;
-- Safer placement for internal helpers
create schema if not exists app_priv;
alter function public._bulk_upsert(...) set schema app_priv;
Excessive EXECUTE Grants (Including PUBLIC) & Default Privileges
By default, new functions in Postgres are executable by PUBLIC. In Supabase that often means everyone at the REST edge if the schema is exposed. We routinely find estates where teams carefully revoke table permissions but forget function grants; or they set broad default privileges that silently grant EXECUTE on every future function to authenticated. The result is a wide, evolving RPC surface that outlives the original intent. Even "harmless" helpers become powerful when combined with SECURITY DEFINER, missing FORCE RLS, or extension calls (e.g., pg_net).
We start by asking: who can execute what today, and what will future functions inherit?
-- 1) Current EXECUTE grants in exposed schemas
select n.nspname as schema, p.proname as function, r.rolname as grantee
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
join pg_roles r on has_function_privilege(r.oid, p.oid, 'EXECUTE')
where n.nspname in ('public','storage','http','net','cron')
order by 1,2,3;
-- 2) Default privileges that grant EXECUTE automatically in future
select pg_get_userbyid(d.defaclrole) as owner_role,
coalesce(n.nspname,'<all>') as schema,
d.defaclobjtype as objtype,
d.defaclacl as default_acl
from pg_default_acl d
left join pg_namespace n on n.oid = d.defaclnamespace
where d.defaclobjtype = 'f'
order by 1,2;
Recommendations
- Zero out function
EXECUTEforPUBLIC, anon, authenticated in exposed schemas; then grant back only the endpoints you mean to publish. - Lock default privileges so new functions don’t appear on the edge by accident.
revoke execute on all functions in schema public from public, anon, authenticated;
revoke execute on all functions in schema storage from public, anon, authenticated;
alter default privileges in schema public revoke execute on functions from public, anon, authenticated;
alter default privileges in schema storage revoke execute on functions from public, anon, authenticated;
Extensions
Introduction
Postgres extensions are your power ups. They add crypto functions, vector search, statistics and more. They also add risk. Network capable extensions like http and pg_net can make outbound calls. That is great for automation if you call them from a private role. It is a problem if a user can reach them through a public function or a loose grant.
Treat extensions like syscalls. Load only what you need. Keep them out of public Restrict EXECUTE to a narrow service role if you must use them at all. If you need to fetch from the internet, prefer doing that in an Edge Function where you can add allow lists, logs and rate limits. When we found full read SSRF last week it always started with an RPC wrapper that reached into http_get
Common Misconfigurations
Full Read SSRF via http / pg_net Extension Over RPC
In Supabase, the Data API (PostgREST) publishes everything in your Exposed Schemas as HTTP endpoints. That includes built-in tables/views in public, your own functions, and critically - extension functions that live in their own schemas. Network-capable extensions such as http (a simple HTTP client) and pg_net (schema net, async HTTP) let the database make outbound requests. If either schema is exposed and end-user roles (e.g., anon, authenticated) can EXECUTE those functions, then any anonymous or authenticated user can hit rest/v1/rpc/http_get or /rest/v1/rpc/net_http_get and make the DB fetch arbitrary URLs and return the full response body. That’s not a "blind ping" it’s full-read SSRF at your public edge, with your server’s egress identity, headers, and network reach.
Think of PostgREST as a perfect mirror: it doesn’t invent power; it forwards the privileges of the role implied by the key you send. If http/net are in Exposed Schemas and anon/authenticated can execute them, you’ve effectively shipped a general-purpose HTTP proxy. In client tests we recreated this by passing a crafted URL (and optional headers) to the RPC The database fetched the target and streamed the response back through PostgREST - JSON, HTML, binary, everything. From there, common escalations include reading internal admin panels, hitting cloud metadata/IP-restricted services, or exfiltrating data pulled from other high-privilege routines. This risk compounds with our earlier sections on Functions/RPC (grants -> endpoints) and Vault & Secrets (don’t combine secret reads with egress): one loose grant turns helpers into a powerful pivot.
We start by asking two blunt questions: are http/net installed and exposed, and who can call them? Then we try the same from the REST edge to prove (or refute) exposure.
-- 1) Are networked extensions installed?
select extname, n.nspname as schema
from pg_extension e
join pg_namespace n on n.oid = e.extnamespace
where extname in ('http','pg_net')
order by 1;
-- 2) Who can EXECUTE functions in those schemas?
select n.nspname as schema, p.proname as function, r.rolname as grantee
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
join pg_roles r on has_function_privilege(r.oid, p.oid, 'EXECUTE')
where n.nspname in ('http','net')
order by 1,2,3;
-- 3) Do end-user roles have USAGE on the schemas?
select grantee, schema_name, privilege_type
from information_schema.schema_privileges
where schema_name in ('http','net') and grantee in ('anon','authenticated')
order by 1,2;
-- 4) (Optional) Is PostgREST configured to expose these schemas?
-- In managed Supabase this is controlled by "Exposed Schemas", but you can also
-- inspect per-role GUCs if you use role-based config.
select rolname, unnest(rolconfig) as setting
from pg_roles
where rolname in ('authenticator','authenticated','anon')
and exists (
select 1 from unnest(rolconfig) c where c like 'pgrst.db_schemas=%'
);
From the REST edge you can confirm behaviour decisively. This should fail with 404/permission denied; if it returns a JSON body from the target, you have full-read SSRF.
# Attempt to call the extension directly via RPC (should be blocked)
curl -sS -X POST \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $ANON_KEY" \
-H "Content-Type: application/json" \
"https://<project>.supabase.co/rest/v1/rpc/http_get" \
--data '{"url":"https://httpbin.org/get"}'
# pg_net variant (should also be blocked)
curl -sS -X POST \
-H "apikey: $AUTHENTICATED_KEY" \
-H "Authorization: Bearer $JWT_FOR_AUTHENTICATED" \
-H "Content-Type: application/json" \
"https://<project>.supabase.co/rest/v1/rpc/net_http_get" \
--data '{"url":"https://httpbin.org/get"}'
Recommendations
Remove the HTTP client from your public edge. Keep egress in Edge Functions with explicit allowlists and boring logs.
-- 1) Revoke end-user access to networked schemas and functions
revoke usage on schema http from anon, authenticated;
revoke usage on schema net from anon, authenticated;
revoke execute on all functions in schema http from anon, authenticated;
revoke execute on all functions in schema net from anon, authenticated;
-- 2) Prevent future drift for newly created functions in those schemas
alter default privileges in schema http revoke execute on functions from anon;
alter default privileges in schema net revoke execute on functions from anon;
alter default privileges in schema http revoke execute on functions from authenticated;
alter default privileges in schema net revoke execute on functions from authenticated;
-- 3) Ensure only intended schemas are exposed via the Data API
-- (Role-based PostgREST GUC form; exclude 'http' and 'net')
alter role authenticator set pgrst.db_schemas = 'public,storage';
notify pgrst, 'reload config';
If you must keep a database wrapper for a narrow use-case (e.g., internal webhook verification), never expose the extension itself. Create a single SECURITY DEFINER wrapper in a non-exposed schema that:
- Pins search_path to known schemas to avoid name shadowing,
- Deny-lists IP literals and rejects loopback/RFC1918/metadata ranges,
- Allow-lists hosts (FQDNs you control) and restricts methods/paths,
- Caps timeouts and body size,
- Returns derived values only (e.g., a boolean or a small parsed field), never raw bodies,
- Is not granted to anon, and only to a minimal server role or tightly-scoped authenticated flow.
TL;DR: If http/net sit in Exposed Schemas and end-user roles can execute them, you’ve published an HTTP proxy at /rest/v1/rpc/*. Revoke the grants, remove the schemas from exposure, keep outbound calls in Edge with strict allowlists, and never combine secret reads with egress in the same routine.
Arbitrary SQL Scheduling via pg_cron Exposed over RPC
pg_cron lets Postgres run scheduled SQL in the background (think: cron for the database). Jobs are stored in cron.job and execute as the role that created them on the schedule you specify. In Supabase, PostgREST is a perfect mirror: anything in your Exposed Schemas with EXECUTE grants becomes reachable at /rest/v1/rpc/<function> If the cron schema is exposed and internet-facing roles (anon, authenticated) can call functions like cron.schedule(...), you’ve effectively given the public edge a way to create persistent background jobs. That’s not just noisy—it’s stealthy persistence, timed data exfiltration, resource abuse, and a write path that bypasses your API entirely. Even an "invoker" wrapper doesn’t help if the caller already has the needed privileges: RPC simply forwards role power.
Think of pg_cron as a capability amplifier: once published via RPC, anyone holding your public keys can plant a job that runs later, under a different context, without further HTTP traffic. We’ve seen estates where cron was added to Exposed Schemas during prototyping “to trigger maintenance,” then forgotten; combined with permissive grants, this turned into a durable backdoor. The fix is boring and decisive: keep cron off the public edge and restrict job control to a trusted admin role.
We start by asking: is pg_cron installed and exposed, who can call it, and are there already jobs scheduled? Then we prove (or refute) the exposure from the REST edge.
-- 1) Is pg_cron installed?
select extname, n.nspname as schema
from pg_extension e
join pg_namespace n on n.oid = e.extnamespace
where extname = 'pg_cron';
-- 2) Who can EXECUTE functions in schema cron?
select p.proname as function, r.rolname as grantee
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
join pg_roles r on has_function_privilege(r.oid, p.oid, 'EXECUTE')
where n.nspname = 'cron'
order by 1,2;
-- 3) Do internet-facing roles have USAGE on schema cron?
select grantee, schema_name, privilege_type
from information_schema.schema_privileges
where schema_name = 'cron' and grantee in ('anon','authenticated')
order by 1,2;
-- 4) Inventory existing jobs & owners (persistence signal)
select jobid, schedule, command, active, database, username
from cron.job
order by jobid;
From the REST edge, any attempt to schedule should fail (404/permission denied). If it succeeds, you’ve exposed job control.
# Endpoint name varies; if 'cron' is exposed, PostgREST will publish its functions.
# This SHOULD be blocked.
curl -sS -X POST \
-H "apikey: $AUTHENTICATED_KEY" \
-H "Authorization: Bearer $JWT_FOR_AUTHENTICATED" \
-H "Content-Type: application/json" \
"https://<project>.supabase.co/rest/v1/rpc/schedule" \
--data '{"schedule":"*/15 * * * *","command":"select now()"}'
Recommendations
Remove job control from the public edge. Schedule maintenance from a trusted server role only.
-- 1) Revoke end-user access to pg_cron
revoke usage on schema cron from anon, authenticated;
revoke execute on all functions in schema cron from anon, authenticated;
-- 2) Prevent future drift
alter default privileges in schema cron
revoke execute on functions from anon, authenticated;
-- 3) Ensure 'cron' is NOT in Exposed Schemas for PostgREST (Data API)
-- (Managed config or role GUCs; exclude 'cron' from pgrst.db_schemas)
-- Example if using role-based GUCs:
-- alter role authenticator set pgrst.db_schemas = 'public,storage';
-- notify pgrst, 'reload config';
-- 4) Clean up persistence: remove any stray jobs
-- Unschedule by id after review
-- select cron.unschedule(<jobid>);
If you truly need tenants to trigger background work, don’t expose pg_cron. Instead, accept a request into an Edge Function, write a row to a job queue table, and have a trusted worker (or a single admin cron job you own) dequeue and execute the work with strict validation and auditing. Keep cron for your maintenance windows, not as a multi-tenant feature.
Vault and Secrets
Introduction
In Supabase there are two places secrets live and they serve different purposes:
Vault (Supabase Vault — Alpha): a database-side secret store you access from SQL/PLpgSQL. Ideal when database functions and triggers need secrets (e.g., signing keys for server-side verification, provider webhooks handled inside Postgres). Because Vault reads happen inside the database, they must be tightly mediated (definer wrappers, pinned search_path, allowlists, audited reads). Never expose Vault helpers to browsers or broad RPC—one loose grant turns it into a decryption/secret oracle.
Edge Function secrets (environment variables): runtime variables injected into Edge Functions (Deno) via supabase secrets. Best for application logic at the edge—calling third-party APIs, verifying webhooks, or doing work that shouldn’t round-trip through SQL. Access with Deno.env.get(...) at runtime (not build time), scope per function, and keep logs/error messages boring so values never leak.
Think of secrets as hazardous materials: segregate by environment, minimise where they reside, and log reads—not values. Use Vault (Alpha) when the database truly needs the key; use Edge env secrets when the Function does. In both cases, never hardcode, never ship to the browser bundle, and never print during debugging.
Common Misconfigurations
Direct Grants on Vault Functions to End-User Roles
Supabase Vault is designed to centralise and securely manage application secrets. But like any schema, its functions are just Postgres routines under the hood — which means they can be granted EXECUTE to any role. If anon or authenticated have direct access, they can invoke vault.get_secret or similar helpers from the Data API and exfiltrate raw secrets straight from your project Vault
Think of Vault as a safe: it doesn’t matter how hardened it is if the door is left open. In a client environment, we tested this by sending an RPC call against /rest/v1/rpc/get_secret with an apikey for the authenticated role. The function executed without restriction, returning plaintext secrets that were intended only for server-side use.
-- Which roles can execute Vault functions?
select n.nspname as schema, p.proname as function, r.rolname as grantee
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
join pg_roles r on has_function_privilege(r.oid, p.oid, 'EXECUTE')
where n.nspname = 'vault'
order by 1,2,3;
Recommendations
Revoke Vault access from all end-user roles. Only the service_role or a tightly-scoped internal role should ever call Vault helpers.
revoke usage on schema vault from anon, authenticated;
revoke execute on all functions in schema vault from anon, authenticated;
Secrets in Edge Code, Logs, or Error Bodies
Even if Vault is correctly scoped, it is common to see secrets copied into Edge Functions or logged inadvertently. We reviewed client projects where the SUPABASE_SERVICE_ROLE_KEY appeared in console logs on error, or was embedded directly in the Edge source to simplify testing. This defeats Vault entirely: the secret is live in code, logs, or build artefacts accessible to devs or attackers.
# Grep through Edge code for sensitive keys
grep -R "service_role" ./supabase/functions/
grep -R "SUPABASE_" ./supabase/functions/
Recommendations
Never hardcode or log secrets in Edge Functions. Use Vault APIs to fetch at runtime, and wrap error handlers to sanitise output. Audit past logs for sensitive values, and rotate any keys that have been exposed.
Edge Functions
Introduction
Edge Functions are your tiny backends that run close to users on Supabase’s edge, powered by Deno and typically using @supabase/supabase-js They are not "just helpers." Every request that hits an Edge Function is a chance to enforce your own rules before the database ever sees a query - things like strict CORS, rate limiting, input validation, bot checks, and careful use of credentials. Done well, Edge Functions give you a strong security boundary in front of PostgREST and the database. Done loosely, they become a fast lane around RLS and a handy place to leak your service_role key. Moreover, neglecting rate limiting can quickly escalate your Supabase bill, as unchecked requests can lead to excessive resource consumption.
Think of Edge as a policy and posture layer: you decide whether a request should proceed, under which identity, and with what blast radius. That means your defaults matter - the default origin policy, the default token you pass to the DB, the default error messages you return when something goes wrong. Our most recent client audit surfaced the same mistakes over and over: service_role used for user-initiated routes, wildcard or reflected CORS, no rate limiting or abuse checks, and client-only captcha validation. Below we unpack each, show how we detected them, and share hardened patterns you can drop in.
Common Misconfigurations
service_role used for User-Initiated Routes
During a recent audit we observed that Edge Functions were being initialised with createClient(SUPABASE_URL, SERVICE_ROLE_KEY) Every downstream call ran with elevated rights, which sidestepped RLS entirely. In testing, requests without an Authorization header still returned private rows, which told us the function was running as a superuser in Supabase's context.
You can spot this quickly by grepping for SERVICE_ROLE and for clients created without forwarding the caller’s Authorization header.
rg -n "SERVICE_ROLE|service_role|createClient\(.*SERVICE_ROLE_KEY" functions/
rg -n "createClient\(.+ANON_KEY" -n functions/ --after-context 5
Recommendations
Forward the caller’s JWT and let RLS work for you. Reserve service_role for private workers and trusted webhooks only.
import { createClient } from "https://esm.sh/@supabase/supabase-js";
export default async function handler(req: Request) {
const auth = req.headers.get("authorization") ?? "";
const supabase = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("ANON_KEY")!, {
global: { headers: { Authorization: auth } }
});
const { data: { user } } = await supabase.auth.getUser();
if (!user) return new Response("Unauthorized", { status: 401 });
// proceed with user-scoped queries; RLS applies
}
Missing JWT Forwarding to Anonymous Context
Another pattern we observe is functions created with the ANON_KEY but never forwarding the caller’s Authorization. Developers often assume auth.getUser() will just work, but without forwarding the token, the client defaults to anon. This widens read access wherever anon is allowed, and removes all auditability.
We confirmed this in audits by sending the same request with and without a JWT — both responses returned identical data.
curl -sS https://functions.example.com/functions/v1/me
curl -sS -H "Authorization: Bearer $USER_JWT" https://functions.example.com/functions/v1/me
Recommendations
Always pass Authorization through to the Supabase client and require a user for routes that touch data. Return 401 on absence.
const auth = req.headers.get("authorization");
if (!auth) return new Response("Unauthorized", { status: 401 });
const supabase = createClient(URL, ANON_KEY, { global: { headers: { Authorization: auth } } });
Permissive or Reflected Cross-Origin Sharing (CORS)
We saw functions setting Access-Control-Allow-Origin: * or echoing back whatever Origin the browser sent. Combined with cookies or bearer tokens, that turns any hostile site into a reader of your responses. A preflight OPTIONS path often returned the same wildcard.
You can test this with a hostile origin and read the headers.
curl -i -H "Origin: https://evil.example" https://functions.example.com/functions/v1/checkout
Recommendations
Maintain an explicit allow list, echo only matches, set Vary: Origin, and handle preflight cleanly.
const ALLOWED = new Set(["https://app.example.com", "https://admin.example.com"]);
function cors(req: Request, res: Response) {
const origin = req.headers.get("origin") ?? "";
if (!ALLOWED.has(origin)) return new Response("Origin not allowed", { status: 403 });
const h = new Headers(res.headers);
h.set("Access-Control-Allow-Origin", origin);
h.set("Vary", "Origin");
h.set("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
h.set("Access-Control-Allow-Headers", "authorization, content-type");
return new Response(res.body, { status: res.status, headers: h });
}
export const OPTIONS = () =>
new Response(null, {
headers: {
"Access-Control-Allow-Origin": "https://app.example.com",
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
"Access-Control-Allow-Headers": "authorization, content-type",
"Vary": "Origin"
}
});
No Rate Limiting or Abuse Controls
In recent audits we consistently observed Supabase projects where critical functions had no protection against brute force or abusive traffic. Endpoints responsible for creating sessions, generating signed URLs, sending contact forms, or querying large datasets all responded identically whether hit once or hundreds of times in rapid succession. Every request returned 200, while the database absorbed the full workload. This not only makes credential stuffing trivial, but also turns APIs into high-bandwidth scrapers or amplification tools for attackers.
The absence of rate limiting undermines even strong authentication. For example, a login route with bcrypt hashing may still withstand individual password guesses, but when an attacker can fire tens of thousands of guesses per minute without slowdown, the system quickly collapses under load. Similarly, contact form endpoints can be flooded to send thousands of spam messages through legitimate channels. In one client engagement, we scripted repeated requests against a function that signed storage URLs — within minutes, we had valid links for every object in a bucket. None of these requests were flagged or throttled.
From an operational standpoint, unrestricted endpoints also create unnecessary noise. Logging pipelines swell with repeated identical requests, monitoring dashboards light up with false positives, and the underlying Postgres instance may hit connection or CPU exhaustion long before any user data is compromised. In short: without rate controls, a single untrusted client can degrade performance for everyone.
We validated this gap with simple load generation. Even basic tools like hey or ab demonstrate how quickly the system can be overwhelmed:
hey -n 500 -c 50 -H "Authorization: Bearer $USER_JWT" \
https://functions.example.com/functions/v1/login
Every request succeeded, no throttling occurred, and sensitive functions executed repeatedly without restriction.
Recommendations
Enforce a coarse limit at the edge proxy and a token-aware sliding window in the function. Key by auth.uid() when present, otherwise by IP.
import { Redis } from "https://esm.sh/@upstash/redis";
const redis = new Redis({ url: Deno.env.get("UPSTASH_URL")!, token: Deno.env.get("UPSTASH_TOKEN")! });
async function rateLimit(req: Request, userId?: string) {
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "0.0.0.0";
const key = `rl:${userId ?? "ip:"+ip}`;
const now = Math.floor(Date.now() / 1000);
await redis.zremrangebyscore(key, 0, now - 60);
await redis.zadd(key, { score: now, member: crypto.randomUUID() });
const count = await redis.zcard(key);
await redis.expire(key, 61);
if (count > 60) throw new Error("rate_limit");
}
Handle the exception and return 429 with a terse body.
Client-Side Captcha Only with No Server Verification
We found Turnstile widgets on forms, but the Edge Functions never called the verify API. Attackers posted directly to the function without a captcha token and received 200 responses.
curl -sS -X POST https://functions.example.com/functions/v1/contact \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","message":"hi"}'
Recommendations
Verify tokens server-side before any sensitive work and fail closed.
async function verifyTurnstile(token?: string) {
if (!token) return false;
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
body: new URLSearchParams({
secret: Deno.env.get("TURNSTILE_SECRET")!,
response: token
})
});
const out = await res.json();
return out.success === true;
}
Relying on “Verify JWT with Legacy Secret as Authorization Control
This toggle looks like a safeguard, but in reality it provides almost no meaningful access control. When enabled, the function only checks that the Authorization header contains a JWT signed with the project’s legacy secret. Both the anon key and the service_role key are JWTs signed with that same secret. In other words, anyone holding the public anon key can satisfy the check, regardless of identity or session state. No user claims are validated, and RLS is never enforced.
In recent client audits we observed this enabled across multiple functions. Requests sent with the anon key consistently returned 200, even when the function was intended to require an authenticated user. Teams believed they had a "JWT check" but in practice the function was only confirming that the token belonged to the same project. When handlers also used SERVICE_ROLE or failed to apply identity-based logic, the result was a clear path around row-level security.
# PoC: if this returns 200, the legacy-JWT check is meaningless
curl -i https://FUNCTIONS_HOST/functions/v1/send-contact-form \
-H "Authorization: Bearer $ANON_KEY"
Recommendations
This feature should almost always remain OFF. For authenticated app routes, require a real user JWT, verify it server-side, and run database queries under that identity so that RLS applies. For public, pre-auth flows (e.g., a landing page contact form), keep the toggle off and instead enforce strict CORS, rate limiting, and bot protection like Turnstile. For server-to-server integrations or webhooks, rely on HMAC signatures or IP allowlists — not the legacy secret — and avoid exposing those endpoints to the public edge wherever possible.
Need to validate a real attack surface?
Scope an AI-augmented penetration test with our in-house team. Every reported issue is reproduced, evidenced and ready for remediation.
Speak to SalesStorage
Introduction
Storage in Supabase is deceptively simple. It is Postgres on the inside with an object table called storage.objects, and your files are just rows with a name that looks like a path. There is no hidden magic in the path. Your RLS policy is the actual lock on the door, and a signed URL is a timed spare key you hand out. Get the policy wrong and users can read across tenants. Set the URL time to something generous and a leaked link becomes a long lived leak.
Think of Storage as a file system you model yourself. Put the user_id or tenant_id in a prediCTAble segment of the name, then write policies that check exactly that segment. Keep buckets private by default and prefer short signed URLs. The pattern is boring and that is the point. Every time we did a review last week, the mistakes were the same. Flat paths with no identity prefix, policies parsing the wrong folder, and signed URLs that lasted for hours.
Common Misconfigurations
Static Paths Without an Identity Prefix
We frequently see Supabase projects storing private files at flat or semi-structured paths like bucket/file.png or bucket/invoices/2025.pdf with no identity marker in the key. Without a tenant_id, user_id, or similar prefix in the object path, policies have nothing concrete to anchor access control to. The result is that policies end up either overly broad (“allow all authenticated users to select”) or brittle, relying on object names that do not map to identity.
In audits, this pattern let us enumerate or guess predictable keys and access other tenants’ files. Even when RLS was enabled on storage.objects, the lack of a path-based discriminator meant policies couldn’t reliably enforce ownership. One guessed filename was enough to cross boundaries and pull back another customer’s invoice.
-- Look for objects stored without tenant/user prefix
select id, bucket_id, name
from storage.objects
where name not like '%/%'
or (storage.foldername(name))[1] not in (select id from tenants);
Recommendations
Always enforce a strong identity prefix in storage paths. Structure keys so that the first path segment maps directly to the caller’s tenant or user identity, e.g. bucket/{tenant_id}/invoices/2025.pdf or bucket/{user_id}/avatars/photo.png This design gives RLS policies a consistent, queryable field to bind against.
Then, write row-level policies that deny by default and explicitly allow access only when the first path segment matches the current user’s claim. By anchoring policies to a stable identifier in the object key, you eliminate guessable static paths and ensure isolation between tenants.
-- Example: only allow users to insert under their own user_id prefix
create policy "user_write_own_prefix"
on storage.objects for insert to authenticated
with check (
(storage.foldername(name))[1] = (auth.jwt() ->> 'user_id')
);
In short, if object keys don’t encode identity, access control has no reliable guardrail. Prefix every path and bind every policy to it.
Buckets Set to Public or anon Granted Broad Read/Select Access
In Supabase, storage buckets can be marked public = true, or access can be granted to anon on storage.objects. In both cases the effect is the same: every object in that bucket becomes globally accessible through the Storage REST API, effectively turning it into a public CDN. This might feel safe when the bucket holds “just static assets,” but in real-world projects we often see the contents drift. File names leak user identity, business state, or metadata you didn’t intend to publish. What starts as harmless logos or CSS often grows into invoices, backups, or private media.
The risk is amplified by the fact that visibility is all-or-nothing inside a bucket. Once a bucket is public, you cannot carve out exceptions for specific objects. Teams frequently assume they can “just hide the sensitive stuff” in the same bucket, only to discover later that every file path was available without authentication. Mixed visibility requires either multiple buckets or strict, identity-aware row-level policies on storage.objects
-- Check which buckets are public
select id, name, public from storage.buckets;
Recommendations
Buckets should be private by default. Treat any public = true setting as a special case, applied only when content is genuinely intended for world-readable distribution. For private content, always introduce a strong identity prefix in object keys (e.g., bucket/{tenant_id}/... or bucket/{user_id}/...) so that RLS can reliably bind the caller’s identity to the path. This design prevents cross-tenant enumeration and enforces scoping through policy.
Row-Level Security policies should then deny-by-default, only granting access when the first path segment matches the authenticated user or tenant. This keeps file operations tightly bound to identity, whether for reads or writes. If mixed visibility is unavoidable, create separate buckets for public and private files rather than relying on exceptions within a single bucket.
-- Example RLS: users can only read files under their own tenant prefix
create policy "tenant_read_own_prefix"
on storage.objects for select to authenticated
using (
bucket_id = 'private-bucket'
and (storage.foldername(name))[1] = (auth.jwt() ->> 'tenant_id')
);
In short: avoid broad grants to anon, scope keys with identity prefixes, and make bucket privacy an explicit, deliberate choice rather than the default.
Long Lived Signed URLs and Proxy Caching that Ignores Expiry
Supabase Storage supports signed URLs as a lightweight access control: a tokenised link with an expiry timestamp. In practice, we routinely see apps generating signed URLs with multi-hour or even day-long TTLs. That problem compounds when a reverse proxy or CDN in front ignores the expiry and caches the response. The application believes a URL is expired, but the edge keeps replaying the cached object. A token leak in that scenario is no longer “temporary” — it’s effectively a permanent backdoor until the cache evicts.
Think of signed URLs as credentials: if they live too long or aren’t respected by the caching layer, the entire model collapses. We’ve validated this by issuing a URL with a 1-hour TTL, waiting for expiry, and still retrieving the asset through the proxy due to cache hit. From an attacker’s perspective, one leaked link can mean long-term access to sensitive files, regardless of expiry.
-- Inspect recent signed URL creation logs for excessive TTLs
select id, created_at, expires_at, (expires_at - created_at) as ttl
from storage.signed_urls
order by created_at desc
limit 50;
# Re-test expired signed URL against proxy/CDN after expiry time
curl -I "https://<project>.supabase.co/storage/v1/object/sign/bucket/file.png?token=abc123"
Recommendations
To mitigate this issue, signed URLs should be treated as short-lived secrets rather than long-term access keys. Configure them with minimal TTLs — ideally just a few minutes — and align your proxy or CDN behaviour so that responses containing tokenised query strings are never cached. This ensures that when the application considers a URL expired, the edge layer does too. For particularly sensitive or high-value assets, signed URLs should be avoided altogether in favour of authenticated downloads that require a fresh user JWT on every request, ensuring access is continuously validated against the user’s current session and permissions.
Conclusion
Supabase has rapidly become a go-to platform for developers who want to move fast, but it is still a long way from being secure by default. The defaults often prioritise convenience over caution, and with the rise of "vibe-coding" — spinning up projects quickly without deep knowledge of Postgres security — it’s easy to overlook critical gaps.
Many of the findings we’ve highlighted stem from subtle misconfigurations rather than outright negligence. The distinction between anon and authenticated roles, for instance, is not always intuitive, and we routinely see projects where these roles inherit privileges far beyond what was intended. Combine that with permissive RLS policies, exposed extensions, and inconsistent Vault usage, and you end up with an attack surface that’s wide open to abuse.
The takeaway is not that Supabase is inherently insecure, but that developers cannot assume the platform will enforce strong security on their behalf. Proper configuration, careful use of roles, and ongoing monitoring are essential. Supabase makes it possible to build secure apps — but only if security is treated as a first-class concern, not an afterthought.
Get started
Need professional security testing?
Speak directly with our team about the risks, scope and testing approach that matter to your organisation.
More Articles
Internal Penetration Testing: Scope and Methods
Plan an internal penetration test around identity, segmentation and critical assets, with practical guidance on scope, access, evidence, reporting and retesting.
How Often Should Penetration Testing Be Done?
Learn when annual, quarterly and change-triggered penetration testing make sense, with a practical risk-based schedule for UK organisations.