Playbook: Resend API Key Rotation

Service: Resend (https://resend.com) — transactional email Secrets: RADEK_BRAND_RESEND_API_KEY, RESEND_API_KEY SOPS files: secrets/monitoring.env.sops, secrets/brandpilot.env.sops Rotation frequency: 90 days (or immediately on suspected exposure) Last rotated: 2026-06-27 Next due: 2026-09-27


Token inventory

Key nameSOPS fileConsumer
RADEK_BRAND_RESEND_API_KEYmonitoring.env.sopsn8n workflows for radekkonarski-personal-brand email sending
RESEND_API_KEYbrandpilot.env.sopsBrandPilot app — transactional emails (welcome, notifications)

These may be the same Resend account but different API keys, or different keys with different scopes. Check the Resend dashboard for existing keys before rotating. Rotate each one separately.


Where stored

LocationKey nameHow to update
secrets/monitoring.env.sopsRADEK_BRAND_RESEND_API_KEYSOPS write pattern
secrets/brandpilot.env.sopsRESEND_API_KEYSOPS write pattern
Vercel env vars (BrandPilot project)RESEND_API_KEYvercel env rm RESEND_API_KEY production && vercel env add or dashboard

Login credentials

  • URL: https://resend.com/api-keys
  • Email: radieu@gmail.com
  • Auth: Google SSO or email/password

Automation status

Playwright-automatable — Resend API key management is accessible via web UI with no additional MFA.


Rotation steps

Option A — Playwright agent (preferred)

You are rotating Resend API keys: RADEK_BRAND_RESEND_API_KEY and RESEND_API_KEY.

IMPORTANT: Never display any secret value in your response. Reference key names only.

1. Use Playwright to navigate to https://resend.com/api-keys
   - Sign in with Google using radieu@gmail.com
   - For RADEK_BRAND_RESEND_API_KEY:
     → Find the key named "radek-brand" or similar → Delete it
     → Create API Key → Name: "radek-brand-<YYYY-MM-DD>" → Full Access or Sending Access
     → Copy value into $env:NEW_BRAND_KEY (never print)
   - For RESEND_API_KEY:
     → Find the key named "brandpilot" or similar → Delete it
     → Create API Key → Name: "brandpilot-<YYYY-MM-DD>" → Full Access or Sending Access
     → Copy value into $env:NEW_BP_KEY (never print)
   - Close the browser

2. Update secrets/monitoring.env.sops (RADEK_BRAND_RESEND_API_KEY):
   $env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
   $plain = sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops
   $temp = "$PWD\secrets\monitoring-edit.env.sops"
   $updated = $plain -replace "^RADEK_BRAND_RESEND_API_KEY=.*", "RADEK_BRAND_RESEND_API_KEY=$env:NEW_BRAND_KEY"
   [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))
   sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops | Out-Null
   if ($LASTEXITCODE -ne 0) { throw "SOPS corrupt — do NOT commit" }
   [System.IO.File]::Delete($temp)

3. Update secrets/brandpilot.env.sops (RESEND_API_KEY):
   $plain = sops --decrypt --input-type dotenv --output-type dotenv secrets\brandpilot.env.sops
   $temp = "$PWD\secrets\brandpilot-edit.env.sops"
   $updated = $plain -replace "^RESEND_API_KEY=.*", "RESEND_API_KEY=$env:NEW_BP_KEY"
   [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\brandpilot.env.sops", ($enc -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
   sops --decrypt --input-type dotenv --output-type dotenv secrets\brandpilot.env.sops | Out-Null
   if ($LASTEXITCODE -ne 0) { throw "SOPS corrupt — do NOT commit" }
   [System.IO.File]::Delete($temp)
   $env:NEW_BRAND_KEY = ""; $env:NEW_BP_KEY = ""

4. Update Vercel env var for BrandPilot (RESEND_API_KEY):
   Run secrets-sync.yml via GH Actions (triggers automatically on merge to dev/main),
   OR update manually via Vercel dashboard:
   https://vercel.com/radieu/brandpilot/settings/environment-variables

5. Commit both SOPS files in one PR:
   git checkout -b fix/rotate-resend-api-keys origin/main
   git add secrets/monitoring.env.sops secrets/brandpilot.env.sops
   git commit -m "chore: rotate RESEND_API_KEY + RADEK_BRAND_RESEND_API_KEY (scheduled 90d)"
   git push -u origin fix/rotate-resend-api-keys
   gh pr create --base main --title "chore: rotate Resend API keys"
   gh pr merge --merge --delete-branch

6. Append to docs/secrets-rotation-log.md:
   | <YYYY-MM-DD> | RESEND_API_KEY + RADEK_BRAND_RESEND_API_KEY | scheduled 90d rotation | AI-agent | SOPS (monitoring + brandpilot) + Vercel |

Option B — Manual (fallback)

Step 1 — Create new keys in Resend dashboard
  URL: https://resend.com/api-keys
  → Sign in → for each key:
    Delete old → Add API Key → name + scope → copy value (shown once, starts with re_)

Step 2 — Store temporarily in .env.local then hand off to Claude:
  Add to d:\code_2026\p24-infra\.env.local:
    RADEK_BRAND_RESEND_API_KEY_NEW=<value>
    RESEND_API_KEY_NEW=<value>
  Tell Claude "Resend keys updated in .env.local" — Claude handles SOPS + PR.

Step 3 — Clear .env.local lines after PR merged
  Remove _NEW lines from .env.local

Verification

$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
 
# 1. Check lengths in SOPS
$b = sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops |
    Select-String "^RADEK_BRAND_RESEND_API_KEY="
Write-Host "RADEK_BRAND_RESEND_API_KEY length: $($b.ToString().Split('=',2)[1].Length)"  # expected: > 20
 
$r = sops --decrypt --input-type dotenv --output-type dotenv secrets\brandpilot.env.sops |
    Select-String "^RESEND_API_KEY="
Write-Host "RESEND_API_KEY length: $($r.ToString().Split('=',2)[1].Length)"  # expected: > 20
 
# 2. Quick API check
$env:K = $r.ToString().Split('=',2)[1]
$resp = Invoke-RestMethod "https://api.resend.com/domains" `
    -Headers @{ Authorization = "Bearer $env:K" }
Write-Host "Resend domains: $($resp.data.Count)"
$env:K = ""; $env:SOPS_AGE_KEY_FILE = ""

Escalation

SymptomAction
Resend login failsTry email/password login if Google SSO fails
BrandPilot emails stop after rotationCheck Vercel env var was updated; redeploy if needed
n8n email workflows fail on bms-4secrets-sync.yml may not have run; check GH Actions or manually update bms-4 env
re_ prefix missing from new keyEnsure “Full Access” scope is selected when creating

Prevention

  • Resend does not send expiry warnings for API keys.
  • The credential-rotation.yml GH Actions workflow (Monday 06:00 UTC) checks next_due and opens a human-action issue.
  • After rotation, update Last rotated and Next due at the top of this playbook.

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="RESEND_API_KEY",
    result="success",  # "success" | "failed" | "skipped"
    detail="Scheduled rotation — Resend API key regenerated and SOPS brandpilot updated",
    env="vps-i1",
    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', 'RESEND_API_KEY', 'success', 'Scheduled rotation — Resend API key regenerated and SOPS brandpilot updated', 'vps-i1')
"
$env:SUPABASE_URL = ''; $env:SUPABASE_SERVICE_KEY = ''