Skip to content

Secrets Rotation Policy

Status: Authoritative · Owner: Tech Lead · Last reviewed: 2026-04-30 · Next scheduled review: 2026-10-30

This document defines how every secret class in the Savoy platform is rotated — scheduled cadence, incident triggers, per-class procedure, and audit evidence. It operationalises § 7 of 12_SECRETS_POLICY.md and is the single source of truth for “when do we rotate X” and “who does it”.

Read first: docs/dev-backend-guides/12_SECRETS_POLICY.md — the five classes (K1–K5), the five principles, and the inventory. This document does not redefine those; it defines their lifecycle.

ISO/IEC 27001:2022 is deliberately risk-based and non-prescriptive about rotation timeframes — it requires that the organisation defines, documents, and follows a cadence, not that the cadence be a specific number. The numbers in this document are derived from the standards companion that ISO 27001 audits accept as evidence of “good practice”:

StandardWhat it gives us
ISO/IEC 27001:2022 A.5.17 / A.8.24Requirement: change authentication info “at predetermined intervals based on risk” + on suspicion; define cryptoperiods
ISO/IEC 27002:2022 §5.17, §8.24, §5.19Implementation guidance on auth info management, cryptoperiods, and supplier-relationship secrets
NIST SP 800-57 Part 1 Rev.5Cryptoperiods — symmetric keys 1–3 years, static authentication keys ≤ 2 years, signature keys 1–3 years
NIST SP 800-63BMemorized secrets (passwords): rotate only on compromise, not periodically (deprecated 2017)
PCI-DSS 4.0 §3.7.4 / §8.3.9Keys at end of cryptoperiod; passwords every 90 days or dynamic risk analysis
OWASP ASVS 4.0.3 V6.4Application secrets: change immediately on suspected compromise; cryptoperiod must be defined
CIS Controls v8 §5.2Unique credentials per system; rotate on personnel change

Auditor’s checklist — the four things ISO 27001 auditors look for (all four are satisfied by this document + the existing tooling):

  1. Written policy with cadences (this file).
  2. Risk assessment justifying the cadence (§ 2 below).
  3. Reproducible procedure per class (§ 5 below).
  4. Evidence trail — Azure Key Vault stores updatedOn per secret automatically; the monthly audit script (§ 6.2) snapshots it.

We rotate on a 12-month baseline for K1/K2 because:

  • No PCI-DSS scope — Savoy does not store cardholder data. Booking is delegated to Synxis/Navarino. The 90-day PCI cadence is therefore a cap, not a floor.
  • No GDPR Art.9 special-category data — guest contact data is Art.6 only. Cadences are driven by operational risk, not regulatory deadline.
  • Self-generated K2 secrets have low external exposure — they live only in Key Vault and App Service env (resolved via @Microsoft.KeyVault(...) references), never in source, logs, or 1Password. Annual rotation is well within NIST SP 800-57’s “static authentication key ≤ 2 years” recommendation.
  • K3 tokens have higher exposure — they exist in 1Password, in the operator’s op cache, and in the issuing provider’s dashboard. Cadence is therefore tighter where blast radius is high (Cloudflare API token: 6 months).
  • Restart-time rotation is acceptable — the Next.js + Umbraco apps tolerate a brief restart on KV secret update. We do not need rolling rotation infrastructure.

The cadences below are conservative for our risk profile (small-team SaaS, no regulated data) and aggressive enough to satisfy a typical ISO 27001 audit without operational pain.

ClassSecret examplesScheduled cadenceMandatory triggersStandard reference
K1SQL admin login + password12 monthsOn-incident · On-personnel-change · On-suspected-leakNIST SP 800-57 (≤ 2y auth keys); PCI-DSS §8.6.3
K2gate-secret, revalidate-secret, umbraco-api-key, forms-api-key, mfa-trust-secret, mcp-client-secret, gate-bypass-secret, preview-secret12 months rollingOn-suspected-leak · On gitleaks hitNIST SP 800-57 (1–3y symmetric); ISO 27002 §8.24
K3(per provider — see § 3.1)Provider-driven, capped at 12 monthsProvider rotation policy · On-incident · On-personnel-changeISO 27002 §5.19 (supplier relationships); PCI-DSS §3.7.4
K4sql-connection-string, blob-connection-stringAutomatic — recomposed on K1 rotationWhenever K1 rotatesN/A (derived)
K5per-dev local SA password, dev API keysOn-personnel-change · 12 months optional · On-laptop-lossOn-personnel-changeISO 27002 §6.5 (responsibilities after termination)

