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) and sb_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 from SUPABASE_SERVICE_KEY.

Note (2026-07-05, superseded 2026-06-21 above): The management access token was moved out of secrets/monitoring.env.sops into secrets/role-secret-manager.env.sops as ROLE_SECRET_MANAGER_SUPABASE_ACCESS_TOKEN, for least-privilege isolation — only secret-manager role workers get this credential (commits 560fb17e/376a4993, issues 2620). Any doc or script still referencing monitoring.env.sops + SUPABASE_ACCESS_TOKEN for this token is stale.

Note (2026-07-11): secrets/administration.env.sops also carries a SUPABASE_ACCESS_TOKEN key — a developer-local-only copy (excluded from secrets-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 as ROLE_SECRET_MANAGER_SUPABASE_ACCESS_TOKEN (issue #3825). Treat role-secret-manager.env.sops as canonical — administration.env.sops’s copy is a convenience mirror for developer-workstation use, not a separate credential.


Token inventory

Key nameSOPS fileConsumer
ROLE_SECRET_MANAGER_SUPABASE_ACCESS_TOKENrole-secret-manager.env.sopssecret-manager role workers — infra scripts, CI/CD operations against the Supabase management API
SUPABASE_ACCESS_TOKEN (mirror)administration.env.sopsDeveloper-workstation convenience copy only — not synced to any server, not consumed by CI
SUPABASE_MANAGEMENT_TOKENbrandpilot.env.sopsBrandPilot 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

LocationKey nameHow to update
secrets/role-secret-manager.env.sopsROLE_SECRET_MANAGER_SUPABASE_ACCESS_TOKENSOPS write pattern
secrets/administration.env.sopsSUPABASE_ACCESS_TOKEN (mirror)SOPS write pattern — keep in sync with the role-scoped value above
secrets/brandpilot.env.sopsSUPABASE_MANAGEMENT_TOKENSOPS write pattern
GH Secret SUPABASE_ACCESS_TOKENSUPABASE_ACCESS_TOKENgh 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 decrypt role-secret-manager.env.sops or administration.env.sops, so it cannot read ROLE_SECRET_MANAGER_SUPABASE_ACCESS_TOKEN. The only worker-reachable copy of an equivalent Supabase management token is SUPABASE_MANAGEMENT_TOKEN in secrets/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 T

Escalation

SymptomAction
Supabase dashboard login failsTry both GitHub and Google SSO options
Old token still works after deletionSupabase may have caching; wait 1–2 minutes and retest
sbp_ prefix missingEnsure you are creating a Personal Access Token under Account settings, not a project API key
CI/CD scripts fail with 401 after rotationConfirm 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.yml GH Actions workflow (Monday 06:00 UTC) checks next_due and opens a human-action issue.
  • 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 = ''