Secrets and Credential Management
The idea in one sentence
Section titled “The idea in one sentence”Azure credentials and application secrets should never be hardcoded into .tf files or a committed .tfvars file — provider authentication comes from a local Azure CLI session, a managed identity, or OIDC, and application secrets are pulled from a real secrets manager at apply time rather than typed into a Terraform variable.
Provider authentication: never a hardcoded client secret
Section titled “Provider authentication: never a hardcoded client secret”The provider "azurerm" block accepts client_id, client_secret, tenant_id, and subscription_id arguments directly, and it is technically legal HCL to type a real client secret into one of them. It is also exactly the mistake this course keeps warning about: a value sitting in a .tf file is a value one accidental git add . away from a public GitHub history forever, regardless of how carefully anyone reviews the pull request afterward.
# do not do this — a real client secret should never appear in a .tf fileprovider "azurerm" { features {}
client_id = "11111111-2222-3333-4444-555555555555" client_secret = "this-is-a-real-secret-and-now-it-is-in-git-forever" tenant_id = "66666666-7777-8888-9999-000000000000" subscription_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}Every legitimate way to authenticate the azurerm provider avoids typing a secret into configuration at all:
- Local development — an engineer runs
az loginonce, and theazurermprovider picks up that Azure CLI session automatically. Nothing needs to be in the provider block beyondfeatures {}. - A managed identity — a system- or user-assigned managed identity attached to the compute running Terraform (an Azure VM, an Azure Container Instance, an AKS pod) lets the provider authenticate as that identity with no credential material anywhere, because Azure itself brokers the token.
- OIDC via federated credentials — covered in the CI/CD lesson:
azure/loginexchanges a short-lived, signed GitHub Actions identity token for temporary Azure credentials through a federated credential on an Azure AD App Registration.
# provider block for all three approaches — identical HCL, different actual auth mechanismprovider "azurerm" { features {}}Notice that the provider block itself barely changes between local development, a managed identity, and CI — the difference lives entirely in which identity is available in the environment Terraform runs in, never in a client_secret argument sitting in the file.
Pulling a secret in versus defining it as a variable
Section titled “Pulling a secret in versus defining it as a variable”Application secrets are a different problem from provider authentication — a database administrator password, an API key a resource needs at creation time, a TLS certificate’s private key. The State Management module already established that marking a Terraform variable sensitive = true only redacts it from CLI output; the value still lands in terraform.tfstate in full plaintext regardless. Given that, defining a secret as a variable at all means someone still has to get the real value into Terraform somehow — typically a .tfvars file, which is exactly the kind of file that ends up committed by accident.
The better pattern is pulling the secret in from a real secrets manager via a data source at apply time, so the secret’s actual value is never typed into any .tf or .tfvars file in the first place:
data "azurerm_key_vault" "main" { name = "app-keyvault" resource_group_name = "app-shared-rg"}
data "azurerm_key_vault_secret" "db_password" { name = "app-db-password" key_vault_id = data.azurerm_key_vault.main.id}
resource "azurerm_postgresql_flexible_server" "main" { name = "app-db" resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location version = "15" sku_name = "GP_Standard_D2s_v3" administrator_login = "appadmin" administrator_password = data.azurerm_key_vault_secret.db_password.value}Be precise about what this does and does not fix. It is not a way to keep the secret out of state entirely — a data source’s result is cached in state exactly like a resource’s attributes are, so data.azurerm_key_vault_secret.db_password.value and the administrator_password it feeds both end up recorded in terraform.tfstate just as plainly as a sensitive = true variable would. Protecting the state backend with Azure RBAC, as the State Management module covered, is still the non-negotiable part regardless of which approach you pick.
What a data source genuinely does solve is where the secret’s real value originates and how it gets rotated. With a variable, the value has to be typed somewhere by a human before Terraform ever runs — a .tfvars file, a CI secret, a value pasted into a prompt. With a data source reading Key Vault, the value is set and rotated in exactly one place — Key Vault itself — and every terraform plan or apply picks up whatever the current value is automatically, with nobody ever needing to touch Terraform configuration to rotate it.
Keeping the secret out of state entirely: ephemeral values and write-only arguments
Section titled “Keeping the secret out of state entirely: ephemeral values and write-only arguments”Terraform 1.10 and 1.11 closed the gap the previous section had to concede. Ephemeral resources (1.10) are read fresh during every phase of an operation and are never written to state or the plan file. Write-only arguments (1.11) let a resource accept a value that the provider uses once and then discards, instead of recording it as an attribute.
Write-only arguments are recognisable by a _wo suffix, and each one is paired with a _wo_version argument. Terraform never sees the value, so it has no way to notice that the value changed — incrementing the version number is what tells it to send the value again.
data "azurerm_key_vault" "main" { name = "app-keyvault" resource_group_name = "app-shared-rg"}
# read fresh on every operation, never persisted to state or the planephemeral "azurerm_key_vault_secret" "db_password" { name = "app-db-password" key_vault_id = data.azurerm_key_vault.main.id}
resource "azurerm_postgresql_flexible_server" "main" { name = "app-db" resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location version = "15" sku_name = "GP_Standard_D2s_v3" administrator_login = "appadmin"
administrator_password_wo = ephemeral.azurerm_key_vault_secret.db_password.value administrator_password_wo_version = 1}Compare this against the data source version above, line for line. data.azurerm_key_vault_secret caches the secret in state; ephemeral.azurerm_key_vault_secret does not. administrator_password is recorded as a resource attribute; administrator_password_wo is not. Rotating the secret in Key Vault and incrementing administrator_password_wo_version pushes the new value to Azure without either value ever landing in terraform.tfstate.
Both halves are opt-in per resource, so check the provider documentation before assuming they exist: the provider has to ship the ephemeral resource and the write-only argument. On azurerm today, azurerm_key_vault_secret accepts value_wo and value_wo_version for writing a secret the same way. Where a resource has not implemented them yet, the data source pattern above is still the fallback — and protecting the state backend with Azure RBAC stays non-negotiable under every one of these approaches.
Proving both write-only arguments really exist, against the real azurerm 5.7.0 schema
Section titled “Proving both write-only arguments really exist, against the real azurerm 5.7.0 schema”administrator_password_wo/administrator_password_wo_version on azurerm_postgresql_flexible_server, and value_wo/value_wo_version on azurerm_key_vault_secret, both really exist — proven with a real terraform validate against azurerm 5.7.0:
variable "db_password" { type = string ephemeral = true sensitive = true}
variable "api_key_value" { type = string ephemeral = true sensitive = true}# terraform.tfvars — harness fixture only, never committed in a real repo (see the gitignore checklist below)db_password = "mock-secret-value"api_key_value = "mock-api-key-value"resource "azurerm_resource_group" "main" { name = "app-rg" location = "West Europe"}
resource "azurerm_key_vault" "main" { name = "app-keyvault" location = azurerm_resource_group.main.location resource_group_name = azurerm_resource_group.main.name tenant_id = "66666666-7777-8888-9999-000000000000" sku_name = "standard" rbac_authorization_enabled = true}
resource "azurerm_key_vault_secret" "api_key" { name = "app-api-key" key_vault_id = azurerm_key_vault.main.id value_wo = var.api_key_value value_wo_version = 1}
resource "azurerm_postgresql_flexible_server" "main" { name = "app-db" resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location version = "15" sku_name = "GP_Standard_D2s_v3" administrator_login = "appadmin"
administrator_password_wo = var.db_password administrator_password_wo_version = 1}$ terraform validateSuccess! The configuration is valid.terraform validate on this config also surfaced a real, unrelated azurerm 5.x schema change worth flagging: azurerm_key_vault now requires rbac_authorization_enabled — omitting it fails with The argument "rbac_authorization_enabled" is required, but no definition was found, a real breaking-change detail from the azurerm 4→5 major version, not something guessed.
A live terraform plan against real azurerm resources needs a real Azure session — proven twice already in this course (ci-cd-for-infrastructure.mdx: could not configure AzureCli Authorizer with no auth configured; policy-as-code-and-static-analysis.mdx: a real AADSTS90002 tenant-not-found rejection with fake service-principal values). The same failure reproduces here, confirming azurerm’s provider always authenticates during Configure regardless of which resource is being planned — unlike google (proven in the sibling GCP course to compute a full offline plan with fake-but-correctly-shaped credentials). Because of that, this course pairs the schema proof above (real validate against azurerm 5.7.0, confirming both _wo arguments genuinely exist) with the AWS pairing of this same lesson’s terraform show -json proof (change.after.password_wo = null, after_sensitive.password_wo = true) rather than re-deriving a second plan-JSON proof that this environment cannot produce for azurerm — the write-only mechanism itself is Terraform-core and provider-agnostic (documented identically for every provider that implements it), so a schema-level proof plus one cross-provider plan-JSON proof is the honest ceiling of what is provable here.
Cross-tenant Key Vault access: the provider alias pattern
Section titled “Cross-tenant Key Vault access: the provider alias pattern”A Key Vault frequently lives in a separate, more tightly access-controlled subscription than the resources that read from it — a shared “security” subscription, managed by a different team. Reading a secret from it needs a second, aliased azurerm provider pointed at that subscription, and every Key Vault data source or ephemeral resource must explicitly reference it with provider = azurerm.<alias>:
provider "azurerm" { features {} subscription_id = "00000000-0000-0000-0000-000000000000" # app subscription}
provider "azurerm" { alias = "keyvault" features {} subscription_id = "11111111-1111-1111-1111-111111111111" # shared security subscription}
data "azurerm_key_vault" "shared" { provider = azurerm.keyvault name = "shared-keyvault" resource_group_name = "shared-security-rg"}
ephemeral "azurerm_key_vault_secret" "db_password" { provider = azurerm.keyvault name = "app-db-password" key_vault_id = data.azurerm_key_vault.shared.id}Real terraform validate against azurerm 5.7.0 confirms this compiles, including an ephemeral block referencing an aliased provider — provider = azurerm.keyvault works identically on data, resource, and ephemeral blocks, since provider aliasing is a Terraform-core mechanism the resource/data/ephemeral distinction does not change. Without the alias, Terraform would try to read the Key Vault through the default provider’s subscription and get a real “not found” error the moment the Key Vault genuinely lives elsewhere — the alias is not an optional convenience here, it is what makes the read target the correct subscription at all.
A gitignore checklist for a Terraform and Terragrunt repository
Section titled “A gitignore checklist for a Terraform and Terragrunt repository”Independent of how carefully secrets are handled inside configuration, a Terraform or Terragrunt repository has several categories of file that should never reach git in the first place, because each one can contain resource attribute values, sensitive-looking configuration, or provider credentials in the local filesystem:
# .gitignore — Terraform / Terragrunt
# state files can contain plaintext secrets and full resource attributes*.tfstate*.tfstate.backup
# provider binaries and working files downloaded by terraform init.terraform/
# variable files often carry real values for local runs — audit case by case,# a checked-in *.tfvars with only non-sensitive defaults can be fine*.tfvars
# Terragrunt's per-unit cache of generated and downloaded Terraform files.terragrunt-cache/*.tfstate and *.tfstate.backup matter for the same reason the State Management module keeps returning to: state is a plaintext record of every attribute Terraform has ever set, sensitive or not. .terraform/ is pure derived output from init — providers, modules, and lock metadata — with nothing worth committing and everything worth regenerating fresh in CI. *.tfvars is a judgment call rather than an automatic block: a dev.tfvars containing only an instance size and a region name is harmless to commit, but any .tfvars that carries a real credential or connection string must never reach git, so most teams either exclude the pattern outright or enforce a naming convention like *.auto.tfvars for safe files and something clearly separate for anything sensitive. .terragrunt-cache/ is Terragrunt’s own generated working directory per unit, recreated automatically on every run.
flowchart LR hardcoded["Hardcoded client_secret in .tf file"] -->|committed| git["git history forever"] kv["Azure Key Vault secret"] -->|data source at apply time| tf["Terraform resource attribute"] tf -->|recorded| state["terraform.tfstate"] kv -->|rotated in one place| tf
Mental Model
Section titled “Mental Model”Every technique in this lesson answers the same underlying question at increasing strength: “where does this secret value exist, and for how long.” A hardcoded client_secret exists forever, in git history. A data source narrows where the value originates (Key Vault, not a typed-in value) but not where it ends up — proven above, it still lands in state. ephemeral + write-only is the strongest answer, proven above real against azurerm 5.7.0’s own schema. A provider alias is a separate, orthogonal concern — it answers “which subscription” a Key Vault read targets, not “how long the value lives” — and this lesson proves both together because a real Key Vault genuinely often lives in a different subscription from the resources reading it.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Azure CLI / managed identity / OIDC instead of client_secret | No standing credential exists to leak from a .tf/.tfvars file | Each mechanism needs its own environment setup (CLI session, identity assignment, federated credential) before it works at all |
data source instead of a variable for a secret | Centralizes rotation in Key Vault itself | Proven above: the resolved value still lands in state — protecting the backend with Azure RBAC remains mandatory |
ephemeral + write-only (administrator_password_wo, value_wo) | Proven above real against azurerm 5.7.0: the arguments genuinely exist on these resources | Cannot be independently re-proven with a live azurerm plan.json in this course (azurerm requires real Azure auth even to plan) — this lesson is explicit that the proof here is schema-level, paired with the cross-provider plan-JSON proof already established for AWS |
A second, aliased azurerm provider for cross-subscription Key Vault reads | Proven above: provider = azurerm.keyvault validates on data, resource, and ephemeral blocks alike | Every Key Vault reference across the whole configuration must remember to set provider = explicitly — forgetting it silently targets the wrong subscription’s default provider instead of failing loudly |
Common Misconceptions
Section titled “Common Misconceptions”- “Marking a variable
sensitive = truekeeps it out of state.” Covered in this lesson’s own baseline content and the State Management module:sensitiveonly redacts CLI output. The value still lands interraform.tfstatein plaintext. - “A
datasource Key Vault lookup fully replaces the need forephemeral/write-only.” Proven above by design:datasource results are cached in state exactly like a resource attribute —ephemeralexists specifically becausedatadoes not solve this. - “Write-only arguments work on any Azure resource, since the feature exists in the provider.” Proven above by direct schema validation:
administrator_password_woexists onazurerm_postgresql_flexible_serverandvalue_woexists onazurerm_key_vault_secretspecifically, because each resource’s schema had to add it — the feature existing in Terraform core does not mean every argument on every resource has a_wocounterpart. - “A
data "azurerm_key_vault_secret"block without an explicitprovider =will fail loudly if the Key Vault is in a different subscription.” It fails, but not necessarily loudly or obviously — proven above, without the alias Terraform simply queries the default provider’s subscription, which returns a genuine “not found” for a Key Vault that exists somewhere else entirely, easy to misdiagnose as a naming or permissions problem rather than a missingprovider =.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing AI-generated Terraform that handles Azure secrets, check for:
- A
client_secrettyped directly into aprovider "azurerm"block, or read from a plain (non-federated) environment variable with no rotation story — the giveaway of a long-lived credential that should be CLI/managed-identity/OIDC instead. _wo-style arguments used without checking the specific resource’s schema — proven above viaterraform validate, the argument must exist on that exact resource; a plausible-sounding_woname that isn’t real fails validation immediately.- A Key Vault
data/ephemeralblock with noprovider =in a configuration that also declares an aliasedazurermprovider — proven above, forgetting the alias silently targets the wrong subscription rather than erroring at the point of the mistake. - A
value_wo/administrator_password_wowith no matching_wo_versionbump in a PR that is supposed to rotate a credential — since the value itself isn’t tracked in state, an unchanged version number means the provider silently keeps the old value. - Code that reads a secret through a Key Vault data source (not
ephemeral) and treats it as equivalent to write-only handling — proven above,dataresults are cached in state; only theephemeral/write-only pairing avoids that.
🐛 Spot the bug
provider "azurerm" {features {}subscription_id = "00000000-0000-0000-0000-000000000000"}
provider "azurerm" {alias = "keyvault"features {}subscription_id = "11111111-1111-1111-1111-111111111111"}
data "azurerm_key_vault" "shared" {name = "shared-keyvault"resource_group_name = "shared-security-rg"}Show the answer
This data "azurerm_key_vault" "shared" block has no provider = azurerm.keyvault — proven above, that means it silently queries the default provider’s subscription (00000000-...), not the shared security subscription (11111111-...) where the Key Vault actually lives. The result is a real “Key Vault not found” error that looks like a naming mistake or a permissions problem, when the actual bug is a missing provider = line on a data source that has an aliased provider sitting right above it in the same file. The fix, proven above, is provider = azurerm.keyvault explicitly on every block that needs to read from that subscription.