Playbook: Playwright-Assisted Credential Rotation

Use this playbook when a credential’s provider requires browser-based login or MFA to create or rotate an API key — making fully automated API rotation impossible.

When to use

  • Provider has no public API for key creation/rotation (e.g. Cloudflare Global API Key)
  • Provider requires MFA or OAuth consent flow (e.g. Claude Max OAuth, Discord bot portal)
  • Provider only shows the new key value once in a UI (e.g. OpenAI, Telegram BotFather)

Rotation type in dev_r_services

rotation_type = 'playwright'

Prerequisites

  • Playwright MCP available in current Claude Code session
  • Existing browser session at provider (user already logged in)
  • secrets/ directory accessible (SOPS age key loaded)

Standard flow

  1. Human: Create a GH issue labelled human-action describing which credential to rotate
  2. Claude: Navigates to provider credentials page via Playwright MCP
  3. Human: Reviews the page — approve proceeding if correct
  4. Claude: Creates new key / initiates rotation in browser
  5. Human: Copies the new credential value — DO NOT paste into chat
  6. Human: In terminal only: $env:NEW_VALUE = "paste-here"
  7. Claude: Runs SOPS update:
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
# Decrypt to temp file (inside secrets/ to match .sops.yaml path_regex)
$plain = sops --decrypt --input-type dotenv --output-type dotenv secrets\<file>.env.sops
[System.IO.File]::WriteAllText("$PWD\secrets\edit-temp.env.sops",
  ($plain -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
# Replace key value
$content = [System.IO.File]::ReadAllText("$PWD\secrets\edit-temp.env.sops")
$content = $content -replace "(?m)^KEY_NAME=.*$", "KEY_NAME=$env:NEW_VALUE"
[System.IO.File]::WriteAllText("$PWD\secrets\edit-temp.env.sops", $content,
  [System.Text.UTF8Encoding]::new($false))
# Re-encrypt
$enc = sops --encrypt --input-type dotenv --output-type dotenv secrets\edit-temp.env.sops
[System.IO.File]::WriteAllText("$PWD\secrets\<file>.env.sops", ($enc -join "`n") + "`n",
  [System.Text.UTF8Encoding]::new($false))
# Canary — mandatory before git add
sops --decrypt --input-type dotenv --output-type dotenv secrets\<file>.env.sops | Out-Null
if ($LASTEXITCODE -ne 0) { throw "SOPS corrupt — do NOT commit" }
Remove-Item "$PWD\secrets\edit-temp.env.sops"
$env:NEW_VALUE = ""
  1. Claude: Updates GH Secret (do this BEFORE clearing $env:NEW_VALUE):
gh secret set KEY_NAME --body "$env:NEW_VALUE" --repo radieu/p24-infra
$env:NEW_VALUE = ""
  1. Claude: Restarts affected containers on VPS (via SSH)
  2. Claude: Logs rotation in dev_r_rotation_log with rotation_type='playwright':
# On worker (bash) — or via Supabase REST from PowerShell session
LOG_ID=$(curl -s -X POST "$SUPABASE_URL/rest/v1/dev_r_rotation_log" \
  -H "apikey: $SUPABASE_SERVICE_KEY" \
  -H "Authorization: Bearer $SUPABASE_SERVICE_KEY" \
  -H "Content-Type: application/json" \
  -H "Prefer: return=representation" \
  -d "{\"secret_name\":\"KEY_NAME\",\"reason\":\"playwright rotation\",\"rotator\":\"radieu\",\"status\":\"completed\",\"rotation_type\":\"playwright\",\"consumers_updated\":\"SOPS + GH Secret + vps restart\",\"completed_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" \
  | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['id'])")
  1. Human: Revokes old key at provider
  2. Claude: Closes GH issue, appends to docs/secrets-rotation-log.md

Fallback — Playwright MCP unavailable

If Playwright MCP is not available in the session:

  1. Human navigates to provider manually, creates new key
  2. Human sets $env:NEW_VALUE = "paste-here" in terminal
  3. Claude runs steps 7–12 above (SOPS update + distribution)

Credentials using this method

CredentialProviderNotes
TELEGRAM_BOT_TOKENTelegram BotFather/revoke command, then /token
OPENAI_ADMIN_KEYplatform.openai.comAPI keys page → Create new
DISCORD_BOT_TOKENdiscord.com/developersBot page → Reset Token
CF_GLOBAL_API_KEYdash.cloudflare.comMy Profile → API Tokens → Global API Key → View (no rotation endpoint — must use existing or create new account key)
CLAUDE_RUNNER_OAUTH_VPS_I1claude.aiSee claude-runner-oauth-rotation.md
CLAUDE_RUNNER_OAUTH_BMS4claude.aiSee claude-runner-oauth-rotation.md

Automated Playwright script standard (no human in the loop)

For services with stable UI flows that don’t require manual approval, create a fully autonomous script instead of using the semi-manual MCP flow above.

File structure per credential

scripts/rotate/<credential_name>.js   — Node.js Playwright (browser automation, key extraction)
scripts/rotate/<credential_name>.ps1  — PowerShell orchestrator (SOPS read → JS → SOPS write → GH Secret → verify → log)

Both files live in scripts/rotate/ which has playwright installed in node_modules/.

JS script contract

ConventionRule
Required env<SERVICE>_PASS (password, read from SOPS silently), TOKEN_OUT_FILE (output path)
Optional envDEBUG_SCREENSHOTS=1 (saves screenshots to /tmp/<script>-*.png)
Chrome profileReuse most-recent mcp-chrome-* from AppData\Local\ms-playwright\ for session persistence
Key extractionTry in order: readonly input → code element → clipboard → page text regex
OutputWrite raw value to TOKEN_OUT_FILE; never log the value to stdout
Exit codes0 success · 1 fatal · 2 MFA required (retry with TOTP env var)
CleanupClear the in-memory variable immediately after writing to file

Reference implementation: scripts/rotate/discord-bot-token.js

PS1 wrapper contract

StepAction
0 — SanityCheck JS script exists, playwright installed
1 — Read secretExtract <PASS_KEY> from SOPS silently into $env:SERVICE_PASS (never print)
--dry-runStop after step 1 — no browser, no SOPS write
2 — Run JSnode scripts/rotate/<name>.js; clear $env:SERVICE_PASS immediately on exit
3 — Read output[System.IO.File]::ReadAllText(TOKEN_OUT_FILE).Trim() → never print
4 — VerifyCall service API with new key before touching SOPS
5 — SOPS updatescripts/sops-set.ps1 -Key <NAME> with $env:NEW_VALUE (enforced write path) — not a hand-rolled decrypt → regex → encrypt (#5298, ADR 003)
6 — GH Secret$newKey | gh secret set NAME --repo (non-fatal if fails — SOPS is source of truth)
7 — Clear$newKey = '' before any logging
8 — LogPrint next steps for git commit + PR; caller appends to secrets-rotation-log.md

UI-only credential flows (#5298, ADR 003): where the login password must be typed into a live browser (e.g. GitLab.com SaaS, which blocks PAT creation via API), do not hand-roll the SOPS read + Playwright launch. Use the broker scripts/secret-broker/Invoke-PlaywrightWithSecret.ps1, which lints the JS so the injected env var is only ever .fill()-ed into a password locator, delivers the value only as a child-process env var, and redacts it (and the new token) out of stdout + trace. See gitlab-token-playwright-broker.md. Steps 1 “Read secret” and 3 “Read output” are also covered by the broker’s Invoke-WithSopsSecret core.

Reference implementation: scripts/rotate/n8n-bms4-api-key.ps1

Checklist before adding a new automated Playwright script

  • Browser flow is stable (no CAPTCHA, no changing DOM selectors between releases)
  • Key/token is shown once in UI and can be extracted (readonly input, clipboard, or text match)
  • Add to credential-automation-registry.md under “Playwright-automatable” section
  • --dry-run flag works (smoke-tests login without writing anything)
  • DEBUG_SCREENSHOTS mode captures all key steps

After rotation

  1. Verify old key returns 401 at provider
  2. Verify new key works: test the affected service
  3. Close GH issue
  4. Append to docs/secrets-rotation-log.md:
| YYYY-MM-DD | KEY_NAME | playwright rotation | radieu | SOPS <commit> + GH Secret + vps restart |