Each 3rd-party provider has its own cap. We follow the shorter of (provider-mandated cadence, our default cap).

SecretKV nameScheduled cadenceReason
Cloudflare API tokencloudflare-api-token6 monthsHigh blast radius (zone-wide write access — DNS, WAF, cache purge). Mirrors NIST SP 800-57 cryptoperiod for “high-impact” auth tokens
Cloudflare Workers deploy tokencloudflareWorkersToken (also in ADO)6 monthsPipeline-privileged; sync to ADO required (see § 5.3.4)
Mailjet API key + secretmailjet-api-key, mailjet-secret-key12 monthsEmail sending only; low blast radius if leaked (rate-limited at provider)
GCP service-account JSON (Analytics)gcp-analytics-sa-json12 monthsRead-only Analytics Data API; low scope
Better Uptime heartbeat URLsalerting-betteruptime-* (7 secrets)24 monthsURL contains a token segment; blast radius is “send fake heartbeat” only — no read/write to monitoring data
Cookiebot IDcookiebot-idProvider-driven (rarely rotated)Tenant-scoped public ID; rotation managed in Cookiebot dashboard if compromised
Zoho OAuth (client secret + refresh token)zoho-mcp-client-secret, zoho-mcp-refresh-token12 monthsRefresh token doesn’t expire by default; rotate to limit drift
Cloudflare zone IDscloudflare-zone-idN/A — not a secretTopology identifier; treated as confidential but not rotated
reCAPTCHA Site Key + Secret Key(now on siteRoot.settings/forms per Phase 4)12 monthsOwned by client; rotation via Google reCAPTCHA Admin
Google Maps API key(on siteRoot.settings/maps)12 monthsOwned by client; restrict by HTTP referrer + API + IP

Rotation is triggered by one of four events. The procedure is the same; only the urgency and logging differ.

TriggerUrgencyExamplesAction
ScheduledNormal (within 30 days of due date)Annual K1 rotation, 6-month Cloudflare API token rotationCalendar event in operator’s calendar; processed in next maintenance window
IncidentCritical (within 24h)gitleaks pre-commit hit on a real secret; secret pasted in Slack; suspected breachImmediate rotation; incident report in 1Password vault Savoy / Infra / Incidents
Personnel changeHigh (within 72h of departure)Operator leaves the projectRotate all K1 + all K3 (operator had 1Password access); revoke 1Password device + ADO PAT
Provider deprecationNormal (per provider deadline)Cloudflare deprecates legacy token formatRotate per provider notice

5.1. K1 — Bootstrap credentials (SQL admin)

Section titled “5.1. K1 — Bootstrap credentials (SQL admin)”

Frequency: 12 months · Owner: Tech Lead · Downtime: None (App Service picks up new connection string on next request via KV reference)

Terminal window
# Step 1 — generate new password and update 1Password
NEW_PWD=$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)
op item edit "savoy-sql-admin-${ENV}" password="$NEW_PWD" --vault "Savoy Infra"
# Step 2 — apply to Azure SQL Server (idempotent — Azure rotates the live admin pwd)
az sql server update \
--name sql-savoy-${ENV} \
--resource-group rg-savoy-${ENV} \
--admin-password "$NEW_PWD"
# Step 3 — re-deploy Bicep so K4 (sql-connection-string) is recomposed
bash scripts/deploy/deploy-infra.sh ${ENV}
# Step 4 — verify
bash scripts/deploy/verify-env.sh ${ENV}

Rollback: if step 2 fails, the old password is still active in Azure SQL — op item edit is the only state mutation that needs a manual revert via 1Password’s version history.

Evidence: Azure Key Vault sql-connection-string shows new updatedOn timestamp; 1Password item shows new version; verify-env.sh exits 0.

Frequency: 12 months rolling · Owner: Tech Lead · Downtime: ~30s App Service restart per secret rotated · Operator never sees the value

