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. The five principles
Section titled “1. The five principles”- 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 bygitleakson every commit. - 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. - 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. - 1Password is the operator-side vault. Anything an operator must hand-feed to Bicep or to
azCLI lives in 1Password vaultSavoy / Infra. Not in shell history, not in personal Notes, not in Slack DMs. - 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.
2. The five classes
Section titled “2. The five classes”Every secret in the system maps to exactly one class. The class determines its lifecycle.
| Class | Definition | Examples | Origin | Where it lives |
|---|---|---|---|---|
| K1 | Bootstrap credentials required to create an Azure resource. Cannot be auto-generated because the operator chooses them. | SQL admin login, SQL admin password | Operator at first deploy | 1Password → CLI param → KV (composed into K4) |
| K2 | Self-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 key | Bicep newGuid() on first deploy | KV only — never seen by humans |
| K3 | 3rd-party API tokens — issued by external providers, used by our app | Cloudflare API token, Cloudflare Workers token, Mailjet API/secret, Better Uptime URLs, GCP service-account JSON, Cookiebot, Zoho OAuth | External provider → operator → 1Password | 1Password → az keyvault secret set → KV |
| K4 | Composed secrets — derived from outputs of other Bicep resources combined with K1 | SQL connection string, Blob connection string | Bicep composition at deploy time | KV only — never emitted as Bicep output |
| K5a | Per-developer local-dev values — never deployed, never shared | Personal Zoho/Figma PAT, personal API keys, dev reCAPTCHA test keys | Each dev generates their own (interactive prompt or openssl rand) | dotnet user-secrets (CMS) or apps/web/.env.local (Next.js) — gitignored |
| K5b | Shared-local conventions — same value across the team for local-dev convenience, never deployed | Local 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 |
3. Where each class lives
Section titled “3. Where each class lives”K1 — Bootstrap creds
Section titled “K1 — Bootstrap creds”- 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 ininfra/main.bicep. Passed via CLI fromscripts/deploy/{env}.shwhich 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.
K2 — Self-generated
Section titled “K2 — Self-generated”- Source: Bicep
newGuid()at first deploy. Subsequent deploys reuse the existing KV secret (Bicepexistingreference). - Bicep:
key-vault.bicepdeclares each as aMicrosoft.KeyVault/vaults/secretsresource withvalue: newGuid(). On first creation Bicep writes the value; on subsequent deploys, the resource hasnamematching → 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.
K3 — 3rd-party tokens
Section titled “K3 — 3rd-party tokens”- 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 / Infraimmediately 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) andcloudflareWorkersToken(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.
K4 — Composed
Section titled “K4 — Composed”- Source: Bicep composes from K1 + Bicep outputs of other resources (
sqlServer.outputs.serverFqdn,storage.listKeys()). - Bicep: the secret value is built inside
key-vault.bicepusing inline references — never emitted as a Bicepoutput, otherwise it leaks into the Azure deployment log readable by anyone withReaderon the RG. - Pipeline: never sees the value.
- Rotation: automatic — rotate K1 (SQL admin pwd) → re-deploy → K4 recomposed.
K5a — Per-dev local (truly personal)
Section titled “K5a — Per-dev local (truly personal)”- Source: the dev’s own machine. Each dev generates their own. Setup scripts use
openssl randor interactiveread -pprompts — never hardcoded values. - Storage:
dotnet user-secrets(CMS) orapps/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 inapps/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:
- Used only on a developer’s localhost
- Bound to 127.0.0.1 (not exposed over network)
- Trivially recreatable (e.g., destroying the docker container is cheap)
4. Forbidden patterns
Section titled “4. Forbidden patterns”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.
- Real secret values in committed files. Including
.env.examplefiles, plan docs, README, comments, test fixtures, Storybook stories, anywhere. @secure()Bicep param sourced from a literal inmain.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.output ... stringin 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.- App Service appSetting with a literal secret value. Always
@Microsoft.KeyVault(...)reference — no exceptions. - Hardcoded fallback secret in shell scripts (e.g.
${SA_PASSWORD:-<SA_PASSWORD>}). Use${SA_PASSWORD:?Set SA_PASSWORD env var}to fail loudly instead. - 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 bydotnet user-secretsin local dev only
- Subscription IDs / tenant IDs / zone IDs / account IDs in committed files. These are not strict secrets but reveal infra topology. Replace with
<REDACTED>or read from environment. - 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 aread -s -p "Password: "prompt (they land in shell history). 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 seeNEXT_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__*)”| Setting | Class | KV secret name | Notes |
|---|---|---|---|
ConnectionStrings__umbracoDbDSN | K4 | sql-connection-string | Composed from K1 + sqlServer outputs |
Umbraco__CMS__DeliveryApi__ApiKey | K2 | umbraco-api-key | newGuid() — also referenced by Next.js |
Umbraco__CMS__Global__Smtp__Username | K3 | mailjet-api-key | From 1Password |
Umbraco__CMS__Global__Smtp__Password | K3 | mailjet-secret-key | From 1Password |
Savoy__Forms__ApiKey | K2 | forms-api-key | newGuid() — must match Next.js Bicep |
Savoy__Gate__Secret | K2 | gate-secret | newGuid() — must match Next.js + CF Worker |
Savoy__Gate__BypassSecret | K2 | gate-bypass-secret | newGuid() — must match Next.js + ADO pipeline secret variable |
Savoy__RevalidateSecret | K2 | revalidate-secret | newGuid() — must match Next.js |
Savoy__Mfa__TrustSecret | K2 | mfa-trust-secret | newGuid() — was undeclared, declared in Phase 3 |
Savoy__Mcp__ClientSecret | K2 | mcp-client-secret | newGuid() — OpenIddict |
Savoy__Cloudflare__ZoneId | K3 | cloudflare-zone-id | From 1Password |
Savoy__Cloudflare__ApiToken | K3 | cloudflare-api-token | From 1Password |
Savoy__Cloudflare__Enabled | (non-secret) | n/a | Literal 'true' in Bicep |
Savoy__BlobStorage__Account | (non-secret) | n/a | Storage account name — public identifier |
Savoy__KeyVault__Uri | (non-secret) | n/a | KV URI — public endpoint |
Savoy__AzureMetrics__SubscriptionId | (non-secret topology) | n/a | Subscription GUID — wired via Bicep, not a literal |
Savoy__AzureMetrics__ResourceGroup | (non-secret) | n/a | RG name |
Savoy__AzureMetrics__Resources__* | (non-secret) | n/a | App / SQL / storage resource names |
Savoy__Alerting__BetterUptime__CmsUpUrl | K3 | alerting-betteruptime-cms-up-url | URL contains a token segment — treated as secret |
Savoy__Alerting__BetterUptime__HeartbeatUrls__* (6) | K3 | alerting-betteruptime-{key} | Idem |
Next.js App Service settings
Section titled “Next.js App Service settings”| Setting | Class | KV secret name |
|---|---|---|
UMBRACO_API_KEY | K2 | umbraco-api-key (same as CMS) |
UMBRACO_FORMS_API_KEY | K2 | forms-api-key (same as CMS) |
UMBRACO_PREVIEW_SECRET | K2 | preview-secret |
REVALIDATE_SECRET | K2 | revalidate-secret (same as CMS) |
GATE_SECRET | K2 | gate-secret (same as CMS + CF Worker) |
GATE_BYPASS_SECRET | K2 | gate-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.)
Pipeline (Azure DevOps)
Section titled “Pipeline (Azure DevOps)”| ADO secret variable | Class | Purpose |
|---|---|---|
gateBypassSecret | K2 | Mirrors 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. |
cloudflareWorkersToken | K3 | Used 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. |
1Password vault Savoy / Infra
Section titled “1Password vault Savoy / Infra”| Item | Type | Class | Contents |
|---|---|---|---|
savoy-sql-admin-dev | Database | K1 | login + password (DEV) |
savoy-sql-admin-stage | Database | K1 | login + password (STAGE — when provisioned) |
savoy-sql-admin-prod | Database | K1 | login + password (PROD — when provisioned) |
savoy-mailjet | API Credential | K3 | API key + secret key |
savoy-cloudflare-api | API Credential | K3 | account ID, zone IDs (one per env), API token |
savoy-cloudflare-workers | API Credential | K3 | Workers deploy token (also synced to ADO) |
savoy-better-uptime | Note | K3 | 7 heartbeat URLs per env |
savoy-gcp-analytics | Document | K3 | service-account JSON |
savoy-zoho-mcp | API Credential | K3 | client ID, client secret, refresh token, portal/project IDs |
savoy-figma-pat | API Credential | K3 | personal access token (per dev — operator owns the team token) |
savoy-azure-subscription | Note | (reference) | subscription ID, tenant ID, RG conventions |
savoy-emergency-recovery | Note | (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/.
6.1 Prerequisites (one-time per operator)
Section titled “6.1 Prerequisites (one-time per operator)”- Install Azure CLI:
brew install azure-cli - Install 1Password CLI:
brew install --cask 1password-cli - Sign into 1Password CLI:
op signin - Sign into Azure:
az login - Verify access to the
Savoy / Infra1Password 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)”# 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 stageNo interactive prompts. No values typed at a shell. No secrets in shell history.
6.3 What bootstrap-env.sh does
Section titled “6.3 What bootstrap-env.sh does”- Validates the operator is signed into 1Password CLI and Azure CLI.
- Reads K3 secrets from 1Password using
op read "op://Savoy Infra/{item}/{field}". - Creates the Key Vault (only the vault itself, no secrets) using a minimal Bicep deploy.
- Writes K3 secrets to KV via
az keyvault secret set(one per K3 secret in § 5). - Reports success — KV is ready for the full Bicep deploy.
6.4 What deploy-infra.sh does
Section titled “6.4 What deploy-infra.sh does”- Reads K1 (SQL admin login + password) from 1Password.
- Runs
az deployment group create -g rg-savoy-{env} --template-file infra/main.bicep --parameters environment={env} --parameters sqlAdminLogin=$LOGIN --parameters sqlAdminPassword=$PWD. - 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.
- Smoke test: hits
https://app-umbraco-savoy-{env}.azurewebsites.net/umbraco/api/savoy-dashboard/heartbeatand checks 200 OK.
6.5 What verify-env.sh does
Section titled “6.5 What verify-env.sh does”- Asserts every App Service KV reference resolves (no
Reference resolution error). - Hits
/umbraco/api/savoy-dashboard/heartbeatand asserts every check isHealthy. - 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). - Verifies CF cache rule is wired (calls
setup-rate-limits.sh --check). - Reports a green/red checklist.
7. Rotation procedures
Section titled “7. Rotation procedures”See docs/dev-backend-guides/13_SECRETS_ROTATION.md for cadences and per-class rotation procedures.
8. Recovery / break-glass
Section titled “8. Recovery / break-glass”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.
9. Adding a new secret
Section titled “9. Adding a new secret”When the application needs a new secret:
- Determine the class (K1–K5) using § 2.
- Add the consumer in code (
IConfiguration.GetValue<string>("Savoy:NewKey")orprocess.env.NEW_KEY). - Update this doc — § 5 inventory.
- Per class:
- K2: add
Microsoft.KeyVault/vaults/secretsresource inkey-vault.bicepwithvalue: newGuid(). Add@Microsoft.KeyVault(...)reference in the App Service Bicep. - K3: add to 1Password (
Savoy / Infravault). Addaz keyvault secret settobootstrap-env.sh. Add@Microsoft.KeyVault(...)reference in App Service Bicep. Do not declare a Bicep secret resource for K3. - K4: compose inside
key-vault.bicepfrom existing K1 + outputs. Add@Microsoft.KeyVault(...)reference. - K1: add to 1Password. Add
@secure() paraminmain.bicep. Addop readline inscripts/deploy/{env}.sh. - K5: add to
apps/cms/user-secrets-setup.sh(withopenssl randgeneration) and/orapps/web/.env.local.example(with__REPLACE_WITH_DEV_VALUE__placeholder).
- K2: add
- For DEV first, then propagate to STAGE/PROD using the procedures in § 6.
- Update rotation cadence in
13_SECRETS_ROTATION.mdif the new secret has its own rotation policy.
10. Cross-references
Section titled “10. Cross-references”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.bicepandinfra/modules/key-vault.bicep— Reference implementation of K1–K4.scripts/deploy/{bootstrap-env,deploy-infra,verify-env}.sh— Operational scripts implementing § 6.