Skip to content

Secrets & Credentials Policy

Status: Authoritative · Owner: Tech Lead · Last reviewed: 2026-04-30

This document defines where every secret/credential in the Savoy platform lives, who controls it, how it flows from creation to runtime consumption, and how to add a new one. It is the single source of truth — Bicep, pipelines, scripts, and runtime code all defer to this classification.

  1. No real secret value lives in the git repository — ever. Not in source files, not in .env.example, not in plan docs, not in shell scripts, not in Bicep parameter files. Verified by gitleaks on every commit.
  2. Azure Key Vault is the only runtime source of truth. Every secret consumed by a deployed App Service resolves via @Microsoft.KeyVault(...) references. No App Service appSetting holds a secret value directly.
  3. The pipeline is privileged but minimal. Azure DevOps pipeline knows only the operational secrets it cannot avoid (gateBypassSecret, cloudflareWorkersToken). It never sees app-runtime secrets like Mailjet keys or SQL passwords.
  4. 1Password is the operator-side vault. Anything an operator must hand-feed to Bicep or to az CLI lives in 1Password vault Savoy / Infra. Not in shell history, not in personal Notes, not in Slack DMs.
  5. Self-generated > hand-fed. Where Bicep can generate a secret deterministically (newGuid()), it does — and the value is never seen by humans, the pipeline, or 1Password. This is strictly the most secure option.

Every secret in the system maps to exactly one class. The class determines its lifecycle.

ClassDefinitionExamplesOriginWhere it lives
K1Bootstrap credentials required to create an Azure resource. Cannot be auto-generated because the operator chooses them.SQL admin login, SQL admin passwordOperator at first deploy1Password → CLI param → KV (composed into K4)
K2Self-generated secrets the application uses internally. No external party knows the value.Gate JWT signing key, Revalidate webhook secret, Forms API key, MFA TrustSecret, MCP client secret, Umbraco DeliveryAPI keyBicep newGuid() on first deployKV only — never seen by humans
K33rd-party API tokens — issued by external providers, used by our appCloudflare API token, Cloudflare Workers token, Mailjet API/secret, Better Uptime URLs, GCP service-account JSON, Cookiebot, Zoho OAuthExternal provider → operator → 1Password1Password → az keyvault secret set → KV
K4Composed secrets — derived from outputs of other Bicep resources combined with K1SQL connection string, Blob connection stringBicep composition at deploy timeKV only — never emitted as Bicep output
K5aPer-developer local-dev values — never deployed, never sharedPersonal Zoho/Figma PAT, personal API keys, dev reCAPTCHA test keysEach dev generates their own (interactive prompt or openssl rand)dotnet user-secrets (CMS) or apps/web/.env.local (Next.js) — gitignored
K5bShared-local conventions — same value across the team for local-dev convenience, never deployedLocal SQL SA password (Savoy_Dev_2026!), local Umbraco backoffice email (admin@savoysignature.com)Hardcoded as the default in committed config (e.g., docker-compose.yml). Devs can override with shell env vars but the default works out of the box.Repo-committed defaults + per-dev override via env var
  • Source: 1Password item savoy-sql-admin-{env} (Database type) — login + password (32-char random, generated when the env is first provisioned).
  • Bicep: declared as @secure() param in infra/main.bicep. Passed via CLI from scripts/deploy/{env}.sh which reads from 1Password CLI: op read "op://Savoy Infra/savoy-sql-admin-${ENV}/password".
  • Pipeline: never seen.
  • Rotation: rotate in 1Password → re-deploy Bicep → composed K4 connection string updates in KV automatically.
  • Source: Bicep newGuid() at first deploy. Subsequent deploys reuse the existing KV secret (Bicep existing reference).
  • Bicep: key-vault.bicep declares each as a Microsoft.KeyVault/vaults/secrets resource with value: newGuid(). On first creation Bicep writes the value; on subsequent deploys, the resource has name matching → Azure preserves the existing value.
  • Pipeline: never seen.
  • Operator: never sees the value. If recovery is needed, a fresh secret is generated (and consumers must accept the cycle).
  • Rotation: delete the KV secret → next deploy generates a new one. Apps must tolerate restart-time rotation.
  • Source: issued by the provider (Cloudflare dashboard, Mailjet console, GCP IAM, Better Uptime UI, Zoho Developer Console, Cookiebot dashboard).
  • Operator: stores in 1Password vault Savoy / Infra immediately on issue.
  • Bicep: does not declare the secret resource. Bicep only declares the App Service appSetting referencing @Microsoft.KeyVault(VaultName=...;SecretName=...). The secret resource is created out-of-band.
  • Population: operator runs az keyvault secret set --vault-name kv-savoy-{env} --name {secret-name} --value "$(op read 'op://Savoy Infra/...')" once per environment.
  • Pipeline: sees only gateBypassSecret (K2-style, declared in Bicep) and cloudflareWorkersToken (K3, but only one — stored as ADO secret variable AND in 1Password as backup).
  • Rotation: rotate at provider → update 1Password → run az keyvault secret set → restart App Service if needed.
  • Source: Bicep composes from K1 + Bicep outputs of other resources (sqlServer.outputs.serverFqdn, storage.listKeys()).
  • Bicep: the secret value is built inside key-vault.bicep using inline references — never emitted as a Bicep output, otherwise it leaks into the Azure deployment log readable by anyone with Reader on the RG.
  • Pipeline: never sees the value.
  • Rotation: automatic — rotate K1 (SQL admin pwd) → re-deploy → K4 recomposed.
  • Source: the dev’s own machine. Each dev generates their own. Setup scripts use openssl rand or interactive read -p prompts — never hardcoded values.
  • Storage: dotnet user-secrets (CMS) or apps/web/.env.local (Next.js) — both gitignored, both per-machine.
  • Bicep / pipeline: never sees these.
  • Rotation: each dev rotates their own. No coordination required.