K2 secrets are generated by Bicep newGuid(). To rotate:

Terminal window
# Step 1 — delete the secret in KV (Bicep `existing` reference will then create fresh)
az keyvault secret delete \
--vault-name kv-savoy-${ENV} \
--name gate-secret # or any other K2 secret
# Wait for soft-delete to complete (immediate for normal secrets)
sleep 5
# Step 2 — re-deploy Bicep (newGuid() runs because the secret no longer exists)
bash scripts/deploy/deploy-infra.sh ${ENV}
# Step 3 — restart consuming App Services so they pick up the new value
az webapp restart --name app-umbraco-savoy-${ENV} --resource-group rg-savoy-${ENV}
az webapp restart --name app-nextjs-savoy-${ENV} --resource-group rg-savoy-${ENV}
# Step 4 — verify
bash scripts/deploy/verify-env.sh ${ENV}

Cross-system sync — these K2 secrets are mirrored to other systems and must be synced after rotation:

SecretAlso lives inSync action
gate-bypass-secretADO pipeline variable gateBypassSecretUpdate via az pipelines variable update (see § 5.3.4)
gate-secretCloudflare Worker envUpdate via wrangler secret put GATE_SECRET --env ${ENV}
umbraco-api-key, forms-api-key, revalidate-secretBoth CMS + Next.js App ServiceNo action — both reference the same KV secret

Rollback: Azure Key Vault soft-delete keeps the previous version for 90 days. To rollback within that window: az keyvault secret recover --vault-name kv-savoy-${ENV} --name gate-secret (recovers latest non-deleted version).

Evidence: KV updatedOn timestamp; App Service restart log; verify-env.sh confirms gate-bypass header still works post-rotation (proves cross-system sync succeeded).

Owner: Tech Lead · Downtime: None (KV reference resolves new value on next request)

Terminal window
# Step 1 — issue new token in Cloudflare dashboard
# https://dash.cloudflare.com/profile/api-tokens
# Permissions: Zone:Read, Zone Settings:Edit, Cache Purge:Purge
# Zone Resources: include the env's zone only
# Step 2 — update 1Password
op item edit "savoy-cloudflare-api" --vault "Savoy Infra" \
"api_token[password]=$NEW_TOKEN"
# Step 3 — push to Key Vault
NEW_TOKEN=$(op read "op://Savoy Infra/savoy-cloudflare-api/api_token")
az keyvault secret set \
--vault-name kv-savoy-${ENV} \
--name cloudflare-api-token \
--value "$NEW_TOKEN"
# Step 4 — restart Umbraco so CachePurgeService re-resolves the token
az webapp restart --name app-umbraco-savoy-${ENV} --resource-group rg-savoy-${ENV}
# Step 5 — revoke the OLD token in Cloudflare dashboard (only after step 4 confirmed working)
# Step 6 — verify cache purge still works
bash scripts/cloudflare/cache-test-mode.sh ${ENV} verify
Terminal window
# Step 1 — issue new key pair in Mailjet console
# https://app.mailjet.com/account/apikeys
# Step 2 — update 1Password (both fields)
op item edit "savoy-mailjet" --vault "Savoy Infra" \
"api_key[password]=$NEW_API" \
"secret_key[password]=$NEW_SECRET"
# Step 3 — push to Key Vault
az keyvault secret set --vault-name kv-savoy-${ENV} --name mailjet-api-key \
--value "$(op read 'op://Savoy Infra/savoy-mailjet/api_key')"
az keyvault secret set --vault-name kv-savoy-${ENV} --name mailjet-secret-key \
--value "$(op read 'op://Savoy Infra/savoy-mailjet/secret_key')"
# Step 4 — restart Umbraco (SMTP credentials)
az webapp restart --name app-umbraco-savoy-${ENV} --resource-group rg-savoy-${ENV}
# Step 5 — verify by sending a test heartbeat email
curl -X POST "https://app-umbraco-savoy-${ENV}.azurewebsites.net/umbraco/api/savoy-dashboard/test-email" \
-H "X-Gate-Bypass: $(op read 'op://Savoy Infra/savoy-gate-bypass-${ENV}/secret')"
# Step 6 — revoke OLD keys in Mailjet console
Terminal window
# Step 1 — create new key in GCP Console
gcloud iam service-accounts keys create new-key.json \
--iam-account=savoy-analytics@${PROJECT}.iam.gserviceaccount.com
# Step 2 — store in 1Password as new attachment
op document create new-key.json \
--title "savoy-gcp-analytics-$(date +%Y%m%d)" \
--vault "Savoy Infra"
# Step 3 — push to KV
az keyvault secret set --vault-name kv-savoy-${ENV} --name gcp-analytics-sa-json \
--value "$(cat new-key.json)"
# Step 4 — restart consumers
az webapp restart --name app-umbraco-savoy-${ENV} --resource-group rg-savoy-${ENV}
# Step 5 — verify Analytics dashboard data still loads
# Step 6 — delete OLD key in GCP Console
gcloud iam service-accounts keys delete ${OLD_KEY_ID} \
--iam-account=savoy-analytics@${PROJECT}.iam.gserviceaccount.com
# Step 7 — securely delete local file
shred -u new-key.json

