Supabase API Key Rotation Playbook
Status: Verified 2026-07-05 (issue #2882 — secrets-manager role)
Supabase project: mwkqmgadqnkkihjdeqsi
SOPS access token key: ROLE_SECRET_MANAGER_SUPABASE_ACCESS_TOKEN in secrets/role-secret-manager.env.sops
TL;DR — Verdict on the Proposed Auto-Rotation Approach
| Item | Proposed | Verdict | Correct value |
|---|---|---|---|
| API base URL | https://supabase.com{ref}/api-keys | WRONG | https://api.supabase.com/v1/projects/{ref}/api-keys |
| Key type for services | publishable | PARTIALLY WRONG | Services use secret keys; publishable = anon/public only |
| Distribution method | PyGithub to GH Secrets | WRONG for our infra | SOPS+age committed → secrets-sync.yml auto-deploys |
| Zero-downtime 7-day overlap | generate B, run both, delete A | CORRECT | Strategy is sound |
| DELETE endpoint | /v1/projects/{ref}/api-keys/{key_id} | CORRECT | Confirmed via live test |
Supabase Management API — Verified Endpoints
All endpoints require Authorization: Bearer <SUPABASE_ACCESS_TOKEN> header.
Base URL: https://api.supabase.com/v1/projects/<project_ref>
| Operation | Method | Endpoint | HTTP status | Notes |
|---|---|---|---|---|
| List all keys | GET | /api-keys | 200 | Returns array with id, name, type |
| Create key | POST | /api-keys | 201 | Body: {"name":"...","type":"..."} |
| Delete key | DELETE | /api-keys/<key_id> | 200 | Uses UUID id from list response |
Tested 2026-07-05 against project mwkqmgadqnkkihjdeqsi — all three operations confirmed working.
Key naming constraints (validated via live API)
The API rejects names that do not match ^[a-z_][a-z0-9_]*$:
{
"message": "name: Name must start with a lowercase letter or an underscore,
followed only by lowercase alphanumeric characters or underscore"
}
- Must start with a lowercase letter or underscore
- Followed only by lowercase letters, digits, or underscores
- NO hyphens, NO uppercase letters, NO spaces
Valid: p24_service_20260705, monitoring_key, n8n_bms4
Invalid: test-rotation-2882-DELETE-ME (hyphens + uppercase — returns HTTP 400)
Key Types — What We Use and What Can Be Rotated
Types returned by GET /api-keys
| Type | Supabase meaning | Value prefix | Rotatable via API | Notes |
|---|---|---|---|---|
legacy | Built-in anon / service_role | eyJhbGciO... (JWT) | NO | Created at project init; cannot be deleted |
publishable | Named anon keys (safe to expose in clients) | sb_publishable_... | YES | Use for NEXT_PUBLIC_SUPABASE_ANON_KEY |
secret | Named service-role level keys | sb_secret_... | YES | Use for SUPABASE_SERVICE_ROLE_KEY |
Our actual key inventory (2026-07-05, key names and IDs only — no values)
| Supabase key name | Type | SOPS key name | Consumer |
|---|---|---|---|
anon | legacy | — | Do not use; use named publishable key instead |
service_role | legacy | — | Do not use; use named secret key instead |
supabase_anon_key | publishable | NEXT_PUBLIC_SUPABASE_ANON_KEY | et-operational-platform (Vercel) |
p24_wap_android_20260629 | publishable | SUPABASE_WAP_ANON_KEY | monitoring.env.sops, WhatsApp Android client |
et_email_dispo_ai_agent | secret | — | AI agent email disposal workflow |
et_fibu_folders_sp_logs | secret | — | FIBU folders service-principal logs |
p24_monitoring_20260703 | secret | SUPABASE_SERVICE_KEY | monitoring.env.sops → vps-i1 exporters |
p24_n8n_20260703 | secret | — | n8n on bms-4 (in n8n-bms4.env.sops) |
p24_vercel_20260703 | secret | SUPABASE_SERVICE_ROLE_KEY | et-operational-platform.env.sops (Vercel) |
p24_service_role_20260705c | secret | SUPABASE_SERVICE_ROLE_KEY | monitoring.env.sops → Grafana, exporters |
Note on legacy keys: The built-in
anonandservice_rolelegacy keys cannot be rotated via the Management API. Do not use them in new integrations. Use namedpublishableandsecretkeys — these are fully rotatable.
SOPS-Compatible Rotation Procedure
Zero-downtime strategy
Day 0: Create Key_B (new) → update SOPS → commit → secrets-sync.yml → deploy
Day 0-7: Key_A (old) and Key_B (new) both active — overlap window
Day 7: Verify Key_B is live everywhere → DELETE Key_A via Management API
Phase 1 — Create the new key (PowerShell, Windows dev machine)
# Set SOPS key
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
# Extract access token safely — NEVER print
$env:SUPABASE_ACCESS_TOKEN = (sops --decrypt --input-type dotenv --output-type dotenv `
secrets\role-secret-manager.env.sops |
Where-Object { $_ -match "^ROLE_SECRET_MANAGER_SUPABASE_ACCESS_TOKEN=" }
).ToString().Split("=", 2)[1].Trim()
# List existing keys first — note old key UUID before creating new one
$headers = @{Authorization = "Bearer $env:SUPABASE_ACCESS_TOKEN"; "Content-Type" = "application/json"}
$list = Invoke-RestMethod -Uri "https://api.supabase.com/v1/projects/mwkqmgadqnkkihjdeqsi/api-keys" `
-Headers $headers -Method GET
$list | ForEach-Object { Write-Host "name='$($_.name)' id='$($_.id)' type='$($_.type)'" }
# Note the UUID of the key being replaced — needed for DELETE on Day 7
# Create new key — name format: <scope>_<YYYYMMDD>
# Replace "p24_vercel" with the appropriate scope for the key being rotated
$keyName = "p24_vercel_$(Get-Date -Format 'yyyyMMdd')"
# Use "secret" for service-role level keys; "publishable" for anon/public keys
$createBody = @{name = $keyName; type = "secret"} | ConvertTo-Json
$created = Invoke-RestMethod -Uri "https://api.supabase.com/v1/projects/mwkqmgadqnkkihjdeqsi/api-keys" `
-Headers $headers -Method POST -Body $createBody -ContentType "application/json"
Write-Host "Created: id='$($created.id)', name='$($created.name)' — store id for later DELETE"
# Pass new value directly to SOPS update — NEVER print it
$env:NEW_SUPABASE_KEY = $created.api_key
# Clear access token and headers
$env:SUPABASE_ACCESS_TOKEN = ""
$headers = @{}Phase 2 — Update the SOPS file
Follow the update-existing-key pattern from docs/playbooks/sops-edit-operations.md.
Example for et-operational-platform.env.sops (SUPABASE_SERVICE_ROLE_KEY):
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
$SOPS_FILE = "$PWD\secrets\et-operational-platform.env.sops"
$TEMP_PLAIN = "$PWD\secrets\et-op-edit-tmp.env.sops" # must match path_regex
$TEMP_ENC = "$PWD\secrets\et-op-enc-tmp.env.sops"
# 1. Decrypt to variable
$plain = sops --decrypt --input-type dotenv --output-type dotenv $SOPS_FILE
if ($LASTEXITCODE -ne 0) { throw "Decrypt failed" }
# 2. Replace key value in memory (from $env:NEW_SUPABASE_KEY — never hardcoded)
$content = ($plain -join "`n") + "`n"
$content = $content -replace "(?m)^SUPABASE_SERVICE_ROLE_KEY=.*$",
"SUPABASE_SERVICE_ROLE_KEY=$env:NEW_SUPABASE_KEY"
$env:NEW_SUPABASE_KEY = "" # clear immediately
$plain = @()
# 3. Write to temp plaintext file (no CRLF/BOM)
[System.IO.File]::WriteAllText($TEMP_PLAIN, $content,
[System.Text.UTF8Encoding]::new($false))
$content = ""
# 4. Encrypt to second temp
sops --encrypt --input-type dotenv --output-type dotenv --output $TEMP_ENC $TEMP_PLAIN
if ($LASTEXITCODE -ne 0) {
@($TEMP_PLAIN, $TEMP_ENC) | Where-Object { Test-Path $_ } |
ForEach-Object { Remove-Item $_ -Force }
throw "Encrypt failed"
}
Remove-Item $TEMP_PLAIN -Force # plaintext gone
# 5. Canary on encrypted temp — production file untouched until this passes
sops --decrypt --input-type dotenv --output-type dotenv $TEMP_ENC | Out-Null
if ($LASTEXITCODE -ne 0) {
Remove-Item $TEMP_ENC -Force
throw "SOPS corrupt on temp — NOT overwriting production file"
}
# 6. Atomic move — production file replaced only after canary passes
Move-Item $TEMP_ENC $SOPS_FILE -Force
Write-Host "SOPS updated"Phase 3 — Commit, push, open PR
git add secrets\et-operational-platform.env.sops
git commit -m "chore: rotate SUPABASE_SERVICE_ROLE_KEY (new: p24_vercel_YYYYMMDD)
Implements: #<issue_number>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
git push origin feat/<branch-name>
# Open PR → merge to main → secrets-sync.yml fires automaticallyAfter merge to main, secrets-sync.yml automatically:
- Pushes to Vercel project (for
et-operational-platform.env.sops) - SSH-copies
.envto vps-i1/opt/p24-infra/monitoring/.env(formonitoring.env.sops)
For monitoring keys: after secrets-sync.yml completes, restart affected containers:
# On vps-i1 (via SSH from a sys-admin role session)
cd /opt/p24-infra/monitoring && docker compose up -dPhase 4 — Verify new key is live (Day 7 + DELETE old key)
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
$env:SUPABASE_ACCESS_TOKEN = (sops --decrypt --input-type dotenv --output-type dotenv `
secrets\role-secret-manager.env.sops |
Where-Object { $_ -match "^ROLE_SECRET_MANAGER_SUPABASE_ACCESS_TOKEN=" }
).ToString().Split("=", 2)[1].Trim()
# Verify new key is in the list
$list = Invoke-RestMethod -Uri "https://api.supabase.com/v1/projects/mwkqmgadqnkkihjdeqsi/api-keys" `
-Headers @{Authorization = "Bearer $env:SUPABASE_ACCESS_TOKEN"}
$list | ForEach-Object { Write-Host "$($_.name) / $($_.id) / $($_.type)" }
# Delete old key by its UUID (noted in Phase 1 before creation)
$oldKeyId = "<UUID-of-old-key>" # e.g., "348f2df2-6e79-4f4f-9784-e4bd4e698e6d"
Invoke-RestMethod -Uri "https://api.supabase.com/v1/projects/mwkqmgadqnkkihjdeqsi/api-keys/$oldKeyId" `
-Headers @{Authorization = "Bearer $env:SUPABASE_ACCESS_TOKEN"} -Method DELETE
Write-Host "Old key deleted: $oldKeyId"
$env:SUPABASE_ACCESS_TOKEN = ""Python Script Template (Linux workers — bms-4, vps-i1)
The proposed Python approach using PyGithub to push to GH Secrets is NOT compatible with our infrastructure. Use this adapted pattern on Linux workers:
import urllib.request, json, os, subprocess
PROJECT_REF = "mwkqmgadqnkkihjdeqsi"
def list_api_keys(token: str) -> list:
"""List all Supabase API keys (names and IDs only)."""
url = f"https://api.supabase.com/v1/projects/{PROJECT_REF}/api-keys"
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
def create_api_key(name: str, key_type: str, token: str) -> dict:
"""Create a Supabase API key. key_type: 'secret' or 'publishable'.
Name must match ^[a-z_][a-z0-9_]*$ (lowercase + digits + underscores only).
Returns dict with id, name, type, api_key.
NEVER log api_key — pass directly to SOPS update."""
url = f"https://api.supabase.com/v1/projects/{PROJECT_REF}/api-keys"
body = json.dumps({"name": name, "type": key_type}).encode()
req = urllib.request.Request(url, data=body, method="POST",
headers={"Authorization": f"Bearer {token}",
"Content-Type": "application/json"})
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
def delete_api_key(key_id: str, token: str) -> bool:
"""Delete a Supabase API key by UUID."""
url = f"https://api.supabase.com/v1/projects/{PROJECT_REF}/api-keys/{key_id}"
req = urllib.request.Request(url, method="DELETE",
headers={"Authorization": f"Bearer {token}"})
with urllib.request.urlopen(req) as resp:
return resp.status == 200
# After creating the new key on a Linux worker:
# 1. Read token safely from SOPS-decrypted env (never from env dump)
# 2. Create key:
# new_key = create_api_key("p24_vercel_20260705", "secret", token)
# 3. Pass value to SOPS update script — NEVER log new_key["api_key"]
# os.environ["NEW_SUPABASE_KEY"] = new_key["api_key"]
# subprocess.run(["./scripts/update-sops-key.sh", "et-operational-platform",
# "SUPABASE_SERVICE_ROLE_KEY"], env=os.environ)
# os.environ["NEW_SUPABASE_KEY"] = "" # clear immediately
# 4. Commit SOPS file and push → secrets-sync.yml handles deploymentIntegration with secrets-sync.yml
After the SOPS file is updated and the PR is merged to main:
| SOPS file | secrets-sync.yml action |
|---|---|
et-operational-platform.env.sops | Pushes all env vars to Vercel prj_ziLl911FOYLAeukQujL4NjxR4eWy |
monitoring.env.sops | SSH-copies .env to /opt/p24-infra/monitoring/.env on vps-i1 |
n8n-bms4.env.sops | SSH-copies .env to /opt/p24-infra/bms-4/.env on bms-4 |
brandpilot.env.sops | Pushes all env vars to Vercel brandpilot project (prj_oclWMN...) |
Do NOT push keys to GH Secrets directly — that channel is reserved for CI/CD secrets managed by secrets-sync.yml itself. Application secrets go through the SOPS → secrets-sync.yml path.
To trigger manually after merge:
gh workflow run secrets-sync.yml --repo radieu/p24-infra -f target=vps-i1Common Mistakes — What to Avoid
| Mistake | Consequence | Correct approach |
|---|---|---|
URL: https://supabase.com{ref}/api-keys | 404 / connection error | Use https://api.supabase.com/v1/projects/{ref}/api-keys |
| Name with hyphens or uppercase | HTTP 400 from Management API | Use [a-z_][a-z0-9_]* only |
publishable key for service auth | Anon key is public — no auth protection | Use secret key for server-to-server calls |
Legacy keys anon/service_role | Cannot delete or rotate via API | Create named publishable/secret keys |
| PyGithub → GH Secrets distribution | Bypasses SOPS+age, creates drift | SOPS update → commit → secrets-sync.yml |
| Deleting old key before verifying new | Service outage | Run both keys in parallel for 7 days |
Logging api_key value | Secret exposure incident; rotate within 1h | Pass directly to SOPS update; never log |
Rotation Checklist
For each key rotation:
- LIST current keys — note old key UUID and its current name
- Create new key with dated name (e.g.,
p24_vercel_20260705) - Note new key UUID for future deletion (store in rotation log or SOPS comment)
- Update SOPS file with new value (never display the value)
- Run canary:
sops --decrypt ... | Out-Null→ must exit 0 - Commit SOPS file with message
chore: rotate KEY_NAME (new: <supabase_key_name>) - Open PR → merge to
main - Wait for
secrets-sync.ymlto complete successfully - Verify service health (Grafana / Vercel deployment / HTTP probe)
- If key is consumed by n8n: run
scripts/sync-n8n-supabase-creds.pyon bms-4 to update n8n credential vault (skipping this caused a 12h GPS fleet freeze — issue #1371) - Wait 7 days (zero-downtime overlap)
- DELETE old key via Management API (using stored UUID)
- Add entry to
docs/secrets-rotation-log.md
Related
docs/playbooks/secret-manager.md— SOPS operation patterns and distribution chaindocs/playbooks/sops-edit-operations.md— Windows-safe SOPS write proceduresdocs/playbooks/secret-rotation-access-matrix.md— Tier classification for each key- Issue #2882 — original review and test request