Every terraform init downloads code that will later run with your most privileged cloud credentials, and most teams have never decided which of that code they trust. Providers are executables that Terraform launches and hands the pipeline's cloud credentials to; modules are configuration written by someone else that can create any resource, run local commands and read any value passed into it. This guide covers the supply chain of those two dependency types in Terraform and OpenTofu: what the dependency lock file does and does not protect, how to pin modules that the lock file ignores, how signing and checksums work, and how to run provider mirrors and a private registry so that only reviewed versions reach production. It is for platform engineers, cloud security teams and anyone who owns an infrastructure as code pipeline. The credentials side (state files, provider credentials, OIDC) is covered in the Terraform and IaC Pipeline Credential Security Guide; this page does not repeat it.
Key takeaways
- The lock file covers providers only. HashiCorp documents that the dependency lock file tracks only provider dependencies; modules are selected fresh from their version constraint on every init, so they need exact pins of their own.
- Checksums are trust on first use. Terraform verifies each installed provider against hashes recorded the first time it was locked, so the security of every later run depends on reviewing the change that first added or upgraded that entry.
- Git tags move, commits do not. A module sourced from Git without a ref follows the default branch, and a tag can be re-pointed; pin third-party Git modules to a full commit SHA.
- Mirrors and a private registry turn trust into a decision. A provider_installation block with a network or filesystem mirror, plus a private module registry, means only versions someone approved can be installed.
- Some settings silently switch verification off. dev_overrides disables version and checksum checks, and the plugin cache option that may break the lock file weakens it; neither belongs on a CI runner.
What terraform init actually pulls in
A Terraform root module has two kinds of external dependency, and they reach your environment by different paths with different protections. Treating them as one "dependency" problem is the root of most gaps.
| Dependency | Typical sources | What it can do once installed | Built-in verification | Primary control |
|---|---|---|---|---|
| Provider plugin | Public Terraform or OpenTofu registry, network mirror, filesystem mirror, private registry | Runs as a local executable with the credentials passed to its provider block or found in the environment; calls cloud and SaaS APIs on your behalf | Registry signature checked at install; package hashes checked against .terraform.lock.hcl | Committed lock file with hashes for every platform, mirror or allowlist, reviewed upgrades |
| Registry module | Public registry, private registry | Declares any resource the provider credentials allow, including IAM roles, network rules and data source reads; can include provisioners | None beyond the version constraint | Exact version pin, review of each new version, private registry copy |
| Git or VCS module | GitHub, Bitbucket, generic Git over HTTPS or SSH | Same as a registry module | None; follows whatever the ref resolves to | ref pinned to a full commit SHA, internal fork for third-party code |
| Object storage or HTTP module | S3, GCS, HTTP archive URLs | Same as a registry module | None at the Terraform level | Bucket write access limited to the publishing pipeline, object versioning, prefer a registry |
| Nested (transitive) modules | Whatever the module author chose | Same as a registry module, but invisible in your root configuration | None | Inventory the full module tree, forbid floating sources in internal modules |
Two module features deserve particular attention in reviews because they reach beyond cloud APIs: local-exec provisioners and the external data source both run programs on the machine executing Terraform, and the external data source does so during plan. A malicious or careless module change can therefore act on a CI runner before anyone approves an apply. This is why ATT&CK places the scenario under T1195.001 Compromise Software Dependencies and Development Tools, and why plan jobs need the same dependency controls as apply jobs.
Provider integrity: how the lock file and signing work
When terraform init (or tofu init) installs a provider, two checks happen. First, for registry installs, the release is signed: HashiCorp requires every provider release published to its registry to carry a SHA256SUMS file listing a SHA-256 checksum for each zip package, and a detached GPG signature of that file made with a key the publisher registered. Terraform verifies this during init. Second, Terraform records the selected version and a set of hashes in .terraform.lock.hcl, and on every later init it refuses any package that matches none of the recorded checksums.
| Lock file element | What it means | Security implication |
|---|---|---|
version | The exact provider version selected from the constraints | init re-selects this version even when a newer one exists, until someone runs init with -upgrade |
constraints | The constraints that were in force | Informational only; Terraform does not use it to decide what to install |
h1: hashes | Preferred scheme: a SHA-256 hash of the package contents, the same whether zipped or unpacked | Works for mirrors and caches; the hash you want present for every platform you run on |
zh: hashes | Legacy scheme: SHA-256 of each official zip package indexed in the origin registry | Only meaningful for the registry's own zip files, not for unpacked mirror layouts |
HashiCorp describes this model as trust on first use. The lock file proves that today's package is the same as the one recorded earlier; it says nothing about whether the recorded one was good. The control that matters is therefore the review of the pull request that changes the lock file:
- Commit the lock file: HashiCorp recommends keeping it in version control so dependency changes go through code review like any other change. A pipeline that runs init without a committed lock file trusts whatever the registry serves at that moment.
- Lock for every platform: a provider installed from a mirror gets checksums only for the platform where init ran, and the configuration then fails on other platforms. Teams react by deleting the lock file. Use
terraform providers lock -platform=linux_amd64 -platform=darwin_arm64(one flag per platform you use) so the file carries hashes for developer laptops and runners alike. - Treat -upgrade as a change request: init with -upgrade ignores existing selections and picks the newest version allowed by the constraints. Run it in a dedicated branch, read the provider changelog, and let the lock file diff be the thing reviewers approve.
- Enforce signatures where the tool allows it: OpenTofu documents an
OPENTOFU_ENFORCE_GPG_VALIDATIONenvironment variable that, when set to true, restricts installation to providers signed with a cryptographic signature. Init output shows the signing key ID, which is worth capturing in pipeline logs.
Lock files written by Terraform and OpenTofu share the same name and hash schemes, but the provider addresses differ (registry.terraform.io versus registry.opentofu.org), so a team moving between the two should regenerate and review the file rather than assume it carries over.
Pinning modules the lock file ignores
Because the lock file does not cover modules, Terraform selects the newest module version that satisfies the constraint on every fresh init. A constraint such as ~> 5.0 on a public module means a new minor release published by its maintainer can reach your plan without any change in your repository. HashiCorp's own guidance for modules is to require specific versions so updates happen only when you choose.
| Dependency and context | Recommended constraint | Reason |
|---|---|---|
| Third-party registry module in a root module | Exact version, for example version = "5.8.1" | Nothing else records which module version ran |
| Internal registry module with disciplined semantic versioning | Exact version in production roots; a range is acceptable in sandboxes | HashiCorp allows ranges where your organisation consistently uses semantic versioning |
| Third-party Git module | ?ref= a full commit SHA | No ref follows the default branch; tags can be moved to new commits |
| Internal Git module | Release tag, protected so tags cannot be rewritten, or a commit SHA | Readable versions, while protection makes the tag immutable in practice |
| Providers in a root module | ~> with a lower and upper bound, plus the lock file | HashiCorp recommends root modules bound both ends; the lock file fixes the exact build |
| Providers in a reusable module | Minimum version only | HashiCorp recommends reusable modules set a floor so callers can resolve one version |
Two practical points make pinning stick. Variables used in a module source or version must be declared with const = true, because modules install at init before plan values exist; avoid building sources dynamically at all, because a reviewer reading the root module should see exactly what is fetched. And pinning a root module does nothing for the modules it calls: open each third-party module and check its own sources, since a pinned module that calls a child module on a floating Git branch reintroduces the risk one level down. Automated update tools can raise pull requests for new module and provider versions, which keeps exact pins from going stale; see the Dependency Update Automation Guide.
Mirrors, private registries and the provider installation policy
Pinning decides which version you want; the installation path decides where it can come from. The CLI configuration file's provider_installation block replaces Terraform's default behaviour, which is to look in local mirror directories and then download directly from the origin registry.
- network_mirror: an HTTPS URL (with a trailing slash) serving providers under the network mirror protocol. HashiCorp's documentation warns not to configure network mirror URLs you do not trust, because the mirror decides what you receive the first time a hash is recorded.
- filesystem_mirror: a local or mounted directory of provider packages, packed or unpacked. Suited to air-gapped runners and golden runner images.
- direct: the origin registry. Use
includeandexcludepatterns to decide which providers may still be fetched directly; where both are set, exclusions take priority. Note that the pattern*/*means registry.terraform.io only, not every host. - Building the mirror:
terraform providers mirrordownloads the providers a configuration needs (using the lock file versions by default) for each-platformyou name, and writes the JSON index files needed to serve the directory as a network mirror. Run it in a controlled pipeline from reviewed configurations, so the mirror only ever contains approved versions.
For modules, a private registry gives the same chokepoint. In HCP Terraform and Terraform Enterprise the private registry is visible only to members of the organisation, can synchronise selected public providers and modules so teams use a curated set, and supports policies (Sentinel is the documented example) that require modules to come from the private registry. Publishing rights sit with the owners team and teams granted the Manage private registry permission, which makes that permission a supply chain control in its own right: review who holds it as part of your Terraform Cloud and HCP Security Guide baseline. Tag-based publishing creates a version for each semantic version tag pushed to the connected repository; deleting the tag later does not remove the published version, so a bad release must be deleted in the registry itself.
Choosing an installation model
| Model | Fits | Trade-offs |
|---|---|---|
| Direct from public registry with committed lock file | Small teams, a handful of providers, no regulatory supply chain requirement | Lowest effort; trust rests on registry signing and lock file review; no control over what a fresh init can fetch for new providers |
| Direct with include allowlist | Mid-sized teams that want a short list of permitted provider namespaces | Blocks unapproved providers without running infrastructure; versions still come straight from the registry |
| Internal network mirror plus private module registry | Large organisations, many teams, audit requirements for software provenance | Mirror and registry become critical infrastructure with their own access control; strongest control over what can be installed |
| Filesystem mirror baked into runner images | Air-gapped or highly regulated environments | Fully deterministic; every provider upgrade requires an image rebuild |
Small organisations get most of the value from three things: a committed multi-platform lock file, exact module pins and a short provider allowlist. Mirrors and a private registry pay off once several teams share modules and you need to prove which code built which environment.
Where identity fits
The supply chain has its own non-human credentials, separate from the cloud credentials the pipeline uses. Private registries and private Git module sources need tokens: Terraform reads registry tokens from credentials blocks in the CLI configuration or from TF_TOKEN_ environment variables named after the host (supported from Terraform 1.2.0), and HashiCorp notes that organisation tokens cannot be used for CLI actions, so pipelines use team tokens. Give plan and apply jobs a read-only registry or deploy key token, keep publishing rights in a separate release pipeline identity, and rotate both. A token that can publish a module version to your private registry is equivalent to write access on every environment that consumes that module. For token handling patterns in pipelines, see the CI/CD Pipeline Identity Security Guide.
Rolling it out
- Inventory: run init across every root module, collect each
.terraform.lock.hcland the resolved module tree (the.terraform/modules/modules.jsonfile lists sources and versions), and list every provider namespace and module source in use. - Fix the lock files: commit a lock file for every root module, regenerate it with
terraform providers lockfor all platforms you run, and add a CI check that fails if init would modify it (run init with-lockfile=readonly). - Pin modules: change every third-party registry module to an exact version and every third-party Git source to a commit SHA. Do internal modules next, starting with those that create IAM, network and logging resources.
- Restrict the installation path: ship a CLI configuration to runners with a
provider_installationblock, first with an include allowlist ondirect, later with a network mirror. Test on one pipeline, compare the lock file before and after, then widen. - Stand up the private registry: publish internal modules there, synchronise the approved public ones, and add a policy that warns (advisory mode) on modules from elsewhere before enforcing it.
- Automate upgrades: let an update bot raise pull requests, require a named reviewer for lock file and module version changes, and record the provider changelog link in the pull request.
Rollback is simple because every step is configuration: revert the lock file or pin to restore the previous version, or remove the provider_installation block from the runner image to fall back to direct installs. Keep the previous mirror contents rather than deleting them when you add new versions.
Detection and measurement
Most supply chain signals in Terraform appear as changes in files you already control, which makes detection cheap.
- Lock file diffs: alert on any pull request that adds a provider or changes a version or hash set, and on lock file changes merged without review.
- Init output: keep init logs from CI; they show where each provider was installed from and, for signed providers, the key ID. A provider suddenly coming from a different source or key deserves investigation.
- Configuration drift in runners: check runner images and CLI configuration for
dev_overrides,plugin_cache_may_break_dependency_lock_fileor theTF_PLUGIN_CACHE_MAY_BREAK_DEPENDENCY_LOCK_FILEvariable. - Registry activity: review publish and delete events in the private registry and changes to who holds publishing permission.
- Static checks: IaC scanners can flag unpinned module sources and missing version constraints before merge; see the Checkov and IaC Scanners Guide.
| Metric | Target |
|---|---|
| Root modules with a committed lock file containing h1 hashes for every runner platform | 100% |
| Third-party module references pinned to an exact version or commit SHA | 100% |
| Provider installs in CI served from the mirror or allowlist (not unrestricted direct) | Rising to 100% in regulated environments |
| Lock file and module version changes merged with a second reviewer | 100% |
| Median age of provider versions behind latest approved release | Within your patch window |
| Identities holding private registry publishing rights | Small, named, reviewed quarterly |
Framework mapping
| Framework | Reference | How this guide applies it |
|---|---|---|
| MITRE ATT&CK | T1195.001 Compromise Software Dependencies and Development Tools | Malicious or tampered providers and modules pulled at init |
| NIST SP 800-53 Rev. 5 | SR-3 Supply Chain Controls and Processes; SR-4 Provenance; SR-11 Component Authenticity | Approved sources, recorded versions and hashes, registry signatures |
| NIST SP 800-53 Rev. 5 | CM-14 Signed Components; SI-7 Software, Firmware, and Information Integrity | Signature validation and checksum enforcement at install |
| NIST SP 800-53 Rev. 5 | CM-7(5) Authorized Software: Allow-by-exception | provider_installation allowlists and private registry policy |
| NIST CSF 2.0 | GV.SC-07; ID.AM-02; PR.PS-02 | Assessing supplier risk, inventorying software in use, keeping it maintained |
For how build and release provenance standards apply more widely, see the SLSA and Build Provenance Guide.
Common failure patterns
- Deleting the lock file to fix a platform error: a lock created on a Mac fails on a Linux runner, someone removes it, and every later run trusts whatever the registry serves. Generate hashes for all platforms instead.
- Floating module constraints:
~>ranges or no version at all on public modules, so a maintainer's new release, or a compromised maintainer account, changes production on the next init. - Git modules on a branch or tag: third-party modules sourced from the default branch or a tag that can be re-pointed. Commit SHAs remove the ambiguity.
- Running fetched code without checking it: pipelines that download a helper script or binary for Terraform jobs and run it directly. In the Codecov Breach, a modified uploader script sent CI environment variables to an attacker for about two months until a customer compared it with the published checksum.
- Verification switched off for convenience: dev_overrides left in a shared CLI configuration, or the plugin cache told to ignore the lock file, quietly bypass the checks this guide relies on.
- Over-wide publishing rights: many people or tokens able to publish to the private registry, turning one compromised account into a change to every consuming workspace.
Practitioner checklist
- Commit
.terraform.lock.hclfor every root module with h1 hashes for every platform you run. - Run CI init with
-lockfile=readonlyso unreviewed provider changes fail the job. - Pin every third-party registry module to an exact version and every Git module to a full commit SHA.
- Check nested modules for floating sources, not just your root configuration.
- Ship a provider_installation block to runners: allowlist first, mirror when scale or audit needs it.
- Keep dev_overrides and lock-file-breaking cache settings out of shared and CI configurations.
- Restrict private registry publishing to a release pipeline identity and a small named team.
- Use read-only, host-scoped registry tokens in plan and apply jobs and rotate them.
- Route provider and module upgrades through pull requests with a second reviewer.
Standards and references
- HashiCorp: Dependency Lock File
- HashiCorp: Module Sources
- HashiCorp: Version Constraints
- HashiCorp: CLI Configuration File
- HashiCorp: Publishing Providers
- HashiCorp: HCP Terraform Private Registry
- OpenTofu: Dependency Lock File
- MITRE ATT&CK: Compromise Software Dependencies and Development Tools
Related NHI Mgmt Group resources: Terraform and IaC Pipeline Credential Security Guide · Terraform Cloud and HCP Security Guide · Malicious Package and Dependency Confusion Guide · Atlantis Security