Playbook: Auto-rotate DISCORD_BOT_TOKEN via Playwright (Discord Dev Portal UI)
RETIRED — 2026-08-07 (#5868)
DISCORD_BOT_TOKENhas been retired, not rotated. The morning-of rotation (#5775, closing out exposure incident #5282) lost the new token value to a$env:cross-tool-call scope bug (same class as the prior false-expiry incident) before it was durably written to SOPS — see #5868 for the full timeline. The old exposed token was already dead (403). Before requesting a third manual Discord reset, usage was re-audited and the credential was found to have no live consumers:
- n8n (bms-4)
discordBotApicredential (idmOjEebvriQM) — referenced by 0 of 70 workflows (46 active), confirmed via aworkflow_entityquery.- The Alertmanager → Discord-bot-poll bridge described below was replaced by a direct Alertmanager → n8n webhook → GitHub comment bridge (#1960); no live alert route depends on the bot token anymore.
- The only remaining reference,
scripts/discord-provisioning.py, is an on-demand manual tool, not a running service.Decision (user-confirmed): retire the key instead of re-rotating it.
DISCORD_BOT_TOKENhas been removed entirely fromsecrets/monitoring.env.sops(not left empty) and theDISCORD_BOT_TOKENGH Secret was deleted. The orphaned n8ndiscordBotApicredential row is pending cleanup on bms-4 (SSH/Docker credential-store operation) — see #5868 for status.scripts/discord-provisioning.pyis kept as-is; its bot-token REST path is simply unavailable until/unless someone re-provisions a fresh token in the future — an accepted tradeoff, not a bug.This entire playbook below is now historical reference only. Do not follow it to “restore”
DISCORD_BOT_TOKEN— if the bot-token REST path is needed again, treat it as bootstrapping a brand-new credential (new Discord application/bot or a fresh token onp24-infra-admin), not a rotation of anext_dueschedule. There is no rotation schedule for this key anymore.
Service: Discord Developer Portal (https://discord.com/developers/applications)
Secret: DISCORD_BOT_TOKEN
Application: p24-infra-admin (App ID 1501737509896323072)
SOPS file: secrets/monitoring.env.sops
GH Secret: DISCORD_BOT_TOKEN in radieu/p24-infra
Rotation frequency: 90 days (or immediately on suspected exposure) N/A — retired 2026-08-07, see banner above
Last rotated: 2026-07-03
Next due: 2026-10-03 retired, not due
Canonical playbook for DISCORD_BOT_TOKEN. This supersedes the older
discord-bot-token-rotation.mdby adding the GH Secret rotation step, the/api/v10/applications/@meverification, n8n restart steps, and the mandatory browser-close security step. Structured to mirrortelegram-bot-token-rotation.mdfor consistency.
Why a playbook (not a one-click API rotation)
DISCORD_BOT_TOKEN cannot be rotated via the Discord REST API — Discord exposes no
endpoint to reset a bot token. The only reset path is the Developer Portal web UI
(Bot → Reset Token). That UI flow is fully automatable with Playwright MCP tools, which is
what Option A below does. Option B is the manual fallback for when the browser session is
unavailable.
Trigger conditions
Rotate when any of these is true:
| Trigger | Source |
|---|---|
| Scheduled 90-day rotation reached | credential-rotation.yml (Mon 06:00 UTC) opens a human-action issue when next_due passes |
| Token suspected exposed (chat leak, log leak, committed in plaintext) | Follow docs/playbooks/static-api-key-incident-rotation.md first, then this playbook |
| Bot compromised / unexpected messages observed in Discord | Immediate rotation |
What uses this credential
| Consumer | How | Effect if missing |
|---|---|---|
| n8n workflows on bms-4 | HTTP Request / Discord Bot nodes posting to #p24-issues and #errors channels | Bot messages fail; webhook fallback may still work |
| Monitoring alert automation | Discord bot posts structured alert embeds to alert channels | Alert embeds stop; only raw webhook messages remain |
Note: Discord webhook URLs (
P24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URL, etc.) are separate from the bot token. Webhooks do not require the bot token and are unaffected by this rotation. Only Bot-API consumers break when the token is reset.
Where stored
| Location | Key name | How to update |
|---|---|---|
secrets/monitoring.env.sops | DISCORD_BOT_TOKEN | SOPS write pattern (below) |
GH Secret (radieu/p24-infra) | DISCORD_BOT_TOKEN | gh secret set DISCORD_BOT_TOKEN |
| vps-i1 deployed env | /opt/p24-infra/monitoring/.env | Auto-synced by secrets-sync.yml on merge to dev/main |
| n8n credential on bms-4 | Discord Bot API credential | Update via n8n UI after SOPS change (old token invalid instantly) — see n8n section below |
Login credentials
- URL:
https://discord.com/developers/applications - Email: radieu@gmail.com
- Password: stored in
discord_radieu_passwordkey insecrets/monitoring.env.sops- Safe extraction (never print the value):
(sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops | Select-String "^discord_radieu_password=").ToString().Split('=',2)[1]
- Safe extraction (never print the value):
Automation status
Playwright-automatable — Discord Developer Portal bot-token reset is accessible via web UI. Resetting the token instantly invalidates the old token (see Regression risks). If the Chrome profile connected to Playwright MCP has an active Discord login, no human interaction is needed. If the session is expired, re-login may prompt 2FA/OTP — a Tier 3 blocker that requires human action (escalate, see below).
Dispatch weight: heavy — Playwright tasks require a browser binary (≥300 MB Chromium). All
Discord bot rotation jobs must be dispatched with job_type: "heavy" so the queue router sends
them to a server with Playwright installed (bms-4 or Windows dev).
Regression risks
- Resetting the token invalidates the old token instantly. Any n8n workflow or container using the old credential breaks until the credential object is updated in the n8n UI and the deployed env is re-synced. Schedule rotation during low-traffic windows.
- Expired browser session: if the Chrome profile is not logged in to Discord, Playwright Option A fails at the login gate (requires human 2FA/OTP). Fall back to Option B.
Rotation steps
Option A — Playwright agent (preferred)
You are rotating DISCORD_BOT_TOKEN for the Discord bot (p24-infra-admin) used by p24-infra automation.
IMPORTANT: Never display any secret value in your response. Reference key names only.
If a command output contains a secret value, summarise the result without quoting the value.
1. Read login credentials safely (never print values):
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
$env:DISCORD_PASS = (sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops |
Select-String "^discord_radieu_password=").ToString().Split('=',2)[1].Trim()
2. Use Playwright MCP to navigate to https://discord.com/login
- Email: radieu@gmail.com
- Password: $env:DISCORD_PASS (pass as fill value, never log it)
- If 2FA is prompted, check .env.local for DISCORD_TOTP_SECRET; if absent, abort and use Option B
- Navigate to https://discord.com/developers/applications
- Open the "p24-infra-admin" application (App ID 1501737509896323072)
- Click Bot (left sidebar)
- Click "Reset Token" -> confirm in the dialog
- Copy the revealed new token into $env:NEW_TOKEN (never print)
$env:DISCORD_PASS = ""
3. Update secrets/monitoring.env.sops (LF-only, WriteAllText pattern — see docs/playbooks/sops-windows-crlf.md):
$plain = sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops
$temp = "$PWD\secrets\monitoring-edit.env.sops"
$updated = $plain -replace "^DISCORD_BOT_TOKEN=.*", "DISCORD_BOT_TOKEN=$env:NEW_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\monitoring.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\monitoring.env.sops | Out-Null
if ($LASTEXITCODE -ne 0) { throw "SOPS corrupt — do NOT commit. See docs/playbooks/sops-windows-crlf.md" }
[System.IO.File]::Delete($temp)
4. Update the GH Secret (CI/CD copy) — value piped, never echoed:
$env:NEW_TOKEN | gh secret set DISCORD_BOT_TOKEN --repo radieu/p24-infra
$env:NEW_TOKEN = ""; $env:SOPS_AGE_KEY_FILE = ""
5. Close the browser session (SECURITY — do not leave the Reset Token page open):
# Playwright: close the Discord Developer Portal tab / browser context
await page.close();
6. Commit and PR targeting main:
git checkout -b fix/rotate-discord-bot-token origin/main
git add secrets/monitoring.env.sops
git commit -m "chore: rotate DISCORD_BOT_TOKEN (scheduled 90d rotation)"
git push -u origin fix/rotate-discord-bot-token
gh pr create --base main --title "chore: rotate DISCORD_BOT_TOKEN"
gh pr merge --merge --delete-branch
7. Update the n8n Discord credential on bms-4 (old token now invalid):
Navigate to https://n8n.bms-4.infra.zintegrowana.online/credentials
-> Find the Discord Bot API credential -> Edit -> update the Bot Token field -> Save
8. Restart any n8n workers / containers still holding the old token (see "n8n / container restart" below).
9. Append a row to docs/secrets-rotation-log.md (see "Rotation log" below).
Browser-close is mandatory. Per the issue #1691 security note: leaving the Developer Portal open after a token reset is a risk — the reset-token page may still be visible or accessible in browser history. Always run
await page.close();(or navigate toabout:blank) as the final Playwright action before reporting back.
Option B — Manual (fallback when browser session unavailable)
Step 1 — Log in to Discord Developer Portal
URL: https://discord.com/developers/applications
Email: radieu@gmail.com
Password: see discord_radieu_password in secrets/monitoring.env.sops
Complete 2FA if prompted
Step 2 — Reset bot token
-> Select the "p24-infra-admin" application (App ID 1501737509896323072)
-> Bot -> Reset Token -> confirm
-> Copy the new token (shown once; long string containing two periods)
Step 3 — Store in .env.local then hand off to Claude:
Add to d:\code_2026\p24-infra\.env.local:
DISCORD_BOT_TOKEN_NEW=<value>
(Use [System.IO.File]::WriteAllText to preserve LF; never display the value.)
Tell Claude "Discord bot token updated in .env.local" — Claude handles SOPS + GH Secret + PR.
Step 4 — Close the Developer Portal tab once the value is captured (security).
Option C — bms-4 heavy worker (headless Playwright, autonomous)
Prerequisite check:
- bms-4 has Playwright 1.40.0 at
/usr/local/bin/playwrightwith Chromium binaries at~/.cache/ms-playwright/chromium-1228/— ready to use, no install needed. - The rotation script
scripts/rotate/discord-bot-token.jsmust be present in the p24-infra repo clone on bms-4 at/opt/p24-infra/scripts/rotate/(available after the PR that introduced it is merged to main). discord_radieu_passwordmust be available to the worker. It lives insecrets/monitoring.env.sopswhich bms-4 workers can decrypt via the age key at/home/claude-runner/.age/p24-infra-keys.txt.
Job dispatch:
POST https://p24-meta-dispatcher.radieu.workers.dev/queue-issue
{ "issue_number": <N>, "repo": "radieu/p24-infra", "job_type": "heavy" }Playwright rotation issues MUST use job_type: "heavy" — the dispatcher routes them to bms-4
(the only server with Playwright + Chromium installed). Light workers lack the browser binary.
Worker instructions (include in GH issue body):
@ai-worker: PLAYWRIGHT_HEAVY rotation task.
1. Confirm Playwright is available:
playwright --version # expected: 1.40.0
ls ~/.cache/ms-playwright/chromium-1228/ # must exist
2. Load discord password from SOPS:
SOPS_AGE_KEY_FILE=/home/claude-runner/.age/p24-infra-keys.txt
export DISCORD_PASS=$(sops --decrypt --input-type dotenv --output-type dotenv \
/opt/p24-infra/secrets/monitoring.env.sops | grep "^discord_radieu_password=" | cut -d= -f2-)
3. Run the rotation script:
cd /opt/p24-infra
TOKEN_OUT_FILE=/tmp/discord-token-$$ DISCORD_PASS="$DISCORD_PASS" \
node scripts/rotate/discord-bot-token.js
unset DISCORD_PASS
4. Update SOPS with new token (use python3 sops-edit pattern):
NEW_TOKEN=$(cat /tmp/discord-token-$$); rm /tmp/discord-token-$$
# Write Python edit script to /tmp/discord_update.py then run it
# (see docs/playbooks/sops-edit-operations.md §Linux pattern)
5. Commit + PR + update rotation log.
# Do NOT hardcode token value — read from SOPS in step 4 via python3
Self-healing note: If discord-bot-token.js breaks (Discord changes their portal UI), the
script captures a debug screenshot to /tmp/discord-debug-*.png. SSH to bms-4, inspect the
screenshots, then spawn a Playwright MCP session (Windows dev) to rebuild the selector.
The script is designed to fail fast with screenshots rather than silently hang.
n8n Discord credential update
After rotation, the n8n Discord Bot API credential must be updated immediately — the old token is invalid from the moment it is reset.
n8n API key prerequisite
⚠️ Known issue (2026-07-03):
BMS4_N8N_API_KEYin SOPS is an expired JWT session token (not a static API key). All/api/v1/calls return HTTP 401. Fix before next rotation:
- Log in to n8n UI → Settings → n8n API → delete old key → create new key (starts with
n8n_api_)- Run
/role-secret-manager→ updateBMS4_N8N_API_KEYinn8n-bms4.env.sopsANDN8N_BMS4_API_KEYinmonitoring.env.sopswith the new static key- Track in issue #2659
Update via n8n REST API (when API key is valid)
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
$bms4 = sops --decrypt --input-type dotenv --output-type dotenv secrets\n8n-bms4.env.sops
$env:N8N_KEY = ($bms4 | Where-Object { $_ -match "^BMS4_N8N_API_KEY=" }).Split("=",2)[1].Trim()
$env:N8N_HOST = ($bms4 | Where-Object { $_ -match "^BMS4_N8N_HOST=" }).Split("=",2)[1].Trim()
$mon = sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops
$env:DISCORD_TOKEN = ($mon | Where-Object { $_ -match "^DISCORD_BOT_TOKEN=" }).Split("=",2)[1].Trim()
$bms4 = $null; $mon = $null
# Find Discord credential
$creds = Invoke-RestMethod "$env:N8N_HOST/api/v1/credentials" `
-Headers @{ "X-N8N-API-KEY" = $env:N8N_KEY } -Method GET
$discordId = ($creds.data | Where-Object { $_.type -like "*discord*" } | Select-Object -First 1).id
Write-Host "Discord cred id: $discordId"
# Update credential (PATCH)
$body = @{ data = @{ botToken = $env:DISCORD_TOKEN } } | ConvertTo-Json -Depth 3
Invoke-RestMethod "$env:N8N_HOST/api/v1/credentials/$discordId" `
-Headers @{ "X-N8N-API-KEY" = $env:N8N_KEY; "Content-Type" = "application/json" } `
-Method PATCH -Body $body
$env:N8N_KEY = ""; $env:DISCORD_TOKEN = ""Update via n8n UI (manual fallback)
1. Navigate to https://n8n.bms-4.infra.zintegrowana.online
2. Log in (admin credentials)
3. Left sidebar → Credentials
4. Find "Discord Bot API" credential → Edit
5. Update the Bot Token field with the new value from secrets/monitoring.env.sops
6. Save
7. Restart n8n workers (see container restart below)
n8n / container restart
After the new token is live in SOPS + the deployed env, restart consumers holding the old token:
# n8n workers on bms-4 (queue mode — restart all workers + main):
ssh root@54.36.123.110 "cd /opt/p24-infra/bms-4 && docker compose restart bms-4-n8n-1 bms-4-n8n-worker-1-1 bms-4-n8n-worker-2-1 bms-4-n8n-worker-3-1"
# Any monitoring container on vps-i1 that reads DISCORD_BOT_TOKEN (only after secrets-sync ran):
ssh root@217.154.82.162 "cd /opt/p24-infra/monitoring && docker compose up -d --no-deps <service>"Verification
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
$line = sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops |
Select-String "^DISCORD_BOT_TOKEN="
$val = $line.ToString().Split('=',2)[1]
Write-Host "DISCORD_BOT_TOKEN length: $($val.Length)" # expected: > 50, never print the value
# Quick API check — confirms the bot application identity, sends no messages:
$env:T = $val
$r = Invoke-RestMethod "https://discord.com/api/v10/applications/@me" `
-Headers @{ Authorization = "Bot $env:T" }
Write-Host "Discord application: $($r.name) (id $($r.id))" # expected: p24-infra-admin / 1501737509896323072
$env:T = ""; $env:SOPS_AGE_KEY_FILE = ""A 200 with the expected application name confirms the new token is valid. A 401 Unauthorized
means the token is wrong or was not saved correctly — re-check the SOPS write and canary.
Rotation log
Append a row to docs/secrets-rotation-log.md (newest first) once the rotation is executed:
| <YYYY-MM-DD> | DISCORD_BOT_TOKEN | scheduled 90d rotation (or: incident #NNNN) | claude (Playwright) | SOPS monitoring.env.sops + GH Secret (this PR) |
Also bump Last rotated / Next due at the top of this playbook.
Escalation
| Symptom | Action |
|---|---|
Discord login requires 2FA code and .env.local has no DISCORD_TOTP_SECRET | Create a human-action GH issue in radieu/p24-infra (title: 🔴 [Discord] DISCORD_BOT_TOKEN rotation blocked — Dev Portal session expired, manual 2FA required); user runs Option B and provides OTP |
| Developer Portal shows no applications | Confirm you are logged in as radieu@gmail.com, not a different account |
| ”Reset Token” button is greyed out | Bot may require privileged intents to be confirmed — check Bot settings |
/api/v10/applications/@me returns 401 after merge | Token not saved correctly — re-run SOPS write + canary, re-verify |
| vps-i1 / n8n automation still fails after merge | Check secrets-sync.yml ran; update the n8n Discord credential; restart affected containers (above) |
| n8n REST API returns 401 when updating Discord credential | BMS4_N8N_API_KEY is likely an expired JWT — see issue #2659 and the “n8n API key prerequisite” section above |
Playwright script discord-bot-token.js fails with no screenshots | Check if script exists in /opt/p24-infra/scripts/rotate/ — PR with script must be merged to main first |
On any unrecoverable failure during an automated run, also send a Discord alert via
P24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URL (color red) and open a GH issue — per the
Error Notification Standard in CLAUDE.md.
Prevention
- Discord bot tokens do not expire but can be compromised; rotate on the 90-day schedule.
- The
credential-rotation.ymlGH Actions workflow (Monday 06:00 UTC) checksnext_dueand opens ahuman-actionissue when due. - Keep the Discord login session active in the Playwright Chrome profile to enable Option A.
- After rotation, update Last rotated and Next due at the top of this playbook.
References
- Older playbook (superseded):
discord-bot-token-rotation.md - Structural reference:
telegram-bot-token-rotation.md - SOPS write safety:
sops-windows-crlf.md - Incident path (exposure):
static-api-key-incident-rotation.md - Rotation script:
scripts/rotate/discord-bot-token.js - Related issues: #1691 (this playbook), #1515 (rotate DISCORD_BOT_TOKEN), #1506 (mass rotation tracker), #2659 (n8n API key JWT fix)
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="DISCORD_BOT_TOKEN",
result="success", # "success" | "failed" | "skipped"
detail="Scheduled rotation — Discord bot token regenerated via Discord Developer Portal and SOPS updated",
env="bms-4",
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', 'DISCORD_BOT_TOKEN', 'success', 'Scheduled rotation — Discord bot token regenerated via Discord Developer Portal and SOPS updated', 'bms-4')
"
$env:SUPABASE_URL = ''; $env:SUPABASE_SERVICE_KEY = ''