TrademarkTrademark
Features
Documentation

Terraform Module Supply Chain Attacks: Tag Mutation, Pwn Requests, and How to Pin Safely

Terraform's dependency lock file cryptographically pins providers but not modules. Here's how tag mutation and pwn-request attacks exploit that gap, and how to close it.
Ryan FeeAugust 12, 2026
Terraform Module Supply Chain Attacks: Tag Mutation, Pwn Requests, and How to Pin Safely
Key takeaways
  • Terraform providers get SHA256 hash pinning in the lock file. Modules only get a version constraint, so a hijacked module version can bypass the one integrity check most teams already rely on.
  • HashiCorp says the registry locks a version tag to the commit it pointed to at first import. BoostSecurity Labs' 2024 research found several hundred public-registry modules still hijackable through tag mutation and pull_request_target ("pwn request") abuse, both of which can happen before that first import ever locks anything in.
  • The only way to get a cryptographic guarantee on a module today is to source it directly from Git with a pinned commit SHA, not a version constraint resolved through the registry protocol.
  • A malicious module doesn't need a registry-level exploit to cause damage. A local-exec provisioner or an http/external data source can exfiltrate state or environment secrets the moment terraform apply runs it.
  • A private module registry moves the trust boundary: your platform team vets and republishes what enters the catalog, so consuming workspaces never resolve directly against the public registry's mutable namespace.
  • OpenTofu inherited the identical Module Registry Protocol from Terraform, including the total absence of module checksums. OpenTofu's own docs go further than Terraform's in stating the gap plainly: the dependency lock file tracks only providers, so OpenTofu 'will always select the newest available module version that meets the specified version constraints.' The real difference between the two registries is transparency, not protection: OpenTofu's indexing code is open source and auditable, HashiCorp's isn't.

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.

The lock file protects providers. It doesn't protect modules the same way.

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.

What HashiCorp says the registry already does

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.

What BoostSecurity Labs found anyway

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:

  • Tag mutation on unprotected repositories. If a repository lacks tag protection rules, an attacker with write access (including a compromised maintainer account, or a maintainer who unknowingly merged something malicious) can move a tag before the registry has indexed that specific version, or publish a new version under a tag that was never previously imported. The "always returns that specific commit" guarantee only applies once a version has been through that first import; a not-yet-indexed or newly created tag has no such protection yet.
  • "Pwn request" exploitation of release workflows. Workflows triggered on 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.

Does OpenTofu fix this?

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.

What a malicious module can actually do once it runs

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.

How to pin a module so it actually can't move

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.

Other defenses worth layering in

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.

How Scalr helps

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.

Key Sources Used

1. Erosion of Trust: Unmasking Supply Chain Vulnerabilities in the Terraform Registry – BoostSecurity Labs

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

6. Module Registry Protocol – OpenTofu Docs

7. Building the OpenTofu Registry – OpenTofu Blog

Frequently asked questions

What's the difference between how Terraform pins providers versus modules?

The dependency lock file (.terraform.lock.hcl) records a SHA256 checksum for every provider version, so a provider binary that doesn't match a recorded hash fails init outright. Module entries in the same file only record a version constraint, with no cryptographic hash tying that version to specific source content. A module version can be pointed at different code than what you originally reviewed without the lock file ever flagging it.

Does pinning a module version protect me from a supply chain attack?

It reduces risk but doesn't eliminate it. A version constraint like ~> 5.0 or even an exact version = "5.2.1" still resolves through the registry's tag-to-commit mapping. If that mapping can be manipulated, for example through tag mutation on a repository without tag protection, pinning a version number doesn't help, because the version number itself now points somewhere else. Pinning to an exact Git commit SHA, not a registry version, is the only pin a supply chain attacker can't move.

What is a "tag mutation" attack on a Terraform module?

It's when an attacker with write access to a module's source repository moves an existing release tag (for example v2.1.0) to point at a different, malicious commit. Terraform's module resolution follows the tag, not a fixed hash, so every configuration referencing that version can silently start pulling different code. This requires the repository to lack tag protection rules, which many public module repositories do.

What is a "pwn request" and how does it relate to Terraform modules?

A pwn request abuses GitHub Actions workflows triggered on pull_request_target, which can run with the base repository's permissions and secret access rather than the fork's. If a maintainer's release workflow checks out and executes code from an incoming pull request under that trigger, a malicious PR author can hijack the workflow's elevated permissions to publish a new module version as if they were the legitimate maintainer, without ever needing direct write access to the repository.

How do I pin a Terraform module to an exact, unchangeable version?

Source it directly from Git with a commit SHA in the ref parameter: source = "git::https://github.com/org/repo.git?ref=8f2c9a1". A commit SHA is content-addressed; it can't be moved the way a tag can. The tradeoff is you lose the registry's automatic SemVer resolution and searchable catalog, which is exactly the gap a private module registry is built to fill without giving that up.

Can a Terraform module run malicious code without anyone noticing?

Yes. A module can include a local-exec provisioner that runs an arbitrary shell command on the machine running terraform apply, or an http or external data source that sends state values, environment variables, or cloud credentials to an attacker-controlled endpoint. None of this requires a Terraform vulnerability. It's normal Terraform functionality being used by code you didn't write and may not have read line by line.

Does OpenTofu protect modules from supply chain attacks any better than Terraform does?

No, not cryptographically. OpenTofu forked from Terraform 1.5.7 and kept the Module Registry Protocol unchanged, so module downloads still carry no checksum or signature at the protocol level, in either engine. OpenTofu's own dependency lock file documentation states plainly that the lock file tracks only provider dependencies and that OpenTofu 'will always select the newest available module version that meets the specified version constraints,' since it never records a prior module selection at all. The one real difference is that OpenTofu's registry indexing code (opentofu/registry on GitHub) is open source and publicly auditable, while HashiCorp's Terraform Registry backend is closed source. That's a transparency advantage, not a stronger cryptographic guarantee, and every defense in this post applies identically to both engines.
About the author
Ryan Feedirector of platform engineering at Scalr
Ryan Fee is the director of platform engineering at Scalr, with over 15 years of experience improving infrastructure experiences at companies large and small.