K5b — Shared-local convention (committed default)

Section titled “K5b — Shared-local convention (committed default)”
  • Source: a single agreed-upon value documented in committed config, deliberately shared among the team for local-dev convenience.
  • Examples:
    • Savoy_Dev_2026! — local SQL SA password in apps/cms/docker-compose.yml. Allows DB backups to be shared between devs and any new worktree to work without per-dev shell config. Never used in DEV/STAGE/PROD.
    • admin@savoysignature.com — local Umbraco backoffice email (the password remains K5a — interactive prompt).
  • Why it’s allowed in the repo: the value protects nothing beyond the developer’s own localhost (docker container bound to 127.0.0.1, not exposed). It does not cross any trust boundary — no cloud, no network, no deployment. The “no real secrets in repo” principle applies to anything that protects assets beyond the dev machine; K5b values do not.
  • Override: any dev can set SA_PASSWORD (or equivalent env var) in their shell to use a custom value. Compose’s ${SA_PASSWORD:-Savoy_Dev_2026!} pattern means env var wins when present.
  • gitleaks: the specific K5b values are explicitly excluded from secret scanning rules (see .gitleaks.toml). New K5b additions need both a code change and an explicit allowlist entry, gated by code review.
  • Threat model check: A K5b value is acceptable iff it satisfies all three:
    1. Used only on a developer’s localhost
    2. Bound to 127.0.0.1 (not exposed over network)
    3. Trivially recreatable (e.g., destroying the docker container is cheap)

