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 API
  • vercel-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:

LocationKey nameWho uses it
secrets/monitoring.env.sopsVERCEL_TOKENcost-exporter, vercel-exporter on vps-i1
GH Secret radieu/p24-infraVERCEL_TOKENGitHub Actions workflows
d:\code_2026\p24-infra\.env.localVERCEL_TOKENLocal 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

  1. Log in at https://vercel.com as devpinbox24-6490
  2. Go to Account Settings → Tokens (or navigate to https://vercel.com/account/tokens)
  3. Click Create
  4. Name: p24-infra-YYYY-MM-DD (use today’s date)
  5. Scope: devp24com’s projects (the team)
  6. Expiry: No expiration (or set 365-day expiry for stricter hygiene)
  7. Click Create Token
  8. 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 push

After 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:

  1. Check Vercel status page: https://www.vercel-status.com/
  2. If exporter containers fail after a bad token is deployed, revert secrets/monitoring.env.sops via git checkout -- secrets/monitoring.env.sops and re-run secrets-sync.yml manually
  3. Create a GitHub issue in radieu/p24-infra with label human-action if 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.