Playbook: Supabase Access Token Rotation
Service: Supabase management API (https://supabase.com)
Secrets: ROLE_SECRET_MANAGER_SUPABASE_ACCESS_TOKEN, SUPABASE_MANAGEMENT_TOKEN
SOPS files: secrets/role-secret-manager.env.sops, secrets/brandpilot.env.sops
Rotation frequency: 90 days (or immediately on suspected exposure)
Last rotated: 2026-06-22 (monitoring, pre-migration), 2026-06-27 (brandpilot)
Next due: 2026-09-22
Note (2026-06-21): Supabase disabled legacy JWT keys. The new key system uses
sb_publishable_...(anon) andsb_secret_...(service_role) prefixes. The access token covered by this playbook is a management API token (not a project API key) — it grants access to the Supabase management API for creating projects, rotating keys, managing settings. Different fromSUPABASE_SERVICE_KEY.
Note (2026-07-05, superseded 2026-06-21 above): The management access token was moved out of
secrets/monitoring.env.sopsintosecrets/role-secret-manager.env.sopsasROLE_SECRET_MANAGER_SUPABASE_ACCESS_TOKEN, for least-privilege isolation — only secret-manager role workers get this credential (commits560fb17e/376a4993, issues 2620). Any doc or script still referencingmonitoring.env.sops+SUPABASE_ACCESS_TOKENfor this token is stale.
Note (2026-07-11):
secrets/administration.env.sopsalso carries aSUPABASE_ACCESS_TOKENkey — a developer-local-only copy (excluded fromsecrets-sync.yml, never deployed by CI). It had gone stale/expired (401) independent of the role-scoped token above; it was repointed to hold the same value asROLE_SECRET_MANAGER_SUPABASE_ACCESS_TOKEN(issue #3825). Treatrole-secret-manager.env.sopsas canonical —administration.env.sops’s copy is a convenience mirror for developer-workstation use, not a separate credential.
Token inventory
| Key name | SOPS file | Consumer |
|---|---|---|
ROLE_SECRET_MANAGER_SUPABASE_ACCESS_TOKEN | role-secret-manager.env.sops | secret-manager role workers — infra scripts, CI/CD operations against the Supabase management API |
SUPABASE_ACCESS_TOKEN (mirror) | administration.env.sops | Developer-workstation convenience copy only — not synced to any server, not consumed by CI |
SUPABASE_MANAGEMENT_TOKEN | brandpilot.env.sops | BrandPilot scripts that use the Supabase management API |
GH Secret SUPABASE_ACCESS_TOKEN (used by credential-rotation.yml, compliance-audit-due-check.yml) is being redesigned away from a static PAT entirely — see issue #3835 (Cloudflare Worker short-lived-token proxy, design in progress). Until that lands, treat the GH Secret as a third copy that must be kept in sync manually.
Where stored
| Location | Key name | How to update |
|---|---|---|
secrets/role-secret-manager.env.sops | ROLE_SECRET_MANAGER_SUPABASE_ACCESS_TOKEN | SOPS write pattern |
secrets/administration.env.sops | SUPABASE_ACCESS_TOKEN (mirror) | SOPS write pattern — keep in sync with the role-scoped value above |
secrets/brandpilot.env.sops | SUPABASE_MANAGEMENT_TOKEN | SOPS write pattern |
GH Secret SUPABASE_ACCESS_TOKEN | SUPABASE_ACCESS_TOKEN | gh secret set SUPABASE_ACCESS_TOKEN --repo radieu/p24-infra — pending replacement, see issue #3835 |
bms-4 worker token source (#5319). A standard bms-4 worker (age key
~/.age/p24-infra-keys.txt) cannot decryptrole-secret-manager.env.sopsoradministration.env.sops, so it cannot readROLE_SECRET_MANAGER_SUPABASE_ACCESS_TOKEN. The only worker-reachable copy of an equivalent Supabase management token isSUPABASE_MANAGEMENT_TOKENinsecrets/brandpilot.env.sops(which the worker can decrypt — confirmed by the #4400 Master-key deliverability re-check in secret-rotation-access-matrix.md). When any step here needs to authenticate against the management API from bms-4 (e.g. the Verification section below), read that key — not the role-scoped one. Note the rotation itself is Playwright/browser-driven (the token cannot be minted via the management API — see below), so a bms-4 worker without a Playwright session should hand off to a Playwright-capable session or the developer.
Login credentials
- URL:
https://supabase.com/dashboard/account/tokens - Email: radieu@gmail.com
- Auth: GitHub SSO or Google SSO
IMPORTANT: API limitation for management tokens
Supabase management API tokens cannot be created via the management API itself (circular dependency). They can only be created in the Supabase dashboard UI under Account → Access Tokens.
Playwright automation is viable since there is no additional MFA step after GitHub/Google login.
Automation status
Playwright-automatable — token management accessible via web UI without additional MFA.
Rotation steps
Option A — Playwright agent (preferred)
You are rotating Supabase management API tokens: SUPABASE_ACCESS_TOKEN and SUPABASE_MANAGEMENT_TOKEN.
IMPORTANT: Never display any secret value in your response. Reference key names only.
1. Use Playwright to navigate to https://supabase.com/dashboard/account/tokens
- Sign in with GitHub (radieu account) or Google (radieu@gmail.com)
- For SUPABASE_ACCESS_TOKEN:
→ Find existing token named "p24-infra" or similar → Delete it (click trash icon → confirm)
→ Generate new token → Name: "p24-infra-<YYYY-MM-DD>" → Generate
→ Copy value into $env:NEW_ACCESS_TOKEN (starts with sbp_; never print)
- For SUPABASE_MANAGEMENT_TOKEN:
→ Find token named "brandpilot" or similar → Delete it
→ Generate new token → Name: "brandpilot-<YYYY-MM-DD>" → Generate
→ Copy value into $env:NEW_MGMT_TOKEN (never print)
- Close the browser
2. Update secrets/role-secret-manager.env.sops (ROLE_SECRET_MANAGER_SUPABASE_ACCESS_TOKEN):
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
$plain = sops --decrypt --input-type dotenv --output-type dotenv secrets\role-secret-manager.env.sops
$temp = "$PWD\secrets\role-secret-manager-edit.env.sops"
$updated = $plain -replace "^ROLE_SECRET_MANAGER_SUPABASE_ACCESS_TOKEN=.*", "ROLE_SECRET_MANAGER_SUPABASE_ACCESS_TOKEN=$env:NEW_ACCESS_TOKEN"
[System.IO.File]::WriteAllText($temp, ($updated -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
$enc = sops --encrypt --input-type dotenv --output-type dotenv $temp
[System.IO.File]::WriteAllText("$PWD\secrets\role-secret-manager.env.sops", ($enc -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
sops --decrypt --input-type dotenv --output-type dotenv secrets\role-secret-manager.env.sops | Out-Null
if ($LASTEXITCODE -ne 0) { throw "SOPS corrupt — do NOT commit" }
[System.IO.File]::Delete($temp)
2b. Mirror into secrets/administration.env.sops (SUPABASE_ACCESS_TOKEN) — same pattern, target
that file/key instead. This copy is developer-local only; skip if it has been retired.
3. Update GH Secret SUPABASE_ACCESS_TOKEN (until issue #3835's CF Worker replacement lands):
$env:T = $env:NEW_ACCESS_TOKEN
gh secret set SUPABASE_ACCESS_TOKEN --repo radieu/p24-infra --body $env:T
$env:T = ""
4. Update secrets/brandpilot.env.sops (SUPABASE_MANAGEMENT_TOKEN):
$plain = sops --decrypt --input-type dotenv --output-type dotenv secrets\brandpilot.env.sops
$temp = "$PWD\secrets\brandpilot-edit.env.sops"
$updated = $plain -replace "^SUPABASE_MANAGEMENT_TOKEN=.*", "SUPABASE_MANAGEMENT_TOKEN=$env:NEW_MGMT_TOKEN"
[System.IO.File]::WriteAllText($temp, ($updated -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
$enc = sops --encrypt --input-type dotenv --output-type dotenv $temp
[System.IO.File]::WriteAllText("$PWD\secrets\brandpilot.env.sops", ($enc -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
sops --decrypt --input-type dotenv --output-type dotenv secrets\brandpilot.env.sops | Out-Null
if ($LASTEXITCODE -ne 0) { throw "SOPS corrupt — do NOT commit" }
[System.IO.File]::Delete($temp)
$env:NEW_ACCESS_TOKEN = ""; $env:NEW_MGMT_TOKEN = ""
5. Commit affected SOPS files in one PR (p24-infra PRs target `main` directly — no `dev` staging step):
git checkout -b fix/rotate-supabase-access-tokens origin/main
git add secrets/role-secret-manager.env.sops secrets/administration.env.sops secrets/brandpilot.env.sops
git commit -m "chore: rotate SUPABASE_ACCESS_TOKEN + SUPABASE_MANAGEMENT_TOKEN (scheduled 90d)"
git push -u origin fix/rotate-supabase-access-tokens
gh pr create --base main --title "chore: rotate Supabase management API tokens"
# Never push directly to main — wait for review/merge, do not auto-merge
6. Append to docs/secrets-rotation-log.md:
| <YYYY-MM-DD> | SUPABASE_ACCESS_TOKEN + SUPABASE_MANAGEMENT_TOKEN | scheduled 90d rotation | AI-agent | SOPS (role-secret-manager + administration mirror + brandpilot) + GH Secret |
Option B — Manual (fallback)
Step 1 — Create tokens in Supabase dashboard
URL: https://supabase.com/dashboard/account/tokens
→ Delete old tokens → Generate new ones → copy values (each starts with sbp_)
Step 2 — Store in .env.local then hand off to Claude:
Add to d:\code_2026\p24-infra\.env.local:
SUPABASE_ACCESS_TOKEN_NEW=<value>
SUPABASE_MANAGEMENT_TOKEN_NEW=<value>
Tell Claude "Supabase tokens updated in .env.local" — Claude handles SOPS + GH Secret + PR.
Verification
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
$env:T = (sops --decrypt --input-type dotenv --output-type dotenv secrets\role-secret-manager.env.sops |
Select-String "^ROLE_SECRET_MANAGER_SUPABASE_ACCESS_TOKEN=").ToString().Split('=',2)[1]
# Check key length (sbp_ prefix = ~40+ chars)
Write-Host "ROLE_SECRET_MANAGER_SUPABASE_ACCESS_TOKEN length: $($env:T.Length)"
# Verify against Supabase management API
$r = Invoke-RestMethod "https://api.supabase.com/v1/projects" `
-Headers @{ Authorization = "Bearer $env:T" }
Write-Host "Projects accessible: $($r.Count)"
$env:T = ""; $env:SOPS_AGE_KEY_FILE = ""bms-4 worker verification (#5319)
The PowerShell block above reads role-secret-manager.env.sops, which a standard bms-4 worker
cannot decrypt. On bms-4, verify with the worker-reachable SUPABASE_MANAGEMENT_TOKEN from
secrets/brandpilot.env.sops instead (read silently, never echo the value):
export SOPS_AGE_KEY_FILE="$HOME/.age/p24-infra-keys.txt"
T=$(sops -d --input-type dotenv --output-type dotenv secrets/brandpilot.env.sops \
| grep '^SUPABASE_MANAGEMENT_TOKEN=' | cut -d= -f2-)
# Status code only — never print the token or the response body
code=$(curl -s -o /dev/null -w '%{http_code}' "https://api.supabase.com/v1/projects" \
-H "Authorization: Bearer $T")
[ "$code" = "200" ] && echo "management token OK (HTTP 200)" || echo "management token FAILED (HTTP $code)"
unset TEscalation
| Symptom | Action |
|---|---|
| Supabase dashboard login fails | Try both GitHub and Google SSO options |
| Old token still works after deletion | Supabase may have caching; wait 1–2 minutes and retest |
sbp_ prefix missing | Ensure you are creating a Personal Access Token under Account settings, not a project API key |
| CI/CD scripts fail with 401 after rotation | Confirm GH Secret SUPABASE_ACCESS_TOKEN was updated: gh secret list --repo radieu/p24-infra |
Prevention
- Supabase does not currently send expiry warnings for personal access tokens.
- The
credential-rotation.ymlGH Actions workflow (Monday 06:00 UTC) checksnext_dueand opens ahuman-actionissue. - After rotation, update Last rotated and Next due at the top of this playbook.
Audit Log — Log to infra_operations
After this operation completes, log it to the infra_operations audit table.
Python (Linux server — bms-4, vps-i1, vps-h1, or similar):
import sys
sys.path.insert(0, '/opt/p24-infra')
from scripts.lib.log_op import log_op
log_op(
actor="claude", # "radieu" for manual human ops, "claude" for agent
op_type="credential_rotation",
resource="SUPABASE_ACCESS_TOKEN",
result="success", # "success" | "failed" | "skipped"
detail="Scheduled rotation — Supabase management access token regenerated and SOPS updated",
env="vps-i1",
gh_issue=2730,
)PowerShell (Windows dev machine):
$env:SUPABASE_URL = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_URL=").ToString().Split("=",2)[1].Trim()
$env:SUPABASE_SERVICE_KEY = (Get-Content "C:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_SERVICE_KEY=").ToString().Split("=",2)[1].Trim()
python -c "
import os, sys
sys.path.insert(0, 'C:/code_2026/p24-infra')
from scripts.lib.log_op import log_op
log_op('claude', 'credential_rotation', 'SUPABASE_ACCESS_TOKEN', 'success', 'Scheduled rotation — Supabase management access token regenerated and SOPS updated', 'vps-i1')
"
$env:SUPABASE_URL = ''; $env:SUPABASE_SERVICE_KEY = ''