5.3.4. Cloudflare Workers token (also in ADO pipeline)

Section titled “5.3.4. Cloudflare Workers token (also in ADO pipeline)”

This is the only secret stored in TWO places that the operator must keep in sync.

Terminal window
# Step 1 — issue new Workers deploy token in Cloudflare
# https://dash.cloudflare.com/profile/api-tokens (Workers Scripts:Edit + Pages:Edit)
# Step 2 — update 1Password (backup of record)
op item edit "savoy-cloudflare-workers" --vault "Savoy Infra" "token[password]=$NEW_TOKEN"
# Step 3 — update ADO pipeline variable
az pipelines variable update \
--org https://dev.azure.com/Bycom \
--project "Savoy Signature" \
--pipeline-id ${PIPELINE_ID} \
--name cloudflareWorkersToken \
--value "$NEW_TOKEN" \
--secret true
# Step 4 — verify next pipeline run succeeds (Storybook + Docs deploy)
# Step 5 — revoke OLD token in Cloudflare
Terminal window
# Step 1 — regenerate heartbeat URLs in Better Uptime UI (one per heartbeat — 7 total per env)
# Step 2 — update 1Password "savoy-better-uptime" note with new URLs
# Step 3 — push each to KV
for KEY in cms-up cms-heartbeat ingest-heartbeat sitemap-heartbeat purge-heartbeat dashboard-heartbeat preview-heartbeat; do
az keyvault secret set --vault-name kv-savoy-${ENV} \
--name "alerting-betteruptime-${KEY}" \
--value "$(op read "op://Savoy Infra/savoy-better-uptime/${ENV}-${KEY}")"
done
# Step 4 — restart Umbraco so HeartbeatPushService re-reads URLs
az webapp restart --name app-umbraco-savoy-${ENV} --resource-group rg-savoy-${ENV}

5.3.6. reCAPTCHA / Google Maps (siteRoot — not KV)

Section titled “5.3.6. reCAPTCHA / Google Maps (siteRoot — not KV)”

These keys live on Umbraco siteRoot after Phase 4 of the secrets overhaul (P894-T727). Rotation is via the backoffice, not the KV pipeline:

  1. Issue new key in Google Console (reCAPTCHA Admin / Maps Platform).
  2. Edit the relevant siteRoot node in Umbraco backoffice (Settings → Forms or Settings → Maps).
  3. Save & publish — triggers cache purge via CachePurgeService (S4 scenario).
  4. Revoke old key in Google Console once new key is verified live.

Automatic. K4 (sql-connection-string, blob-connection-string) is recomposed by Bicep whenever K1 or the underlying resource (storage account key) rotates. No manual rotation procedure exists or should exist.

To force regeneration of the storage account key (separate from K1):

Terminal window
# Step 1 — rotate storage account key in Azure
az storage account keys renew \
--account-name stsavoy${ENV} \
--resource-group rg-savoy-${ENV} \
--key key1
# Step 2 — re-deploy Bicep (Bicep recomposes blob-connection-string from listKeys())
bash scripts/deploy/deploy-infra.sh ${ENV}
# Step 3 — restart Umbraco
az webapp restart --name app-umbraco-savoy-${ENV} --resource-group rg-savoy-${ENV}

