Skip to content

Secrets and Credential Management

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 file
provider "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 login once, and the azurerm provider picks up that Azure CLI session automatically. Nothing needs to be in the provider block beyond features {}.
  • 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/login exchanges 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 mechanism
provider "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 plan
ephemeral "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:

variables.tf
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"
main.tf
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 validate
Success! 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:

Terminal window
# .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
Azure Key Vault as the real source of truth for a credential, read into Terraform via a data source at apply time, versus a hardcoded credential sitting in a committed file

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.

ChoiceBenefitCost
Azure CLI / managed identity / OIDC instead of client_secretNo standing credential exists to leak from a .tf/.tfvars fileEach 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 secretCentralizes rotation in Key Vault itselfProven 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 resourcesCannot 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 readsProven above: provider = azurerm.keyvault validates on data, resource, and ephemeral blocks alikeEvery 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
  • “Marking a variable sensitive = true keeps it out of state.” Covered in this lesson’s own baseline content and the State Management module: sensitive only redacts CLI output. The value still lands in terraform.tfstate in plaintext.
  • “A data source Key Vault lookup fully replaces the need for ephemeral/write-only.” Proven above by design: data source results are cached in state exactly like a resource attribute — ephemeral exists specifically because data does 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_wo exists on azurerm_postgresql_flexible_server and value_wo exists on azurerm_key_vault_secret specifically, 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 _wo counterpart.
  • “A data "azurerm_key_vault_secret" block without an explicit provider = 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 missing provider =.

When reviewing AI-generated Terraform that handles Azure secrets, check for:

  • A client_secret typed directly into a provider "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 via terraform validate, the argument must exist on that exact resource; a plausible-sounding _wo name that isn’t real fails validation immediately.
  • A Key Vault data/ephemeral block with no provider = in a configuration that also declares an aliased azurerm provider — proven above, forgetting the alias silently targets the wrong subscription rather than erroring at the point of the mistake.
  • A value_wo/administrator_password_wo with no matching _wo_version bump 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, data results are cached in state; only the ephemeral/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.

What is current best practice for authenticating the azurerm provider in a CI pipeline running on Azure
Why is pulling a secret into Terraform via a data source from Azure Key Vault generally better than defining it as a Terraform variable
Which of these should always be excluded from a Terraform or Terragrunt repository gitignore
A provider "azurerm" block contains only features {} with no client_id or client_secret arguments at all. What does this imply
What does pairing a write-only argument like administrator_password_wo with administrator_password_wo_version actually buy you
A Key Vault lives in a separate "shared-security" subscription from the resources that read from it. What does the read need, proven above against a real validate?