Vercel Token Rotation Playbook
Overview
VERCEL_TOKEN is a Vercel API token scoped to the devpinbox24-6490 team. It is used by:
cost-exporter— fetches Vercel billing/spend data via the Vercel REST APIvercel-exporter— fetches deployment metrics via the Vercel REST API- GitHub Actions workflows — deployment and environment management (GH Secret
VERCEL_TOKEN)
Token Storage Locations
All three locations must be updated together during rotation:
| Location | Key name | Who uses it |
|---|---|---|
secrets/monitoring.env.sops | VERCEL_TOKEN | cost-exporter, vercel-exporter on vps-i1 |
GH Secret radieu/p24-infra | VERCEL_TOKEN | GitHub Actions workflows |
d:\code_2026\p24-infra\.env.local | VERCEL_TOKEN | Local development / Claude sessions |
Token Properties
- Token type: team-scoped (scope
type=team,teamId=team_RuXLTWx5DXK6n0HUKoOUTGY3) - Token prefix:
vcp_ - Token length: 60 characters
- No expiry set (rotated manually on schedule)
CRITICAL: API Limitation
The Vercel REST API POST /v3/user/tokens cannot be used to create new tokens when the
current token is team-scoped. The API returns:
{"error":{"code":"forbidden","message":"To create a token you must be authenticated to scope \"devpinbox24-6490\""}}
This means token rotation must be done manually via the Vercel dashboard. There is no fully automated API path for this rotation.
Rotation Procedure (Manual — ~10 minutes)
Step 1: Create new token in Vercel dashboard
- Log in at https://vercel.com as
devpinbox24-6490 - Go to Account Settings → Tokens (or navigate to https://vercel.com/account/tokens)
- Click Create
- Name:
p24-infra-YYYY-MM-DD(use today’s date) - Scope: devp24com’s projects (the team)
- Expiry: No expiration (or set 365-day expiry for stricter hygiene)
- Click Create Token
- Copy the token value immediately — it is shown only once
Step 2: Record old token ID for revocation
Before updating any store, note the ID of the old token from the token list page. You can also retrieve it via API using the current token:
$curToken = (Get-Content "d:\code_2026\p24-infra\.env.local" | Select-String "^VERCEL_TOKEN=").ToString().Split("=",2)[1].Trim()
$tokenList = Invoke-RestMethod -Uri "https://api.vercel.com/v3/user/tokens" -Method GET -Headers @{Authorization="Bearer $curToken"}
$tokenList.tokens | ForEach-Object { Write-Host (" Name=" + $_.name + " ID=" + $_.id) }Step 3: Update all storage locations
Run all three updates in a single PowerShell session (keep the new token in $newToken):
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
Set-Location "d:\code_2026\p24-infra"
# Set $newToken to the value copied from Vercel dashboard (do NOT print it)
# $newToken = "<paste-from-dashboard>" # <- set this variable first
# --- 3a. Update monitoring.env.sops ---
$sopsPath = "secrets\monitoring.env.sops"
$tempPath = "secrets\monitoring-edit.env.sops"
$plain = sops --decrypt --input-type dotenv --output-type dotenv $sopsPath 2>$null
$updated = $plain | ForEach-Object { if ($_ -match '^VERCEL_TOKEN=') { "VERCEL_TOKEN=$newToken" } else { $_ } }
[System.IO.File]::WriteAllText("$PWD\$tempPath", ($updated -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
$enc = sops --encrypt --input-type dotenv --output-type dotenv "$PWD\$tempPath"
[System.IO.File]::WriteAllText("$PWD\$sopsPath", ($enc -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
# Canary — MUST pass before git add
sops --decrypt --input-type dotenv --output-type dotenv $sopsPath | Out-Null
if ($LASTEXITCODE -ne 0) { throw "SOPS canary FAILED — do NOT commit. See docs/playbooks/sops-windows-crlf.md" }
Write-Host "SOPS OK"
[System.IO.File]::Delete("$PWD\$tempPath")
# --- 3b. Update GH Secret ---
Write-Output $newToken | gh secret set VERCEL_TOKEN --repo radieu/p24-infra
Write-Host "GH Secret updated"
# --- 3c. Update .env.local ---
$envContent = Get-Content "d:\code_2026\p24-infra\.env.local"
$envUpdated = $envContent | ForEach-Object { if ($_ -match '^VERCEL_TOKEN=') { "VERCEL_TOKEN=$newToken" } else { $_ } }
[System.IO.File]::WriteAllText("d:\code_2026\p24-infra\.env.local", ($envUpdated -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
Write-Host ".env.local updated"Step 4: Verify the new token works
# Use $newToken still in memory from step 3
$check = Invoke-RestMethod -Uri "https://api.vercel.com/v6/deployments?limit=1" -Headers @{Authorization="Bearer $newToken"}
Write-Host ("Deployments returned: " + $check.deployments.Count + " — token is valid")Step 5: Revoke the old token
In the Vercel dashboard → Account Settings → Tokens, find the old token by ID or name and click Delete.
Or via API using the new token (record $oldTokenId from Step 2):
$null = Invoke-RestMethod -Uri ("https://api.vercel.com/v3/user/tokens/" + $oldTokenId) -Method DELETE -Headers @{Authorization="Bearer $newToken"}
Write-Host "Old token revoked"Step 6: Commit and deploy
Set-Location "d:\code_2026\p24-infra"
git add secrets/monitoring.env.sops
git commit -m "chore: rotate VERCEL_TOKEN $(Get-Date -Format 'yyyy-MM-dd')
Updated: monitoring.env.sops + GH Secret.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
git pushAfter the PR merges to dev, secrets-sync.yml deploys the new token to vps-i1.
Restart affected containers to pick up the new value:
ssh root@217.154.82.162 "cd /opt/p24-infra/monitoring && docker compose up -d --no-deps cost-exporter vercel-exporter"Step 7: Update rotation log
Add a row to docs/secrets-rotation-log.md:
| YYYY-MM-DD HH:MM UTC | — | VERCEL_TOKEN | scheduled 90d rotation | radieu | monitoring.env.sops + GH Secret + .env.local |
Step 7b: Log the operation to infra_operations audit log
# Read SUPABASE_URL and key safely from .env.local (never print values)
$env:SUPABASE_URL = (Get-Content "d:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_URL=").ToString().Split("=",2)[1].Trim()
$env:SUPABASE_SERVICE_KEY = (Get-Content "d:\code_2026\p24-infra\.env.local" | Select-String "^SUPABASE_SERVICE_KEY=").ToString().Split("=",2)[1].Trim()
python "d:\code_2026\p24-infra\scripts\lib\log_op.py"Or call log_op() directly in Python:
from scripts.lib.log_op import log_op
log_op(
actor="radieu",
op_type="credential_rotation",
resource="VERCEL_TOKEN",
result="success",
detail="Scheduled 90d rotation — vcp_ team-scoped token replaced via dashboard",
env="vps-i1",
)Verification
After rotation, confirm the exporter containers are healthy:
ssh root@217.154.82.162 "curl -s http://localhost:9210/metrics | head -5"
ssh root@217.154.82.162 "docker compose -f /opt/p24-infra/monitoring/docker-compose.yml ps cost-exporter vercel-exporter"Expected: containers in Up state, metrics endpoint returns data without auth errors.
Rotation Frequency
Recommended: every 90 days. Add to docs/playbooks/credential-rotation-180d.md schedule.
Next rotation due after initial token (created ~2025-05): NEXT_DUE — schedule for 2026-09-01.
Escalation
If Vercel dashboard is inaccessible or token creation fails:
- Check Vercel status page: https://www.vercel-status.com/
- If exporter containers fail after a bad token is deployed, revert
secrets/monitoring.env.sopsviagit checkout -- secrets/monitoring.env.sopsand re-runsecrets-sync.ymlmanually - Create a GitHub issue in
radieu/p24-infrawith labelhuman-actionif dashboard access requires the account owner
Why API Automation Is Not Available
The current VERCEL_TOKEN is a team-scoped token (prefix vcp_). The Vercel API endpoint
POST /v3/user/tokens requires a user-level token to create new tokens — team-scoped tokens
are forbidden from creating tokens. Until a user-level token is obtained and stored, rotation must
be performed manually via the dashboard.
To enable future automation: create a separate user-scope token in the dashboard (no team scope
selected), store it as VERCEL_USER_TOKEN in monitoring.env.sops, and use that token only for
the rotation step.