#!/bin/sh # # Test API key types and asymmetric auth against a running self-hosted instance. # # Usage: # sh test-auth-keys.sh # Uses http://localhost:8000 # sh test-auth-keys.sh # Custom URL # # Prerequisites: # - Running self-hosted Supabase instance # - .env file with all keys configured # - jq (for JSON parsing) # - node >= 16 (for HS256 token minting test only) # set -e BASE_URL="${1:-http://localhost:8000}" if [ ! -f .env ]; then echo "Error: .env file not found. Run from the project directory." exit 1 fi for cmd in jq node; do if ! command -v $cmd >/dev/null 2>&1; then echo "Error: $cmd not found." exit 1 fi done # Read keys from .env JWT_SECRET=$(grep '^JWT_SECRET=' .env | cut -d= -f2-) ANON_KEY=$(grep '^ANON_KEY=' .env | cut -d= -f2-) SERVICE_ROLE_KEY=$(grep '^SERVICE_ROLE_KEY=' .env | cut -d= -f2-) SUPABASE_PUBLISHABLE_KEY=$(grep '^SUPABASE_PUBLISHABLE_KEY=' .env | cut -d= -f2-) SUPABASE_SECRET_KEY=$(grep '^SUPABASE_SECRET_KEY=' .env | cut -d= -f2-) pass=0 fail=0 check() { test_name="$1" expected="$2" actual="$3" if [ "$actual" = "$expected" ]; then echo " PASS: $test_name (HTTP $actual)" pass=$((pass + 1)) else echo " FAIL: $test_name (expected $expected, got $actual)" fail=$((fail + 1)) fi } http_status() { url="$1" shift curl -s -o /dev/null -w "%{http_code}" "$@" "$url" } # Like http_status, but appends " sdk" when the response carries the # @supabase/server error header, i.e. the gateway passed the request through # and the function itself rejected it. fn_status() { url="$1" shift out=$(curl -s -o /dev/null -D - -w '\n%{http_code}' "$@" "$url") code=$(printf '%s\n' "$out" | tail -n 1) if printf '%s\n' "$out" | grep -qi '^x-supabase-server-error:'; then echo "$code sdk" else echo "$code" fi } # Detect which gateway is running so gateway-specific assertions can be gated. detect_gateway() { command -v docker >/dev/null 2>&1 || { echo unknown; return; } running=$(docker ps --format '{{.Names}}' 2>/dev/null) if printf '%s\n' "$running" | grep -q '^supabase-envoy$'; then echo envoy elif printf '%s\n' "$running" | grep -q '^supabase-kong$'; then echo kong else echo unknown fi } GATEWAY=$(detect_gateway) echo "" echo "=== Testing against $BASE_URL ===" echo "" # --------------------------------------------- # 1. Route tests with API key types # --------------------------------------------- echo "--- REST API (/rest/v1/) ---" check "Legacy ANON_KEY -> 403" "403" \ "$(http_status "$BASE_URL/rest/v1/" -H "apikey: $ANON_KEY")" check "Legacy SERVICE_ROLE_KEY" "200" \ "$(http_status "$BASE_URL/rest/v1/" -H "apikey: $SERVICE_ROLE_KEY")" if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then check "New PUBLISHABLE_KEY -> 403" "403" \ "$(http_status "$BASE_URL/rest/v1/" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")" check "New SECRET_KEY" "200" \ "$(http_status "$BASE_URL/rest/v1/" -H "apikey: $SUPABASE_SECRET_KEY")" else echo " SKIP: Opaque keys not configured" fi check "No key -> 401" "401" \ "$(http_status "$BASE_URL/rest/v1/")" check "Invalid key -> 401" "401" \ "$(http_status "$BASE_URL/rest/v1/" -H "apikey: invalid-key")" echo "" echo "--- Auth (/auth/v1/settings) ---" check "Legacy ANON_KEY" "200" \ "$(http_status "$BASE_URL/auth/v1/settings" -H "apikey: $ANON_KEY")" if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then check "New PUBLISHABLE_KEY" "200" \ "$(http_status "$BASE_URL/auth/v1/settings" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")" fi check "No key -> 401" "401" \ "$(http_status "$BASE_URL/auth/v1/settings")" echo "" echo "--- Storage (/storage/v1/bucket) ---" # Storage has no key-auth - passes through, Storage returns its own errors check "No key -> not 401 (Storage handles auth)" "true" \ "$([ "$(http_status "$BASE_URL/storage/v1/bucket")" != "401" ] && echo true || echo false)" check "Legacy ANON_KEY" "200" \ "$(http_status "$BASE_URL/storage/v1/bucket" -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY")" if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then # With opaque key, the API gateway translates to asymmetric JWT in Authorization check "New PUBLISHABLE_KEY" "200" \ "$(http_status "$BASE_URL/storage/v1/bucket" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")" fi echo "" echo "--- Storage S3 (/storage/v1/s3/) ---" # S3 uses AWS SigV4 auth (not apikey) - the request-transformer Lua expression # passes the Authorization header through unchanged for non-sb_ values check "S3 route accessible" "true" \ "$([ "$(http_status "$BASE_URL/storage/v1/s3/")" != "502" ] && echo true || echo false)" echo "" echo "--- GraphQL (/graphql/v1) ---" check "Legacy ANON_KEY" "200" \ "$(http_status "$BASE_URL/graphql/v1" \ -H "apikey: $ANON_KEY" \ -H "Content-Type: application/json" \ -d '{"query":"{ __typename }"}')" if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then check "New PUBLISHABLE_KEY" "200" \ "$(http_status "$BASE_URL/graphql/v1" \ -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ -H "Content-Type: application/json" \ -d '{"query":"{ __typename }"}')" fi check "No key -> 401" "401" \ "$(http_status "$BASE_URL/graphql/v1" \ -H "Content-Type: application/json" \ -d '{"query":"{ __typename }"}')" echo "" echo "--- Realtime REST (/realtime/v1/api/) ---" # Realtime REST API - use /api/ping to verify key auth (expect 200 with a valid key) check "Legacy ANON_KEY -> 200" "200" \ "$(http_status "$BASE_URL/realtime/v1/api/ping" -H "apikey: $ANON_KEY")" if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then check "New PUBLISHABLE_KEY -> 200" "200" \ "$(http_status "$BASE_URL/realtime/v1/api/ping" -H "apikey: $SUPABASE_PUBLISHABLE_KEY")" fi check "No key -> 401" "401" \ "$(http_status "$BASE_URL/realtime/v1/api/ping")" # Management endpoints must be blocked at the gateway (even with a valid key) check "/api/tenants blocked -> 403" "403" \ "$(http_status "$BASE_URL/realtime/v1/api/tenants" -H "apikey: $ANON_KEY")" check "/api/openapi blocked -> 403" "403" \ "$(http_status "$BASE_URL/realtime/v1/api/openapi" -H "apikey: $ANON_KEY")" echo "" echo "--- Edge Functions (/functions/v1/) ---" # hello uses withSupabase({ auth: ["publishable", "secret"] }), which requires # an sb_ key in the apikey header. "401 sdk" means the gateway passed the # request through and the function rejected it; a bare "401" is the gateway. check "No auth -> passed to function, rejected by SDK" "401 sdk" \ "$(fn_status "$BASE_URL/functions/v1/hello" -X POST -d '{}')" # Non-sb_ values (legacy JWT, typo, third-party JWT) are not validated at the gateway. check "Legacy ANON_KEY -> passed to function, rejected by SDK" "401 sdk" \ "$(fn_status "$BASE_URL/functions/v1/hello" -X POST -H "apikey: $ANON_KEY" -d '{}')" check "Non-sb_ invalid apikey -> passed to function, rejected by SDK" "401 sdk" \ "$(fn_status "$BASE_URL/functions/v1/hello" -X POST -H "apikey: invalid-key" -d '{}')" if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then check "PUBLISHABLE_KEY -> hello reachable" "200" \ "$(fn_status "$BASE_URL/functions/v1/hello" -X POST -H "apikey: $SUPABASE_PUBLISHABLE_KEY" -d '{}')" check "SECRET_KEY -> hello reachable" "200" \ "$(fn_status "$BASE_URL/functions/v1/hello" -X POST -H "apikey: $SUPABASE_SECRET_KEY" -d '{}')" # sb_ in Authorization only: the gateway translates it and passes it through, # but the SDK only accepts sb_ keys in the apikey header. check "sb_ in Authorization only -> passed to function, rejected by SDK" "401 sdk" \ "$(fn_status "$BASE_URL/functions/v1/hello" -X POST -H "Authorization: Bearer $SUPABASE_PUBLISHABLE_KEY" -d '{}')" # Invalid sb_-prefixed key and apikey/bearer sb_ conflict are rejected at the # gateway - but only by Envoy. Kong is permissive and passes them through. if [ "$GATEWAY" = "envoy" ]; then check "Invalid sb_ apikey -> 401 at gateway (Envoy)" "401" \ "$(fn_status "$BASE_URL/functions/v1/hello" -X POST -H "apikey: sb_publishable_0000000000000000000000_00000000" -d '{}')" check "Conflicting sb_ keys -> 401 at gateway (Envoy)" "401" \ "$(fn_status "$BASE_URL/functions/v1/hello" -X POST -H "apikey: $SUPABASE_SECRET_KEY" -H "Authorization: Bearer $SUPABASE_PUBLISHABLE_KEY" -d '{}')" else check "Invalid sb_ apikey -> passed to function, rejected by SDK (Kong)" "401 sdk" \ "$(fn_status "$BASE_URL/functions/v1/hello" -X POST -H "apikey: sb_publishable_0000000000000000000000_00000000" -d '{}')" fi fi echo "" echo "--- supabase-js style requests (apikey + Authorization) ---" # supabase-js sends both apikey header AND Authorization: Bearer if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then check "apikey + Authorization: Bearer sb_ (replace path)" "403" \ "$(http_status "$BASE_URL/rest/v1/" \ -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ -H "Authorization: Bearer $SUPABASE_PUBLISHABLE_KEY")" check "secret apikey + Authorization: Bearer sb_secret" "200" \ "$(http_status "$BASE_URL/rest/v1/" \ -H "apikey: $SUPABASE_SECRET_KEY" \ -H "Authorization: Bearer $SUPABASE_SECRET_KEY")" fi check "Legacy apikey + Authorization: Bearer " "403" \ "$(http_status "$BASE_URL/rest/v1/" \ -H "apikey: $ANON_KEY" \ -H "Authorization: Bearer $ANON_KEY")" check "Service role apikey + Authorization: Bearer " "200" \ "$(http_status "$BASE_URL/rest/v1/" \ -H "apikey: $SERVICE_ROLE_KEY" \ -H "Authorization: Bearer $SERVICE_ROLE_KEY")" echo "" echo "--- Edge cases ---" # Opaque key in Authorization only (no apikey header) - should be rejected by key-auth if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then check "sb_ in Authorization only (no apikey) -> 401" "401" \ "$(http_status "$BASE_URL/rest/v1/" \ -H "Authorization: Bearer $SUPABASE_PUBLISHABLE_KEY")" fi echo "" echo "--- JWKS endpoint ---" check "JWKS public endpoint (no auth)" "200" \ "$(http_status "$BASE_URL/auth/v1/.well-known/jwks.json")" # Verify JWKS content: should have EC key, should NOT have symmetric key jwks_content=$(curl -s "$BASE_URL/auth/v1/.well-known/jwks.json") jwks_has_ec=$(echo "$jwks_content" | jq -r '[.keys[] | .kty] | if any(. == "EC") then "true" else "false" end' 2>/dev/null) jwks_has_oct=$(echo "$jwks_content" | jq -r '[.keys[] | .kty] | if any(. == "oct") then "true" else "false" end' 2>/dev/null) check "JWKS contains EC public key" "true" "$jwks_has_ec" check "JWKS does NOT contain symmetric key" "false" "$jwks_has_oct" #echo "" #echo "--- OAuth metadata endpoint ---" #check "well-known oauth (no auth)" "200" \ # "$(http_status "$BASE_URL/.well-known/oauth-authorization-server")" echo "" echo "--- Realtime WebSocket upgrade ---" # Test that WebSocket upgrade request gets through (expect 101 or non-401) # curl --max-time to prevent hanging on successful upgrade (101 keeps connection open) ws_status=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 \ "$BASE_URL/realtime/v1/websocket?apikey=$ANON_KEY&vsn=1.0.0" \ -H "Upgrade: websocket" \ -H "Connection: Upgrade" \ -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ -H "Sec-WebSocket-Version: 13" 2>/dev/null || echo "000") # 101 = upgrade success, 000 = timeout (connection stayed open = success) check "WebSocket upgrade with legacy key -> not 401" "true" \ "$([ "$ws_status" != "401" ] && echo true || echo false)" if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then ws_status_new=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 \ "$BASE_URL/realtime/v1/websocket?apikey=$SUPABASE_PUBLISHABLE_KEY&vsn=1.0.0" \ -H "Upgrade: websocket" \ -H "Connection: Upgrade" \ -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ -H "Sec-WebSocket-Version: 13" 2>/dev/null || echo "000") check "WebSocket upgrade with opaque key -> not 401" "true" \ "$([ "$ws_status_new" != "401" ] && echo true || echo false)" fi # --------------------------------------------- # 2. User session JWT tests # --------------------------------------------- echo "" echo "--- User session JWT ---" # Create user via admin API (works regardless of email autoconfirm setting) test_email="test-keys-$$@example.com" test_password="test-password-123456" create_resp=$(curl -s "$BASE_URL/auth/v1/admin/users" \ -X POST \ -H "apikey: $SERVICE_ROLE_KEY" \ -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ -H "Content-Type: application/json" \ -d "{\"email\":\"$test_email\",\"password\":\"$test_password\",\"email_confirm\":true}") test_user_id=$(echo "$create_resp" | jq -r '.id // empty' 2>/dev/null) # Sign in to get session JWT auth_response=$(curl -s "$BASE_URL/auth/v1/token?grant_type=password" \ -H "apikey: $ANON_KEY" \ -H "Content-Type: application/json" \ -d "{\"email\":\"$test_email\",\"password\":\"$test_password\"}") access_token=$(echo "$auth_response" | jq -r '.access_token // empty' 2>/dev/null) if [ -n "$access_token" ]; then # Check the algorithm in the JWT header jwt_alg=$(echo "$access_token" | cut -d. -f1 | \ jq -Rr '@base64d | fromjson | .alg // empty' 2>/dev/null) if [ -n "$jwt_alg" ]; then echo " INFO: User session JWT signed with: $jwt_alg" if [ "$jwt_alg" = "ES256" ]; then check "JWT uses ES256 (asymmetric)" "ES256" "$jwt_alg" else check "JWT uses HS256 (legacy)" "HS256" "$jwt_alg" fi fi # Use the session JWT with PostgREST check "Session JWT with PostgREST" "403" \ "$(http_status "$BASE_URL/rest/v1/" \ -H "apikey: $ANON_KEY" \ -H "Authorization: Bearer $access_token")" check "Session JWT with PostgREST + service role key" "200" \ "$(http_status "$BASE_URL/rest/v1/" \ -H "apikey: $SERVICE_ROLE_KEY" \ -H "Authorization: Bearer $access_token")" # Use the session JWT with Storage check "Session JWT with Storage" "200" \ "$(http_status "$BASE_URL/storage/v1/bucket" \ -H "apikey: $ANON_KEY" \ -H "Authorization: Bearer $access_token")" # CRITICAL: Authenticated user + opaque key (most common supabase-js flow) # supabase-js sends apikey: sb_publishable_xxx AND Authorization: Bearer # The expression MUST keep the user JWT and NOT replace it with the anon asymmetric JWT if [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then echo "" echo "--- Authenticated user + opaque key (critical path) ---" check "Opaque apikey + user JWT -> PostgREST uses user JWT" "403" \ "$(http_status "$BASE_URL/rest/v1/" \ -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ -H "Authorization: Bearer $access_token")" check "Secret apikey + user JWT -> PostgREST allowed" "200" \ "$(http_status "$BASE_URL/rest/v1/" \ -H "apikey: $SUPABASE_SECRET_KEY" \ -H "Authorization: Bearer $access_token")" check "Opaque apikey + user JWT -> Storage uses user JWT" "200" \ "$(http_status "$BASE_URL/storage/v1/bucket" \ -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ -H "Authorization: Bearer $access_token")" check "Opaque apikey + user JWT -> Auth uses user JWT" "200" \ "$(http_status "$BASE_URL/auth/v1/user" \ -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ -H "Authorization: Bearer $access_token")" # Functions leaves Authorization (the user JWT) untouched and adds the # translated sb-api-key alongside it; the request must reach the worker. check "Opaque apikey + user JWT -> Functions reachable" "200" \ "$(http_status "$BASE_URL/functions/v1/hello" -X POST \ -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ -H "Authorization: Bearer $access_token" \ -d '{}')" fi else check "Sign in test user" "true" "false" fi # Clean up test user if [ -n "$test_user_id" ]; then curl -s -o /dev/null "$BASE_URL/auth/v1/admin/users/$test_user_id" \ -X DELETE \ -H "apikey: $SERVICE_ROLE_KEY" \ -H "Authorization: Bearer $SERVICE_ROLE_KEY" fi # --------------------------------------------- # 3. HS256 backward compatibility # --------------------------------------------- echo "" echo "--- HS256 backward compatibility ---" # Mint a legacy HS256 JWT with role=anon (simulating a pre-migration token) hs256_token=$(JWT_SECRET="$JWT_SECRET" node -e " const crypto = require('crypto'); const header = Buffer.from(JSON.stringify({alg:'HS256',typ:'JWT'})).toString('base64url'); const payload = Buffer.from(JSON.stringify({ role:'anon',iss:'supabase', iat:Math.floor(Date.now()/1000), exp:Math.floor(Date.now()/1000)+3600 })).toString('base64url'); const sig = crypto.createHmac('sha256',process.env.JWT_SECRET) .update(header+'.'+payload).digest('base64url'); console.log(header+'.'+payload+'.'+sig); " 2>/dev/null) if [ -n "$hs256_token" ]; then check "HS256 token with PostgREST (backward compat)" "403" \ "$(http_status "$BASE_URL/rest/v1/" \ -H "apikey: $ANON_KEY" \ -H "Authorization: Bearer $hs256_token")" check "HS256 token with PostgREST + service role key" "200" \ "$(http_status "$BASE_URL/rest/v1/" \ -H "apikey: $SERVICE_ROLE_KEY" \ -H "Authorization: Bearer $hs256_token")" else echo " SKIP: Could not mint HS256 token (node required)" fi # --------------------------------------------- # 4. JWT_KEYS format validation # --------------------------------------------- echo "" echo "--- JWT_KEYS format ---" JWT_KEYS_VAL=$(grep '^JWT_KEYS=' .env | cut -d= -f2-) if [ -n "$JWT_KEYS_VAL" ]; then # Auth expects a JSON array, not a JWKS object jwt_keys_is_array=$(echo "$JWT_KEYS_VAL" | jq -r 'if type == "array" then "true" else "false" end' 2>/dev/null) check "JWT_KEYS is JSON array (not JWKS object)" "true" "$jwt_keys_is_array" jwt_keys_has_sign=$(echo "$JWT_KEYS_VAL" | jq -r 'if any(.[]; .key_ops and (.key_ops | index("sign"))) then "true" else "false" end' 2>/dev/null) check "JWT_KEYS has a signing key (key_ops: sign)" "true" "$jwt_keys_has_sign" else echo " SKIP: JWT_KEYS not configured" fi # --------------------------------------------- # Summary # --------------------------------------------- echo "" echo "=== Results: $pass passed, $fail failed ===" echo "" if [ "$fail" -gt 0 ]; then exit 1 fi