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
- Human: Create a GH issue labelled
human-actiondescribing which credential to rotate - Claude: Navigates to provider credentials page via Playwright MCP
- Human: Reviews the page — approve proceeding if correct
- Claude: Creates new key / initiates rotation in browser
- Human: Copies the new credential value — DO NOT paste into chat
- Human: In terminal only:
$env:NEW_VALUE = "paste-here" - 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 = ""- 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 = ""- Claude: Restarts affected containers on VPS (via SSH)
- Claude: Logs rotation in
dev_r_rotation_logwithrotation_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'])")- Human: Revokes old key at provider
- Claude: Closes GH issue, appends to
docs/secrets-rotation-log.md
Fallback — Playwright MCP unavailable
If Playwright MCP is not available in the session:
- Human navigates to provider manually, creates new key
- Human sets
$env:NEW_VALUE = "paste-here"in terminal - Claude runs steps 7–12 above (SOPS update + distribution)
Credentials using this method
| Credential | Provider | Notes |
|---|---|---|
TELEGRAM_BOT_TOKEN | Telegram BotFather | /revoke command, then /token |
OPENAI_ADMIN_KEY | platform.openai.com | API keys page → Create new |
DISCORD_BOT_TOKEN | discord.com/developers | Bot page → Reset Token |
CF_GLOBAL_API_KEY | dash.cloudflare.com | My Profile → API Tokens → Global API Key → View (no rotation endpoint — must use existing or create new account key) |
CLAUDE_RUNNER_OAUTH_VPS_I1 | claude.ai | See claude-runner-oauth-rotation.md |
CLAUDE_RUNNER_OAUTH_BMS4 | claude.ai | See 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
| Convention | Rule |
|---|---|
| Required env | <SERVICE>_PASS (password, read from SOPS silently), TOKEN_OUT_FILE (output path) |
| Optional env | DEBUG_SCREENSHOTS=1 (saves screenshots to /tmp/<script>-*.png) |
| Chrome profile | Reuse most-recent mcp-chrome-* from AppData\Local\ms-playwright\ for session persistence |
| Key extraction | Try in order: readonly input → code element → clipboard → page text regex |
| Output | Write raw value to TOKEN_OUT_FILE; never log the value to stdout |
| Exit codes | 0 success · 1 fatal · 2 MFA required (retry with TOTP env var) |
| Cleanup | Clear the in-memory variable immediately after writing to file |
Reference implementation: scripts/rotate/discord-bot-token.js
PS1 wrapper contract
| Step | Action |
|---|---|
| 0 — Sanity | Check JS script exists, playwright installed |
| 1 — Read secret | Extract <PASS_KEY> from SOPS silently into $env:SERVICE_PASS (never print) |
--dry-run | Stop after step 1 — no browser, no SOPS write |
| 2 — Run JS | node 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 — Verify | Call service API with new key before touching SOPS |
| 5 — SOPS update | scripts/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 — Log | Print 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. Seegitlab-token-playwright-broker.md. Steps 1 “Read secret” and 3 “Read output” are also covered by the broker’sInvoke-WithSopsSecretcore.
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.mdunder “Playwright-automatable” section -
--dry-runflag works (smoke-tests login without writing anything) - DEBUG_SCREENSHOTS mode captures all key steps
After rotation
- Verify old key returns 401 at provider
- Verify new key works: test the affected service
- Close GH issue
- Append to
docs/secrets-rotation-log.md:
| YYYY-MM-DD | KEY_NAME | playwright rotation | radieu | SOPS <commit> + GH Secret + vps restart |