Owner: each developer · Out-of-band trigger: laptop loss, departure from project

Each dev rotates their own local secrets — no central coordination. The setup script regenerates them:

Terminal window
cd apps/cms && bash user-secrets-setup.sh

For Next.js dev keys (apps/web/.env.local), edit manually. There is no central store.

On personnel change: the leaving dev confirms in writing that their ~/.aspnet/DataProtection-Keys/, apps/web/.env.local, and apps/cms/Savoy.Cms/umbraco/Data/DataProtection-Keys/ are wiped via scripts/teardown-worktree.sh for every worktree, and that their machine’s 1Password device is removed by the Tech Lead. No rotation of K1/K2/K3 is needed unless the dev had access to 1Password vault Savoy / Infra (only Tech Lead and Operator do).

Azure Key Vault provides per-secret metadata that satisfies the ISO 27001 audit trail requirement without any additional logging:

Terminal window
# List all secrets with last-updated date
az keyvault secret list --vault-name kv-savoy-${ENV} \
--query "[].{name:name, updated:attributes.updated}" \
-o table

This is the primary evidence for “when was secret X last rotated”.

Terminal window
# scripts/audit/check-secret-ages.sh ${ENV}
#
# Outputs a table of every KV secret with:
# - name
# - last updated date
# - days since rotation
# - cadence target (from this document)
# - status: OK | DUE_SOON (within 30d) | OVERDUE
#
# Exits non-zero if any secret is OVERDUE.

Run from the operator’s machine on the 1st of each month. Output is committed to docs/audit/secret-rotation-${YYYY-MM}.md (gitignored content; tracked filename only) and reviewed at the monthly maintenance window.

Every 3 months the Tech Lead:

  1. Runs check-secret-ages.sh for all environments.
  2. Confirms every overdue secret has a rotation ticket open.
  3. Reviews 1Password vault Savoy / Infra / Incidents for any incident-driven rotations in the period.
  4. Updates this document’s “Last reviewed” header.

Aligned with the project’s ISO 27001 surveillance audit (October each year). Deliverables:

  • This document, updated and signed off by Tech Lead.
  • Last 12 months of audit/secret-rotation-*.md snapshots.
  • Incident report log (1Password).
  • Risk assessment delta (any new secrets added, any provider cadence changes).

7. Incident response — out-of-band rotation

Section titled “7. Incident response — out-of-band rotation”

When a secret is suspected to have leaked, the procedure is the same as the scheduled rotation but time-boxed to 24h and logged in the incident vault.

T+0h — Detection (gitleaks hit, breach indicator, etc.)
→ Notify Tech Lead in #savoy-security Slack channel
T+1h — Open incident in 1Password "Savoy / Infra / Incidents"
→ Capture: which secret, how detected, exposure window, blast radius
T+2h — Begin rotation per § 5 procedure for the affected class
T+6h — Rotation complete + verified
T+24h — Post-mortem committed to incident vault
T+7d — Review whether procedure or cadence needs updating

For K3 leaks — the provider’s revocation is the priority, not the KV rotation. Revoke at provider FIRST, then rotate.

For K2 leaks — soft-delete + Bicep redeploy is sufficient. The leaked value becomes undecryptable on next App Service restart.

For K1 leaks — additionally enable Azure SQL “advanced threat protection” alerts and audit recent SQL admin logins via Azure portal.

  • docs/dev-backend-guides/12_SECRETS_POLICY.md — Authoritative classification + inventory + bootstrap procedures.
  • docs/dev-backend-guides/09_INFRASTRUCTURE_BICEP.md — Bicep architecture; how K2 secrets are declared and how K4 is composed.
  • docs/dev-backend-guides/07_SECURITY_AND_ENVIRONMENTS.md — Security overview and OWASP audit checklist.
  • scripts/deploy/{bootstrap-env,deploy-infra,verify-env}.sh — Operational scripts referenced throughout § 5.
  • scripts/audit/check-secret-ages.sh — Monthly automated check (§ 6.2).
  • 1Password vault Savoy / Infra / Incidents — Incident log (§ 7).