
This post is part of a series on IaC Security: Securing Your Terraform and OpenTofu Infrastructure. For the broader picture of how modules fit alongside providers, CI/CD, and signing infrastructure as attack surface, see Terraform Supply Chain Attacks: Risks, Real Incidents, and How to Defend Against Them.
Pin your providers, commit your lock file, and you've closed off the best-understood Terraform supply chain risk. Modules are a different story, and that's true whether you run Terraform or OpenTofu, since OpenTofu forked from Terraform 1.5.7 with the identical module registry model intact (more on exactly where the two engines do and don't diverge below). The same lock file that cryptographically verifies every provider binary you download does almost nothing for the modules sitting right next to them in your configuration. That gap isn't theoretical: it's been demonstrated at scale against the public Terraform Registry, and it's the exact mechanism GitHub's own community has been asking HashiCorp to close for years.
Open .terraform.lock.hcl and you'll see two different shapes of entry. A provider looks like this:
provider "registry.terraform.io/hashicorp/aws" {
version = "5.94.1"
constraints = "~> 5.0"
hashes = [
"h1:6Zn6y+0jw5S9iiRLzeCTIJ5+uMhKW6PLdD+bZuiPJ6c=",
]
}That hashes value is a cryptographic commitment. If the provider binary Terraform downloads doesn't match one of those hashes, terraform init refuses to continue. It doesn't matter who controls the source repository or what happened to it after this hash was recorded; the artifact itself is verified.
A module reference in your configuration carries no equivalent entry in the lock file at all:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
}There's no hash, no checksum, nothing binding that version string to specific, unchangeable source content. Version resolution depends entirely on the registry correctly and permanently mapping a version to a commit, and staying accurate about that mapping forever.
This isn't an oversight nobody has raised. A long-running GitHub issue has asked HashiCorp to support pinning modules by commit SHA the way providers are pinned by hash, specifically to prevent the scenario where a re-tagged release ships different code than what was reviewed.
HashiCorp's official response is that the registry already handles this: "Today's Terraform Registry now checks which commit the tag refers to at import time and then always returns that specific commit for any future request." In other words, the first time a version is imported into the registry, the registry is supposed to lock in the commit it currently maps to and ignore any later tag movement for that version.
That's a real mitigation, and it's worth taking at face value: it means routine tag mutation after a version is already indexed shouldn't retroactively change what gets served. But "at import time" is the operative phrase, and it's exactly where the remaining risk lives.
In 2024, researchers at BoostSecurity Labs analyzed the public Terraform Registry in full, more than 3,000 providers and 13,000+ modules, and found several hundred modules still exploitable through two techniques that operate before or around that import-time lock:
pull_request_target can run with the base repository's permissions and secret access instead of the fork's. If a maintainer's CI is configured to check out and run code from an incoming PR under that trigger, without carefully scoping what that workflow can do, a malicious PR author can ride those elevated permissions to trigger a release, publish, or tag-push action as if they were the legitimate maintainer. The registry then dutifully imports whatever that workflow just produced.The researchers also showed a runtime path that has nothing to do with the registry's tagging model at all: a .gomplate.yaml file with a postExec command, planted inside a module used for templating during a build step, that quietly exfiltrates a GitHub token. No CVE, no registry exploit, just a build tool doing exactly what it was configured to do on behalf of code nobody reviewed line by line.
Community members on the same GitHub issue remain unconvinced the import-time fix fully closes the gap, one specifically citing recent supply chain incidents as the reason their team can't rely on the registry alone. That's a reasonable position to hold even while accepting that HashiCorp's mitigation is real: the two things aren't in conflict, they just describe different points in the module's lifecycle.
No, not the core gap. OpenTofu forked from Terraform at version 1.5.7 and inherited the Module Registry Protocol unchanged. That protocol defines exactly two operations for a module: list the available versions, and return a download location for a chosen version. Nowhere in it is there a checksum, hash, or signature field. That's true regardless of which registry serves the request, because it's the same protocol on both sides of the fork.
If anything, OpenTofu's own documentation is more direct about the gap than Terraform's. Its dependency lock file docs state plainly: "At present, the dependency lock file tracks only provider dependencies. OpenTofu does not remember version selections for remote modules, and so OpenTofu will always select the newest available module version that meets the specified version constraints." There's no module entry in the lock file at all, cryptographic or otherwise, in either engine. Whatever version discipline you get comes entirely from how tightly you write your own constraint, not from anything the tooling remembers between runs.
What actually differs is the registry's transparency, not its guarantees. HashiCorp's Terraform Registry backend is closed source, so the "we lock a tag to its commit at first import" mitigation cited earlier can only be taken on the strength of a maintainer's comment on a GitHub issue. OpenTofu's registry is the opposite: it's a fully open source, statically generated system (the opentofu/registry project) that indexes releases straight from GitHub's own tag and release feeds and pre-builds the entire registry API as static files. Anyone can read the indexing code itself instead of trusting a statement about what it does.
That transparency cuts both ways, though. OpenTofu's own architecture write-up doesn't describe an equivalent "lock the commit at first import" protection the way HashiCorp's does. That's not evidence the protection is absent, only that it isn't publicly documented the way HashiCorp's is, which matters if you were counting on that specific mitigation rather than verifying it. Either way, the practical conclusion is the same for both engines: nothing here changes what you need to do. Commit-SHA pinning through a direct Git source, a private module registry, and policy-as-code gating on provisioners all apply identically whether you're running Terraform or OpenTofu.
A hijacked or maliciously published module doesn't need a registry-level trick to cause damage after you apply it. Two ordinary Terraform features are enough:
local-exec provisioners run an arbitrary shell command on whatever machine executes apply, whether that's a developer's laptop or your CI runner. A module can bury a data-exfiltration command inside a provisioner block that looks like routine cleanup or bootstrapping.
http or external data sources can send state values, resource attributes, or environment variables to any URL, including one the module author controls. This is often the more dangerous path, because it can run during plan, before anyone has approved an apply.
Neither of these requires a bug in Terraform. They're normal functionality, and that's precisely why static scanners built to catch misconfigurations (open security groups, missing encryption) often aren't tuned to flag them as suspicious by default. You have to specifically look for unreviewed provisioners and outbound data sources in third-party modules.
| Approach | What it protects against | Tradeoff |
|---|---|---|
Registry version constraint (version = "~> 5.0") |
Accidental upgrades to incompatible versions | No cryptographic guarantee; depends entirely on registry tag-to-commit integrity |
Registry exact version (version = "5.2.1") |
Same tag-to-commit dependency, just narrower | Still resolves through the registry; doesn't stop a compromised import |
Git source pinned to a commit SHA (source = "git::https://github.com/org/repo.git?ref=8f2c9a1") |
Any form of tag movement or re-publishing, since a commit SHA is content-addressed | Loses SemVer resolution and the registry's searchable catalog |
| Private module registry, republished from your own vetted release process | Both of the above, by moving the trust boundary to your platform team | Requires someone to own curation and republishing |
For anything you didn't write yourself, commit-SHA pinning through a direct Git source is the only option on this list that a tag-mutation or pwn-request attack genuinely can't bypass, because there's no tag in the loop for an attacker to move. The cost is real: you give up automatic SemVer bumps and the discoverability of the registry UI.
That's exactly the gap a private module registry is designed to close without asking you to give up either. Your platform team vets a module once, publishes it to an internal catalog under a logical name like account.scalr.io/org/vpc/aws, and consuming workspaces resolve against that catalog instead of the public registry's namespace. Nothing about the module's provenance depends on a third-party repository's tag protection settings, because the version consumers see was republished by your own team on your own release cadence. Scalr's implementation, free up to 50 runs per month, syncs automatically from your VCS when you tag a SemVer release, so this doesn't turn into a manual gatekeeping bottleneck.
Review before you vendor, not just before you upgrade. A version bump PR from Dependabot is a good moment to actually look at what changed in the module's source, not just merge because CI is green.
Gate provisioners and data sources with policy as code. Policy as code evaluated against the plan can flag or block local-exec, remote-exec, and outbound http/external data sources from third-party module sources, so a malicious module has to clear a review gate even if it slipped past everything else.
Prefer maintainers with tag protection enabled. You can check this yourself on GitHub before adopting a public module: repository settings will show whether release tags are protected from being moved after the fact. It's a small signal, but it directly addresses the exact mechanism BoostSecurity's research exploited.
Don't assume "popular" means "safe." Star count and download numbers reflect adoption, not security review. The modules BoostSecurity found vulnerable weren't obscure; several hundred out of the registry's full catalog is a meaningful fraction of what teams pull in every day without a second look.
Beyond the private module registry described above, two more pieces close gaps this post hasn't otherwise covered:
Policy as code enforcement. Scalr's policy as code engine can run against every plan to allowlist approved module sources and flag or block local-exec provisioners and outbound http/external data sources from third-party modules. A module that clears every other check still has to pass a policy gate before apply.
Module usage reports for incident response. Prevention isn't the whole story. If a module you rely on is later disclosed as compromised, the way BoostSecurity's research disclosed several hundred, the next question is "where are we exposed?" Scalr's module usage reporting shows which workspaces across your entire account are on which module version, so you can answer that in minutes instead of grepping through lock files repository by repository.
Both are part of the same free tier (free up to 50 runs per month) as the private module registry.
2. Pin modules by Commit SHA – hashicorp/terraform GitHub Issue #29867
3. Terraform Module Sources – HashiCorp Developer Docs
4. Terraform Dependency Lock File – HashiCorp Developer Docs
5. Dependency Lock File – OpenTofu Docs
