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.
1. Standards alignment
Section titled “1. Standards alignment”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”:
| Standard | What it gives us |
|---|---|
| ISO/IEC 27001:2022 A.5.17 / A.8.24 | Requirement: change authentication info “at predetermined intervals based on risk” + on suspicion; define cryptoperiods |
| ISO/IEC 27002:2022 §5.17, §8.24, §5.19 | Implementation guidance on auth info management, cryptoperiods, and supplier-relationship secrets |
| NIST SP 800-57 Part 1 Rev.5 | Cryptoperiods — symmetric keys 1–3 years, static authentication keys ≤ 2 years, signature keys 1–3 years |
| NIST SP 800-63B | Memorized secrets (passwords): rotate only on compromise, not periodically (deprecated 2017) |
| PCI-DSS 4.0 §3.7.4 / §8.3.9 | Keys at end of cryptoperiod; passwords every 90 days or dynamic risk analysis |
| OWASP ASVS 4.0.3 V6.4 | Application secrets: change immediately on suspected compromise; cryptoperiod must be defined |
| CIS Controls v8 §5.2 | Unique 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):
- Written policy with cadences (this file).
- Risk assessment justifying the cadence (§ 2 below).
- Reproducible procedure per class (§ 5 below).
- Evidence trail — Azure Key Vault stores
updatedOnper secret automatically; the monthly audit script (§ 6.2) snapshots it.
2. Risk assessment summary
Section titled “2. Risk assessment summary”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
opcache, 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.
3. Cadence summary
Section titled “3. Cadence summary”| Class | Secret examples | Scheduled cadence | Mandatory triggers | Standard reference |
|---|---|---|---|---|
| K1 | SQL admin login + password | 12 months | On-incident · On-personnel-change · On-suspected-leak | NIST SP 800-57 (≤ 2y auth keys); PCI-DSS §8.6.3 |
| K2 | gate-secret, revalidate-secret, umbraco-api-key, forms-api-key, mfa-trust-secret, mcp-client-secret, gate-bypass-secret, preview-secret | 12 months rolling | On-suspected-leak · On gitleaks hit | NIST SP 800-57 (1–3y symmetric); ISO 27002 §8.24 |
| K3 | (per provider — see § 3.1) | Provider-driven, capped at 12 months | Provider rotation policy · On-incident · On-personnel-change | ISO 27002 §5.19 (supplier relationships); PCI-DSS §3.7.4 |
| K4 | sql-connection-string, blob-connection-string | Automatic — recomposed on K1 rotation | Whenever K1 rotates | N/A (derived) |
| K5 | per-dev local SA password, dev API keys | On-personnel-change · 12 months optional · On-laptop-loss | On-personnel-change | ISO 27002 §6.5 (responsibilities after termination) |
3.1. K3 cadence per provider
Section titled “3.1. K3 cadence per provider”Each 3rd-party provider has its own cap. We follow the shorter of (provider-mandated cadence, our default cap).
| Secret | KV name | Scheduled cadence | Reason |
|---|---|---|---|
| Cloudflare API token | cloudflare-api-token | 6 months | High blast radius (zone-wide write access — DNS, WAF, cache purge). Mirrors NIST SP 800-57 cryptoperiod for “high-impact” auth tokens |
| Cloudflare Workers deploy token | cloudflareWorkersToken (also in ADO) | 6 months | Pipeline-privileged; sync to ADO required (see § 5.3.4) |
| Mailjet API key + secret | mailjet-api-key, mailjet-secret-key | 12 months | Email sending only; low blast radius if leaked (rate-limited at provider) |
| GCP service-account JSON (Analytics) | gcp-analytics-sa-json | 12 months | Read-only Analytics Data API; low scope |
| Better Uptime heartbeat URLs | alerting-betteruptime-* (7 secrets) | 24 months | URL contains a token segment; blast radius is “send fake heartbeat” only — no read/write to monitoring data |
| Cookiebot ID | cookiebot-id | Provider-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-token | 12 months | Refresh token doesn’t expire by default; rotate to limit drift |
| Cloudflare zone IDs | cloudflare-zone-id | N/A — not a secret | Topology identifier; treated as confidential but not rotated |
| reCAPTCHA Site Key + Secret Key | (now on siteRoot.settings/forms per Phase 4) | 12 months | Owned by client; rotation via Google reCAPTCHA Admin |
| Google Maps API key | (on siteRoot.settings/maps) | 12 months | Owned by client; restrict by HTTP referrer + API + IP |
4. Trigger taxonomy
Section titled “4. Trigger taxonomy”Rotation is triggered by one of four events. The procedure is the same; only the urgency and logging differ.
| Trigger | Urgency | Examples | Action |
|---|---|---|---|
| Scheduled | Normal (within 30 days of due date) | Annual K1 rotation, 6-month Cloudflare API token rotation | Calendar event in operator’s calendar; processed in next maintenance window |
| Incident | Critical (within 24h) | gitleaks pre-commit hit on a real secret; secret pasted in Slack; suspected breach | Immediate rotation; incident report in 1Password vault Savoy / Infra / Incidents |
| Personnel change | High (within 72h of departure) | Operator leaves the project | Rotate all K1 + all K3 (operator had 1Password access); revoke 1Password device + ADO PAT |
| Provider deprecation | Normal (per provider deadline) | Cloudflare deprecates legacy token format | Rotate per provider notice |
5. Procedures by class
Section titled “5. Procedures by class”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)
# Step 1 — generate new password and update 1PasswordNEW_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 recomposedbash scripts/deploy/deploy-infra.sh ${ENV}
# Step 4 — verifybash 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.
5.2. K2 — Self-generated secrets
Section titled “5.2. K2 — Self-generated secrets”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:
# 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 valueaz 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 — verifybash scripts/deploy/verify-env.sh ${ENV}Cross-system sync — these K2 secrets are mirrored to other systems and must be synced after rotation:
| Secret | Also lives in | Sync action |
|---|---|---|
gate-bypass-secret | ADO pipeline variable gateBypassSecret | Update via az pipelines variable update (see § 5.3.4) |
gate-secret | Cloudflare Worker env | Update via wrangler secret put GATE_SECRET --env ${ENV} |
umbraco-api-key, forms-api-key, revalidate-secret | Both CMS + Next.js App Service | No 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).
5.3. K3 — Third-party tokens
Section titled “5.3. K3 — Third-party tokens”Owner: Tech Lead · Downtime: None (KV reference resolves new value on next request)
5.3.1. Cloudflare API token
Section titled “5.3.1. Cloudflare API token”# 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 1Passwordop item edit "savoy-cloudflare-api" --vault "Savoy Infra" \ "api_token[password]=$NEW_TOKEN"
# Step 3 — push to Key VaultNEW_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 tokenaz 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 worksbash scripts/cloudflare/cache-test-mode.sh ${ENV} verify5.3.2. Mailjet API key + secret
Section titled “5.3.2. Mailjet API key + secret”# 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 Vaultaz 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 emailcurl -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 console5.3.3. GCP service-account JSON
Section titled “5.3.3. GCP service-account JSON”# Step 1 — create new key in GCP Consolegcloud iam service-accounts keys create new-key.json \ --iam-account=savoy-analytics@${PROJECT}.iam.gserviceaccount.com
# Step 2 — store in 1Password as new attachmentop document create new-key.json \ --title "savoy-gcp-analytics-$(date +%Y%m%d)" \ --vault "Savoy Infra"
# Step 3 — push to KVaz keyvault secret set --vault-name kv-savoy-${ENV} --name gcp-analytics-sa-json \ --value "$(cat new-key.json)"
# Step 4 — restart consumersaz 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 Consolegcloud iam service-accounts keys delete ${OLD_KEY_ID} \ --iam-account=savoy-analytics@${PROJECT}.iam.gserviceaccount.com
# Step 7 — securely delete local fileshred -u new-key.json5.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.
# 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 variableaz 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 Cloudflare5.3.5. Better Uptime heartbeat URLs
Section titled “5.3.5. Better Uptime heartbeat URLs”# 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 KVfor 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 URLsaz 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:
- Issue new key in Google Console (reCAPTCHA Admin / Maps Platform).
- Edit the relevant
siteRootnode in Umbraco backoffice (Settings → Forms or Settings → Maps). - Save & publish — triggers cache purge via
CachePurgeService(S4 scenario). - Revoke old key in Google Console once new key is verified live.
5.4. K4 — Composed secrets
Section titled “5.4. K4 — Composed secrets”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):
# Step 1 — rotate storage account key in Azureaz 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 Umbracoaz webapp restart --name app-umbraco-savoy-${ENV} --resource-group rg-savoy-${ENV}5.5. K5 — Per-developer local
Section titled “5.5. K5 — Per-developer local”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:
cd apps/cms && bash user-secrets-setup.shFor 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).
6. Audit & evidence
Section titled “6. Audit & evidence”6.1. Automated evidence
Section titled “6.1. Automated evidence”Azure Key Vault provides per-secret metadata that satisfies the ISO 27001 audit trail requirement without any additional logging:
# List all secrets with last-updated dateaz keyvault secret list --vault-name kv-savoy-${ENV} \ --query "[].{name:name, updated:attributes.updated}" \ -o tableThis is the primary evidence for “when was secret X last rotated”.
6.2. Monthly automated check
Section titled “6.2. Monthly automated check”# 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.
6.3. Quarterly review (Tech Lead)
Section titled “6.3. Quarterly review (Tech Lead)”Every 3 months the Tech Lead:
- Runs
check-secret-ages.shfor all environments. - Confirms every overdue secret has a rotation ticket open.
- Reviews 1Password vault
Savoy / Infra / Incidentsfor any incident-driven rotations in the period. - Updates this document’s “Last reviewed” header.
6.4. Annual ISO audit cycle
Section titled “6.4. Annual ISO audit cycle”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-*.mdsnapshots. - 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 channelT+1h — Open incident in 1Password "Savoy / Infra / Incidents" → Capture: which secret, how detected, exposure window, blast radiusT+2h — Begin rotation per § 5 procedure for the affected classT+6h — Rotation complete + verifiedT+24h — Post-mortem committed to incident vaultT+7d — Review whether procedure or cadence needs updatingFor 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.
8. Cross-references
Section titled “8. Cross-references”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).