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

ItemProposedVerdictCorrect value
API base URLhttps://supabase.com{ref}/api-keysWRONGhttps://api.supabase.com/v1/projects/{ref}/api-keys
Key type for servicespublishablePARTIALLY WRONGServices use secret keys; publishable = anon/public only
Distribution methodPyGithub to GH SecretsWRONG for our infraSOPS+age committed → secrets-sync.yml auto-deploys
Zero-downtime 7-day overlapgenerate B, run both, delete ACORRECTStrategy is sound
DELETE endpoint/v1/projects/{ref}/api-keys/{key_id}CORRECTConfirmed 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>
OperationMethodEndpointHTTP statusNotes
List all keysGET/api-keys200Returns array with id, name, type
Create keyPOST/api-keys201Body: {"name":"...","type":"..."}
Delete keyDELETE/api-keys/<key_id>200Uses 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

TypeSupabase meaningValue prefixRotatable via APINotes
legacyBuilt-in anon / service_roleeyJhbGciO... (JWT)NOCreated at project init; cannot be deleted
publishableNamed anon keys (safe to expose in clients)sb_publishable_...YESUse for NEXT_PUBLIC_SUPABASE_ANON_KEY
secretNamed service-role level keyssb_secret_...YESUse for SUPABASE_SERVICE_ROLE_KEY

Our actual key inventory (2026-07-05, key names and IDs only — no values)

Supabase key nameTypeSOPS key nameConsumer
anonlegacyDo not use; use named publishable key instead
service_rolelegacyDo not use; use named secret key instead
supabase_anon_keypublishableNEXT_PUBLIC_SUPABASE_ANON_KEYet-operational-platform (Vercel)
p24_wap_android_20260629publishableSUPABASE_WAP_ANON_KEYmonitoring.env.sops, WhatsApp Android client
et_email_dispo_ai_agentsecretAI agent email disposal workflow
et_fibu_folders_sp_logssecretFIBU folders service-principal logs
p24_monitoring_20260703secretSUPABASE_SERVICE_KEYmonitoring.env.sops → vps-i1 exporters
p24_n8n_20260703secretn8n on bms-4 (in n8n-bms4.env.sops)
p24_vercel_20260703secretSUPABASE_SERVICE_ROLE_KEYet-operational-platform.env.sops (Vercel)
p24_service_role_20260705csecretSUPABASE_SERVICE_ROLE_KEYmonitoring.env.sops → Grafana, exporters

Note on legacy keys: The built-in anon and service_role legacy keys cannot be rotated via the Management API. Do not use them in new integrations. Use named publishable and secret keys — 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 automatically

After merge to main, secrets-sync.yml automatically:

  • Pushes to Vercel project (for et-operational-platform.env.sops)
  • SSH-copies .env to vps-i1 /opt/p24-infra/monitoring/.env (for monitoring.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 -d

Phase 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 deployment

Integration with secrets-sync.yml

After the SOPS file is updated and the PR is merged to main:

SOPS filesecrets-sync.yml action
et-operational-platform.env.sopsPushes all env vars to Vercel prj_ziLl911FOYLAeukQujL4NjxR4eWy
monitoring.env.sopsSSH-copies .env to /opt/p24-infra/monitoring/.env on vps-i1
n8n-bms4.env.sopsSSH-copies .env to /opt/p24-infra/bms-4/.env on bms-4
brandpilot.env.sopsPushes 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-i1

Common Mistakes — What to Avoid

MistakeConsequenceCorrect approach
URL: https://supabase.com{ref}/api-keys404 / connection errorUse https://api.supabase.com/v1/projects/{ref}/api-keys
Name with hyphens or uppercaseHTTP 400 from Management APIUse [a-z_][a-z0-9_]* only
publishable key for service authAnon key is public — no auth protectionUse secret key for server-to-server calls
Legacy keys anon/service_roleCannot delete or rotate via APICreate named publishable/secret keys
PyGithub → GH Secrets distributionBypasses SOPS+age, creates driftSOPS update → commit → secrets-sync.yml
Deleting old key before verifying newService outageRun both keys in parallel for 7 days
Logging api_key valueSecret exposure incident; rotate within 1hPass 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.yml to complete successfully
  • Verify service health (Grafana / Vercel deployment / HTTP probe)
  • If key is consumed by n8n: run scripts/sync-n8n-supabase-creds.py on 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

  • docs/playbooks/secret-manager.md — SOPS operation patterns and distribution chain
  • docs/playbooks/sops-edit-operations.md — Windows-safe SOPS write procedures
  • docs/playbooks/secret-rotation-access-matrix.md — Tier classification for each key
  • Issue #2882 — original review and test request