Playbook: IONOS_API_TOKEN Rotation

Service: IONOS Developer Portal (https://developer.hosting.ionos.de) Secrets: IONOS_API_TOKEN_PREFIX + IONOS_API_TOKEN_ENCRYPTION Rotation frequency: 90 days (or immediately on suspected exposure) Last rotated: 2026-06-27 Next due: 2026-09-27


Token structure

The IONOS API token is split into two parts:

PartKey nameLengthPurpose
Public prefixIONOS_API_TOKEN_PREFIX32 charsIdentifies the token (not secret alone)
Encryption partIONOS_API_TOKEN_ENCRYPTION86 charsSecret component
CombinedUsed in requests asPREFIX.ENCRYPTIONValue passed in X-API-Key header

Rotate both parts together — they are a single credential generated together in the IONOS portal.


What uses this credential

ConsumerHowEffect if missing
cost-exporter container (vps-i1)X-API-Key: PREFIX.ENCRYPTION header to IONOS billing APIVPS cost metrics missing from Grafana
scripts/ionos-*.py / dns-manager.pyIONOS API calls for DNS managementDNS operations fail
GH Actions provisioning workflowVPS provisioning via IONOS Cloud APINew VPS provisioning fails

Where stored

LocationKey namesHow to update
secrets/monitoring.env.sopsIONOS_API_TOKEN_PREFIX, IONOS_API_TOKEN_ENCRYPTIONSOPS write pattern (see below)
.env.local on dev workstationIONOS_API_TOKEN_PREFIX, IONOS_API_TOKEN_ENCRYPTIONEdit both lines
vps-i1 deployed env/opt/p24-infra/monitoring/.envAuto-synced by secrets-sync.yml on merge to dev/main
GH Secret radieu/p24-infraCheck docs/infrastructure-overview.mdgh secret set

Also see: docs/playbooks/ionos-api-server-management.md for full IONOS operations reference.


Automation status

Playwright-automatable — Claude can rotate this via the IONOS Developer Portal. The portal allows creating and deleting API tokens under “API Keys” or “Developer Portal”. Spawn a Playwright agent with the prompt below.


Rotation steps

Option A — Playwright agent (preferred)

Spawn a background agent with this prompt:

You are rotating IONOS_API_TOKEN_PREFIX and IONOS_API_TOKEN_ENCRYPTION for the IONOS hosting account.

IMPORTANT: Never display any secret value in your response. Reference key names only.
Both parts must be rotated together — they are generated as a pair.

1. Use Playwright to navigate to https://developer.hosting.ionos.de
   - Log in with radieu@gmail.com credentials
     (IONOS login may use the associated email for the IONOS account — check .env.local for
     IONOS_ACCOUNT_EMAIL if the login email differs from radieu@gmail.com)
   - Navigate to "API Keys" or "Manage API Tokens" section
   - Find the existing token (likely named "p24-infra" or similar)
   - Note its label — do NOT log the prefix or encryption values
   - Click "Delete" or "Revoke" on the old token → Confirm

2. Create a new token:
   - Click "Create API Key" or "Generate new token"
   - Label: p24-infra-<YYYY-MM-DD>
   - The portal will display the token as: PREFIX.ENCRYPTION (split by a dot)
   - Split on the first dot: PREFIX = part before first dot, ENCRYPTION = part after first dot
   - Store silently:
     $env:NEW_PREFIX = <32-char part before dot>
     $env:NEW_ENC = <86-char part after dot>
   - Never print either value

3. Verify lengths before writing:
   Write-Host "PREFIX length: $($env:NEW_PREFIX.Length)"   # expected: 32
   Write-Host "ENC length: $($env:NEW_ENC.Length)"          # expected: 86

4. Update secrets/monitoring.env.sops:
   $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
   $tempPath = "$PWD\secrets\monitoring-edit.env.sops"
   $updated = $plain -replace "^IONOS_API_TOKEN_PREFIX=.*", "IONOS_API_TOKEN_PREFIX=$env:NEW_PREFIX"
   $updated = $updated -replace "^IONOS_API_TOKEN_ENCRYPTION=.*", "IONOS_API_TOKEN_ENCRYPTION=$env:NEW_ENC"
   [System.IO.File]::WriteAllText($tempPath, ($updated -join "`n") + "`n",
     [System.Text.UTF8Encoding]::new($false))
   $enc = sops --encrypt --input-type dotenv --output-type dotenv $tempPath
   [System.IO.File]::WriteAllText("$PWD\secrets\monitoring.env.sops",
     ($enc -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
   # Canary
   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($tempPath)

5. Verify the new token works:
   $combined = "$env:NEW_PREFIX.$env:NEW_ENC"
   $r = Invoke-RestMethod "https://api.hosting.ionos.com/dns/v1/zones" `
     -Headers @{ "X-API-Key" = $combined }
   Write-Host "DNS zones accessible: $($r.Count)"
   $combined = ""; $env:NEW_PREFIX = ""; $env:NEW_ENC = ""

6. Commit and open PR targeting main:
   git add secrets/monitoring.env.sops
   git commit -m "chore: rotate IONOS_API_TOKEN (scheduled 90d)"
   Push and open PR via gh pr create.

7. After merge: restart cost-exporter on vps-i1:
   ssh root@217.154.82.162 "cd /opt/p24-infra/monitoring && docker compose restart cost-exporter"

8. Append to docs/secrets-rotation-log.md:
   | <YYYY-MM-DD> | IONOS_API_TOKEN_PREFIX + ENCRYPTION | scheduled 90d rotation | AI-agent | SOPS (monitoring) |

Option B — Manual (fallback)

Step 1 — Log into IONOS Developer Portal
  URL: https://developer.hosting.ionos.de
  Use radieu@gmail.com (or the IONOS account email — check .env.local for IONOS_ACCOUNT_EMAIL)

Step 2 — Delete old token
  → API Keys (or similar section)
  → Find "p24-infra" or current active token
  → Delete / Revoke → Confirm

Step 3 — Create new token
  → Create API Key / Generate Token
  → Label: p24-infra-<YYYY-MM-DD>
  → The portal shows: PREFIX.ENCRYPTION (one string with a dot separator)
  → Split on the first dot:
    - PREFIX: 32 chars before the first dot
    - ENCRYPTION: 86 chars after the first dot

Step 4 — Update secrets/monitoring.env.sops
  $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
  $tempPath = "$PWD\secrets\monitoring-edit.env.sops"
  $env:NEW_PREFIX = "<32-char prefix from step 3>"
  $env:NEW_ENC = "<86-char encryption from step 3>"
  $updated = $plain -replace "^IONOS_API_TOKEN_PREFIX=.*", "IONOS_API_TOKEN_PREFIX=$env:NEW_PREFIX"
  $updated = $updated -replace "^IONOS_API_TOKEN_ENCRYPTION=.*",
    "IONOS_API_TOKEN_ENCRYPTION=$env:NEW_ENC"
  [System.IO.File]::WriteAllText($tempPath, ($updated -join "`n") + "`n",
    [System.Text.UTF8Encoding]::new($false))
  $enc = sops --encrypt --input-type dotenv --output-type dotenv $tempPath
  [System.IO.File]::WriteAllText("$PWD\secrets\monitoring.env.sops",
    ($enc -join "`n") + "`n", [System.Text.UTF8Encoding]::new($false))
  # Canary
  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($tempPath)
  $env:NEW_PREFIX = ""; $env:NEW_ENC = ""

Step 5 — Commit and push
  git add secrets/monitoring.env.sops
  git commit -m "chore: rotate IONOS_API_TOKEN (scheduled 90d)"
  git push
  gh pr create --base main --title "chore: rotate IONOS_API_TOKEN"

Step 6 — After merge: restart cost-exporter
  ssh root@217.154.82.162 "cd /opt/p24-infra/monitoring && docker compose restart cost-exporter"

Step 7 — Append to docs/secrets-rotation-log.md
  | <YYYY-MM-DD> | IONOS_API_TOKEN_PREFIX + ENCRYPTION | scheduled 90d rotation | radieu | SOPS (monitoring) |

Verification

# 1. Confirm SOPS updated (both keys present with expected lengths)
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
$decrypted = sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops
 
$prefix = ($decrypted | Select-String "^IONOS_API_TOKEN_PREFIX=").ToString().Split('=',2)[1]
$enc    = ($decrypted | Select-String "^IONOS_API_TOKEN_ENCRYPTION=").ToString().Split('=',2)[1]
Write-Host "PREFIX length: $($prefix.Length)"   # expected: 32
Write-Host "ENC length:    $($enc.Length)"       # expected: 86
 
# 2. Test API call (use combined token — never print it)
$combined = "$prefix.$enc"
$r = Invoke-RestMethod "https://api.hosting.ionos.com/dns/v1/zones" `
    -Headers @{ "X-API-Key" = $combined }
Write-Host "DNS zones count: $($r.Count)"   # expected: >0
$combined = ""; $prefix = ""; $enc = ""; $env:SOPS_AGE_KEY_FILE = ""
 
# 3. Check cost-exporter metrics after container restart
ssh root@217.154.82.162 "curl -s http://localhost:9210/metrics | grep ionos | head -5"

Escalation

SymptomAction
IONOS portal URL has changedTry https://my.ionos.de → Developer API section
Login failsIONOS login uses a separate IONOS account email; check .env.local for IONOS_ACCOUNT_EMAIL
Token shown without dot separatorSome portal versions show PREFIX and ENCRYPTION in separate fields; combine manually
cost-exporter still shows old metricsContainer needs restart; check secrets-sync.yml has merged and run
DNS operations fail after rotationscripts/dns-manager.py reads from .env on vps-i1; wait for sync or manually update /opt/p24-infra/monitoring/.env

Prevention

  • The credential-rotation.yml GH Actions workflow (Monday 06:00 UTC) opens a human-action issue when next_due passes.
  • IONOS does not send expiry warnings for API tokens.
  • Always rotate both PREFIX and ENCRYPTION together — they are a matched pair from a single generation event.
  • After rotation, update this playbook’s Last rotated and Next due dates.
  • See also: docs/playbooks/ionos-api-server-management.md for related IONOS API operations.

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