The following are explicit anti-patterns. Any commit introducing one of these is automatically rejected by the pre-commit hook (gitleaks) and/or by code review.

  1. Real secret values in committed files. Including .env.example files, plan docs, README, comments, test fixtures, Storybook stories, anywhere.
  2. @secure() Bicep param sourced from a literal in main.parameters.{env}.json. Parameter files in git must contain only non-sensitive values (env name, SKUs, location). K1 secrets come exclusively from 1Password via deploy scripts.
  3. output ... string in Bicep where the value contains a secret. Use module-internal references instead. Suppressing the linter (#disable-next-line outputs-should-not-contain-secrets) is forbidden except for legacy code being phased out.
  4. App Service appSetting with a literal secret value. Always @Microsoft.KeyVault(...) reference — no exceptions.
  5. Hardcoded fallback secret in shell scripts (e.g. ${SA_PASSWORD:-<SA_PASSWORD>}). Use ${SA_PASSWORD:?Set SA_PASSWORD env var} to fail loudly instead.
  6. Self-rolled placeholder strings that look like secrets (e.g. dev-secret, <GATE_SECRET_DEV>). Use one of two canonical placeholders:
    • __REPLACED_BY_ENV_VAR__ — for keys that are populated from App Service Configuration
    • __DEV_OVERRIDE__ — for keys that are populated by dotnet user-secrets in local dev only
  7. Subscription IDs / tenant IDs / zone IDs / account IDs in committed files. These are not strict secrets but reveal infra topology. Replace with &lt;REDACTED&gt; or read from environment.
  8. Bicep deploys that prompt for secret values interactively. Operator must use the deploy scripts (scripts/deploy/{env}.sh), which read from 1Password — never type secrets at a read -s -p "Password: " prompt (they land in shell history).
  9. process.env.SECRET_* reads in client-side Next.js code (the bundle). All secrets must be server-only (apps/web/src/app/api/, apps/web/src/lib/ server-only). Browser bundle can only see NEXT_PUBLIC_* non-secret values.

5. Inventory — current state (target state after Phase 5)

Section titled “5. Inventory — current state (target state after Phase 5)”

CMS App Service settings (Savoy__* / Umbraco__*)

Section titled “CMS App Service settings (Savoy__* / Umbraco__*)”
SettingClassKV secret nameNotes
ConnectionStrings__umbracoDbDSNK4sql-connection-stringComposed from K1 + sqlServer outputs
Umbraco__CMS__DeliveryApi__ApiKeyK2umbraco-api-keynewGuid() — also referenced by Next.js
Umbraco__CMS__Global__Smtp__UsernameK3mailjet-api-keyFrom 1Password
Umbraco__CMS__Global__Smtp__PasswordK3mailjet-secret-keyFrom 1Password
Savoy__Forms__ApiKeyK2forms-api-keynewGuid() — must match Next.js Bicep
Savoy__Gate__SecretK2gate-secretnewGuid() — must match Next.js + CF Worker
Savoy__Gate__BypassSecretK2gate-bypass-secretnewGuid() — must match Next.js + ADO pipeline secret variable
Savoy__RevalidateSecretK2revalidate-secretnewGuid() — must match Next.js
Savoy__Mfa__TrustSecretK2mfa-trust-secretnewGuid() — was undeclared, declared in Phase 3
Savoy__Mcp__ClientSecretK2mcp-client-secretnewGuid() — OpenIddict
Savoy__Cloudflare__ZoneIdK3cloudflare-zone-idFrom 1Password
Savoy__Cloudflare__ApiTokenK3cloudflare-api-tokenFrom 1Password
Savoy__Cloudflare__Enabled(non-secret)n/aLiteral 'true' in Bicep
Savoy__BlobStorage__Account(non-secret)n/aStorage account name — public identifier
Savoy__KeyVault__Uri(non-secret)n/aKV URI — public endpoint
Savoy__AzureMetrics__SubscriptionId(non-secret topology)n/aSubscription GUID — wired via Bicep, not a literal
Savoy__AzureMetrics__ResourceGroup(non-secret)n/aRG name
Savoy__AzureMetrics__Resources__*(non-secret)n/aApp / SQL / storage resource names
Savoy__Alerting__BetterUptime__CmsUpUrlK3alerting-betteruptime-cms-up-urlURL contains a token segment — treated as secret
Savoy__Alerting__BetterUptime__HeartbeatUrls__* (6)K3alerting-betteruptime-{key}Idem
SettingClassKV secret name
UMBRACO_API_KEYK2umbraco-api-key (same as CMS)
UMBRACO_FORMS_API_KEYK2forms-api-key (same as CMS)
UMBRACO_PREVIEW_SECRETK2preview-secret
REVALIDATE_SECRETK2revalidate-secret (same as CMS)
GATE_SECRETK2gate-secret (same as CMS + CF Worker)
GATE_BYPASS_SECRETK2gate-bypass-secret (same as CMS + ADO pipeline)

reCAPTCHA / Google Maps / GTM / GA4 / Cookiebot keys: see docs/PRD/06_Content_Modeling_Umbraco.md § siteRoot — these live on Umbraco siteRoot, not in App Service. (Migration: Phase 4 of this overhaul.)

ADO secret variableClassPurpose
gateBypassSecretK2Mirrors KV gate-bypass-secret. Used to bypass the dev auth gate during E2E / Lighthouse / ZAP tests. Both values must be identical — see § 6 for the sync procedure.
cloudflareWorkersTokenK3Used by wrangler pages deploy for Storybook + Docs. Stored ALSO in 1Password as backup.
azureSubscription (service connection)(not a value secret)ADO service connection name binding the pipeline to a service principal.
acrServiceConnection (service connection)(not a value secret)Idem for ACR push.
$(System.AccessToken)(built-in)ADO REST API auth.
ItemTypeClassContents
savoy-sql-admin-devDatabaseK1login + password (DEV)
savoy-sql-admin-stageDatabaseK1login + password (STAGE — when provisioned)
savoy-sql-admin-prodDatabaseK1login + password (PROD — when provisioned)
savoy-mailjetAPI CredentialK3API key + secret key
savoy-cloudflare-apiAPI CredentialK3account ID, zone IDs (one per env), API token
savoy-cloudflare-workersAPI CredentialK3Workers deploy token (also synced to ADO)
savoy-better-uptimeNoteK37 heartbeat URLs per env
savoy-gcp-analyticsDocumentK3service-account JSON
savoy-zoho-mcpAPI CredentialK3client ID, client secret, refresh token, portal/project IDs
savoy-figma-patAPI CredentialK3personal access token (per dev — operator owns the team token)
savoy-azure-subscriptionNote(reference)subscription ID, tenant ID, RG conventions
savoy-emergency-recoveryNote(reference)break-glass procedures (see § 7)

6. Bootstrap a new environment (DEV / STAGE / PROD)

Section titled “6. Bootstrap a new environment (DEV / STAGE / PROD)”

Authoritative procedure — use exactly this sequence. Documented also as runnable scripts under scripts/deploy/.

  1. Install Azure CLI: brew install azure-cli
  2. Install 1Password CLI: brew install --cask 1password-cli
  3. Sign into 1Password CLI: op signin
  4. Sign into Azure: az login
  5. Verify access to the Savoy / Infra 1Password vault: op vault list | grep Savoy

6.2 Provision a new environment (e.g. STAGE)

Section titled “6.2 Provision a new environment (e.g. STAGE)”
Terminal window
# Step 1 — create resource group (manual, one-time)
az group create --name rg-savoy-stage --location westeurope
# Step 2 — bootstrap KV with K3 secrets (reads from 1Password)
bash scripts/deploy/bootstrap-env.sh stage
# Step 3 — deploy infra (reads K1 from 1Password, Bicep generates K2)
bash scripts/deploy/deploy-infra.sh stage
# Step 4 — verify all health checks (gate-bypass test, KV refs resolve, heartbeats Healthy)
bash scripts/deploy/verify-env.sh stage

No interactive prompts. No values typed at a shell. No secrets in shell history.

  1. Validates the operator is signed into 1Password CLI and Azure CLI.
  2. Reads K3 secrets from 1Password using op read "op://Savoy Infra/{item}/{field}".
  3. Creates the Key Vault (only the vault itself, no secrets) using a minimal Bicep deploy.
  4. Writes K3 secrets to KV via az keyvault secret set (one per K3 secret in § 5).
  5. Reports success — KV is ready for the full Bicep deploy.
  1. Reads K1 (SQL admin login + password) from 1Password.
  2. Runs az deployment group create -g rg-savoy-{env} --template-file infra/main.bicep --parameters environment={env} --parameters sqlAdminLogin=$LOGIN --parameters sqlAdminPassword=$PWD.
  3. Bicep:
    • Provisions all resources (App Services, SQL, storage, App Insights, RBAC).
    • Generates K2 secrets (newGuid()) and writes them to KV — first deploy only; subsequent deploys preserve existing.
    • Composes K4 (SQL connection string, Blob connection string) and writes to KV.
    • Wires App Service Configuration with @Microsoft.KeyVault(...) references for every K2/K3/K4.
  4. Smoke test: hits https://app-umbraco-savoy-{env}.azurewebsites.net/umbraco/api/savoy-dashboard/heartbeat and checks 200 OK.
  1. Asserts every App Service KV reference resolves (no Reference resolution error).
  2. Hits /umbraco/api/savoy-dashboard/heartbeat and asserts every check is Healthy.
  3. Probes the dev gate with X-Gate-Bypass: $(op read 'op://Savoy Infra/savoy-gate-bypass-{env}/secret') and asserts 200 (proves gate-bypass-secret is in sync between KV, App Service, and ADO).
  4. Verifies CF cache rule is wired (calls setup-rate-limits.sh --check).
  5. Reports a green/red checklist.

See docs/dev-backend-guides/13_SECRETS_ROTATION.md for cadences and per-class rotation procedures.

See 1Password → savoy-emergency-recovery for procedures, including:

  • Operator account locked out of 1Password — alternative trustees and recovery codes.
  • KV soft-deleted accidentally — recovery via Azure portal within 90 days.
  • Single K3 secret leaked — rotation procedure that doesn’t break running services.
  • Operator laptop lost/stolen — KV access revocation, ADO PAT revocation, 1Password device removal.

When the application needs a new secret:

  1. Determine the class (K1–K5) using § 2.
  2. Add the consumer in code (IConfiguration.GetValue&lt;string&gt;("Savoy:NewKey") or process.env.NEW_KEY).
  3. Update this doc — § 5 inventory.
  4. Per class:
    • K2: add Microsoft.KeyVault/vaults/secrets resource in key-vault.bicep with value: newGuid(). Add @Microsoft.KeyVault(...) reference in the App Service Bicep.
    • K3: add to 1Password (Savoy / Infra vault). Add az keyvault secret set to bootstrap-env.sh. Add @Microsoft.KeyVault(...) reference in App Service Bicep. Do not declare a Bicep secret resource for K3.
    • K4: compose inside key-vault.bicep from existing K1 + outputs. Add @Microsoft.KeyVault(...) reference.
    • K1: add to 1Password. Add @secure() param in main.bicep. Add op read line in scripts/deploy/{env}.sh.
    • K5: add to apps/cms/user-secrets-setup.sh (with openssl rand generation) and/or apps/web/.env.local.example (with __REPLACE_WITH_DEV_VALUE__ placeholder).
  5. For DEV first, then propagate to STAGE/PROD using the procedures in § 6.
  6. Update rotation cadence in 13_SECRETS_ROTATION.md if the new secret has its own rotation policy.
  • docs/dev-backend-guides/09_INFRASTRUCTURE_BICEP.md — Bicep architecture and deploy workflow (operationalises this policy).
  • docs/dev-backend-guides/13_SECRETS_ROTATION.md — Per-secret rotation cadences and procedures.
  • docs/dev-backend-guides/07_SECURITY_AND_ENVIRONMENTS.md — Security overview and OWASP audit checklist.
  • .claude/rules/anti-patterns.md — Project-wide anti-patterns including secret-handling violations.
  • infra/main.bicep and infra/modules/key-vault.bicep — Reference implementation of K1–K4.
  • scripts/deploy/{bootstrap-env,deploy-infra,verify-env}.sh — Operational scripts implementing § 6.