TerraformXX · Supply Chain: Providers and ModulesProduction Terraform
Provider Provenance and Verification
What you'll learn
- Explain how Terraform resolves a provider from its source address
- Verify a provider binary using the registry signing model and the dependency lock file
- Configure `required_providers` and the lock file to pin provider versions and hashes
- Conduct a provider audit that catches missing hashes, deprecated providers, and unapproved mirrors
- Mirror providers through an internal registry without breaking the lock file contract
Prerequisites
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
When Terraform needs to talk to AWS, it does not contain AWS
knowledge. It calls a provider binary. The binary is
downloaded once on terraform init, cached on the runner, and
executed with the credentials configured in the environment.
The binary sees the credentials. The binary sees the state.
The binary sees every secret the configuration passes through
it.
If the binary is wrong, the binary is the attack.
What a provider is, mechanically
A provider is a binary plugin discovered by Terraform through the plugin protocol. The binary implements an RPC interface. Terraform launches it as a subprocess, passes the configuration in, and receives the planned changes back. The provider is not signed by your team. It is signed by whoever published it.
Configuration
|
v
Source address: registry.terraform.io/hashicorp/aws
|
v
Discovery: GET /v1/providers/hashicorp/aws/versions
|
v
Selection: highest version that matches the constraint
|
v
Download: GET /v1/providers/hashicorp/aws/5.61.0/download/linux/amd64
|
v
Verification: package matches a hash recorded in the lock file
|
v
Install: package unpacked into .terraform/providers/
|
v
Execution: provider binary launches as subprocess, holds credentials
The discovery protocol is documented. Every registry implementation (the public HashiCorp registry, an internal Terraform Enterprise registry, an open-source mirror like Terralist) implements the same endpoints. The lock file format is the same regardless of the registry.
The trust model
Three actors own the trust relationship:
- The registry. It serves the provider binary. The
HashiCorp registry GPG-signs the provider and publishes the
public key at
https://www.hashicorp.com/.well-known/pgp-key.txt. An internal registry should follow the same signing model. - The maintainer. The maintainer signs a release with their own key. The registry signature attests that the registry received the binary from the named maintainer.
- You. You choose which version to pin. The lock file commits your team to the exact bytes you reviewed.
When you add a required_providers block, you are making a
trust statement about the source address. When you run
terraform init, you are downloading bytes you have not read.
When you commit the lock file, you are pinning the bytes you
trust.
Configuring the source and the version
The production configuration:
terraform {
required_version = ">= 1.9.0, < 2.0.0"
required_providers {
aws = {
source = "registry.terraform.io/hashicorp/aws"
version = ">= 5.40.0, < 6.0.0"
}
vault = {
source = "registry.terraform.io/hashicorp/vault"
version = "~> 4.4"
}
}
}
The fields:
sourceis the registry address, namespace, and type. It is the unique key for the provider. Two providers with the same namespace and type but different registries are different providers.versionis the constraint.>= 5.40.0, < 6.0.0is an explicit range: any 5.x release from 5.40.0 upward is allowed; the next major is not.~> 4.4is the pessimistic form of the same idea — only the rightmost component named may increment, so it allows 4.4 and every later 4.x release but not 5.0.~> 4.4.0would allow 4.4.x patches only.required_versionis the Terraform core constraint. Pin core too; provider behaviour changes between core versions.
The constraint is permissive; the lock file is restrictive. The constraint says “any of these versions are acceptable”. The lock file says “exactly this one until we change it”.
The dependency lock file
The dependency lock file (.terraform.lock.hcl) records the
provider version, the platforms, and the hashes that Terraform
verified when it downloaded the binary.
# .terraform.lock.hcl
# Manual edits may be lost - proceed with caution!
provider "registry.terraform.io/hashicorp/aws" {
version = "5.61.0"
constraints = ">= 5.40.0, < 6.0.0"
hashes = [
"h1:abcd1234...",
"zh:1a2b3c4d...",
]
}
The zh: hash is the legacy “zip hash” from the provider
registry protocol: a SHA-256 of the official .zip package as
the origin registry indexed it. The h1: hash is the newer
scheme and the one Terraform prefers: a SHA-256 computed from
the contents of the unpacked provider package rather than of
the archive around them, so it holds whether the package
arrives as the official zip, an unpacked directory, or a
repackaged zip. A package is accepted if it matches any hash
recorded for it.
When the lock file is committed, every team member and every CI runner uses the same provider bytes. When the lock file is missing, every run resolves the latest matching version, and two engineers planning on Monday and Tuesday may produce different plans.
The file is generated by terraform init and updated by
terraform init -upgrade. The file is read-only after commit
unless an engineer runs an upgrade deliberately.
Inspecting and updating the lock
Three commands the production reader runs:
# Severity: READ-ONLY
terraform providers
Providers required by configuration:
.
├── provider[registry.terraform.io/hashicorp/aws] 5.61.0
├── provider[registry.terraform.io/hashicorp/vault] 4.4.1
└── provider[registry.terraform.io/hashicorp/random] 3.6.0
terraform providers lists the providers the configuration
requires, the resolved versions, and the source addresses. It
is the inventory.
# Severity: READ-ONLY
grep -A 3 '^provider' .terraform.lock.hcl
provider "registry.terraform.io/hashicorp/aws" {
version = "5.61.0"
constraints = ">= 5.40.0, < 6.0.0"
# Severity: CONFIGURATION
terraform providers lock \
-platform=linux_amd64 \
-platform=darwin_arm64
terraform providers lock regenerates the lock for the listed
platforms. Run it when adding a new platform to the team’s
matrix (a new macOS arm64 engineer, a new Windows runner).
Run it when the upstream provider publishes a new build that
your existing hashes do not cover.
Binary verification under the hood
When terraform init runs, Terraform:
- Resolves the source address to a registry endpoint.
- Downloads the provider zip for the running platform and, for a registry install, checks it against the checksums the registry publishes and signs.
- Compares the
zh:zip hash — the SHA-256 of the downloaded.zip— against the hashes recorded in the lock file. - Unpacks the package and computes the
h1:content hash over the extracted files, comparing it against the same recorded list. The package is accepted if it matches any recorded hash. - Records the hashes in the lock file if the provider has no entry yet. This is trust on first use: the first install establishes the record and every later install is checked against it.
If the package does not match any recorded hash, terraform init
stops:
Error: Failed to install provider
Error while installing hashicorp/aws v5.61.0: the current package
for registry.terraform.io/hashicorp/aws 5.61.0 doesn't match any
of the checksums previously recorded in the dependency lock file.
The init halts. No provider is loaded. This is the lock file doing its job.
The audit
A provider audit is a quarterly check. The check produces a list of providers, their sources, their versions, their licences, and their last-update dates. The check produces a diff against the previous quarter. The diff is the change.
# Severity: READ-ONLY
terraform providers | sort > /tmp/providers.txt
# Compare to last quarter's snapshot.
diff /var/audit/providers.last.txt /tmp/providers.txt
> provider[registry.terraform.io/cloudflare/cloudflare] 4.42.0
< provider[registry.terraform.io/hashicorp/cloudflare] 4.42.0
A namespace change like this one is significant. hashicorp/cloudflare
became cloudflare/cloudflare when HashiCorp transferred the
provider to the vendor. The lock file needs an update, the
configuration needs an update, and the audit records the
change.
The audit also checks:
- Hash coverage. Every provider has an
h1:hash recorded for every platform in the team’s matrix. A platform missing hashes is a “works on my laptop” bug waiting to surface. - Deprecated providers.
terraform validatewarns about deprecated resources, but deprecated providers only show up if the team runsterraform providersand compares. - Unapproved mirrors. A
source =attribute pointing at an internal registry that is not on the approved list is a configuration that should not exist. - Unknown namespaces. The provider namespace should match
the maintainer.
registry.terraform.io/random-guy/awsis not the same provider asregistry.terraform.io/hashicorp/aws, even if the type matches.
Mirror registries
For compliance or air-gapped environments, mirror the public registry. The mirror is a static HTTP server serving the same JSON the public registry serves, plus the same provider zips.
# ~/.terraformrc
provider_installation {
network_mirror {
url = "https://terraform-mirror.internal/providers/"
}
direct {
exclude = ["registry.terraform.io/*/*"]
}
}
The network_mirror block tells Terraform to fetch providers
from the mirror first. The direct block excludes the public
registry for everything; without it, Terraform falls back to
the public registry for misses. The mirror must serve the same
discovery protocol and the same download URLs.
The lock file does not change. The hashes are still the SHA-256 of the original zips. The mirror is a copy; the trust is the same.
Production failure modes
-
Lock file missing.
terraform initresolves the latest matching version and records its hashes for the first time. Nothing pins the result, so two engineers initialising on different days plan different binaries. The fix is to commit the lock file and require it in CI. -
Lock file platform mismatch. The lock file has
linux_amd64hashes only. A newdarwin_arm64engineer runsinit. No recorded hash covers the arm64 package, soinitfails with the checksum error above. The fix isterraform providers lock -platform=darwin_arm64, then commit the updated lock file. -
Source not pinned.
required_providersblock has nosource =. The default is the public registry. An air-gapped runner cannot resolve it. The fix is to state the source explicitly, including for providers in the default namespace. -
Namespace drift after a transfer. HashiCorp transferred several providers to vendors in 2023-2024. The lock file has the old namespace.
terraform initwarns. The fix is to update thesource =to the new namespace and runterraform init -upgrade. -
Mirror serves stale zips. The internal mirror caches providers but does not refresh them. A security update to the public provider is invisible. The fix is a nightly sync from the public registry, with an alert on sync failure.
-
Deprecated provider still in the lock file. The team upgrades Terraform core. A provider the configuration depended on is no longer compatible. The lock file has the old version.
terraform initerrors. The fix is to read the deprecation notice, update the constraint, and runterraform init -upgrade.
Security implications
- A provider binary executes with the environment’s credentials. A compromised binary is a credential disclosure. The lock file’s hashes are the verification boundary.
- The source address is the trust anchor. Anything that changes the source address without a corresponding config update is a supply-chain attack.
- Mirror registries expand the trust surface. The mirror must be at least as trusted as the source it mirrors.
Performance implications
terraform initdownloads the provider unless a plugin cache already holds it. The cache is off by default; it is turned on withplugin_cache_dirin the CLI configuration file — for exampleplugin_cache_dir = "$HOME/.terraform.d/plugin-cache"— or with theTF_PLUGIN_CACHE_DIRenvironment variable, and the directory must already exist. On a fresh CI runner with no shared cache, every job pays the download cost.- The lock file check is a SHA-256 computation. The cost is negligible compared to the download.
terraform providers lockre-downloads the provider for every platform. The cost is proportional to the matrix size.
What comes next
The next lesson covers the same discipline for modules: how an internal registry differs from the public registry, how to version a module, and how CODEOWNERS protects the module repository.
Verification
-
terraform providersoutput is committed to a quarterly audit log. -
.terraform.lock.hclis committed and contains anh1:hash for every platform in the runner matrix. - Every
required_providersblock has an explicitsource =attribute. - The mirror registry sync job has run successfully in the last 24 hours.
- No provider in the lock file has been deprecated by its maintainer.
Knowledge check · 7 questions
Q1. What does the dependency lock file record?
Q2. A new engineer joins on macOS arm64. The CI runs on linux amd64. What do you do?
Q3. A `required_providers` block without a `source =` attribute defaults to the public HashiCorp registry.
Q4. What does `terraform providers` show?
Q5. Which of the following belong in a quarterly provider audit? (Select all that apply.)
Q6. A team mirrors providers through an internal registry. What changes in the lock file?
Q7. A team adds a Linux CI runner. `terraform init` fails for one provider with "the current package for registry.terraform.io/hashicorp/aws 5.61.0 doesn't match any of the checksums previously recorded in the dependency lock file". What is the first action?
Passing score: 75%. Answers are checked in this browser.