The Go build cache is one of those features developers love without thinking about. You type go build, and if the compiler has already compiled the same package with the same inputs before, it reuses the cached object file and your build finishes in milliseconds. It is fast, deterministic, and almost invisible. It is also, when you think about it, a trust boundary that most people have not examined. If the cache can be poisoned, your build can produce attacker-chosen output from apparently-clean source.
I spent a few weekends digging into how the cache is actually structured and where the sharp edges are. Here is what I found.
What is in the build cache?
The Go build cache lives under $GOCACHE, which defaults to $HOME/.cache/go-build on Linux and %LocalAppData%\go-build on Windows. Inside, it contains millions of small files organized into two-character hex subdirectories. Each cached item is keyed by a hash that covers the compiler version, the build flags, the source content, and the hashes of every dependency object file.
When the compiler is invoked to build a package, the Go command computes this key, looks it up in the cache, and if a hit is found, uses the cached output. If not, the compile runs, and the result is stored. The same mechanism covers linker output, test binaries, and various auxiliary artifacts.
The hashing is thorough. It is not just source file hashes. It includes the content of go.mod, the toolchain version, the CGO_ENABLED setting, the -gcflags, the -ldflags, the GOOS and GOARCH, the vendor directory if present, and more. If any input changes, the key changes, and a new compile runs. This is what makes the cache reliable in normal use.
Where can poisoning happen?
Poisoning means writing attacker-chosen bytes into a cache entry whose key a future build will look up. There are a few ways this could happen.
First, direct filesystem access. If an attacker can write to $GOCACHE, they can overwrite cache entries. On a shared build machine or a compromised developer laptop, this is plausible. The Go command does not verify cache entry integrity before using them; it trusts the filesystem.
Second, shared caches. Some CI systems cache $GOCACHE between builds or across projects to speed up CI. If one build can write bad entries that a later build consumes, that is a poisoning path. I have seen this in setups where CI runners share a persistent volume for $GOCACHE to save time.
Third, network-backed caches. Tools like GOCACHEPROG, introduced experimentally in Go 1.22 (released 6 February 2024) and improved in later releases, let you plug in a remote cache protocol. If the remote cache is compromised or the protocol is misconfigured, the same poisoning risk applies over the network.
Why does poisoning matter if the cache is just compiler output?
Because the Go command uses cache output without recompilation. If a cached object file for crypto/tls has been modified to skip certificate verification, every build that hits that cache entry gets a binary with broken TLS. The source looks clean. go.sum is correct. govulncheck passes. The attacker wins because their modification lives in the compiled artifact, not in the source tree.
This is why the threat model matters. For local developer caches, poisoning requires local access, which is usually a sign of a much bigger compromise. For shared CI caches, the threat is more serious, because one bad build can affect every subsequent build that hits the cache.
Can Go detect cache corruption?
The Go command does some self-healing. If a cache entry fails to parse or the expected format is wrong, the compile runs again. But it does not verify that a cache entry corresponds to any particular hash of the expected output. The cache key is an input key, not an output commitment.
go clean -cache wipes everything. It is a nuclear option but useful when investigating suspected poisoning. go env GOCACHE shows where the cache lives.
Patterns for safe CI caching
A few principles I apply in CI.
First, never share the Go build cache across projects. Each project gets its own cache namespace. The cache can be shared across runs of the same project, which is useful, but not across different projects where the threat models differ.
Second, if the cache is persisted (cached between CI runs), verify the Go toolchain version has not changed before trusting the cache. A cache populated by Go 1.22.5 should not be used by Go 1.23.0, and the hash keys will not match anyway, but the cache volume itself can pile up cruft from old versions that an attacker could exploit via race conditions.
Third, use per-runner caches rather than cross-runner shared caches. A poisoned cache on one runner should not affect another runner. Modern CI systems like GitHub Actions and GitLab CI support runner-specific caches.
Fourth, in high-security environments, disable the cache for release builds. Set GOCACHE=off to force a full recompile. This is slower but produces artifacts that did not consult any prior cache state. For release artifacts destined for production, the tradeoff is usually worth it.
CVE-adjacent incidents
CVE-2023-29406 in Go 1.20.5 affected HTTP host header handling and was fixed in July 2023. The release notes are worth reading as a reminder that the compiler and toolchain get CVEs too, and any cached build output from a vulnerable toolchain carries the same issue even after you upgrade. Clearing the cache after a toolchain update is not just hygiene; it is correctness.
CVE-2024-24783 and CVE-2024-24784 in March 2024 affected crypto/x509 and net/mail respectively. A build cache populated before those fixes would happily reuse stale object files for standard library packages until a source or toolchain change invalidated the keys. In practice, upgrading the toolchain invalidates all keys because the toolchain version is part of the hash, but this is worth confirming when you run your own cache layer.
GOCACHEPROG and remote caches
GOCACHEPROG lets you define a program that the Go toolchain invokes to read and write cache entries. This enables shared remote caches across a team or organization. Bazel users will recognize the pattern.
The protocol is simple, which makes it easy to implement and easy to get wrong. A cache server that does not authenticate writes can be poisoned by anyone on the network. A cache server that does not authenticate reads leaks intermediate object files to anyone who can guess hash keys.
If you deploy a remote cache, require mutual TLS, require signed cache entries, and treat the cache server as a security-critical service. Do not let developers push arbitrary cache content to a production-shared cache.
Detecting poisoning after the fact
This is hard. If a cache entry is poisoned and then used to build a binary, the only trace is in the binary itself. Reproducible builds help: if you rebuild from source with a clean cache and get different bytes than the production artifact, something is wrong.
Go binaries have runtime/debug.BuildInfo, which records the module graph and the toolchain version. They do not record cache state. For high-assurance builds, complement go build with periodic clean-cache builds and compare outputs. A mismatch is a signal to dig deeper.
The cache is a feature worth keeping
Nothing here is an argument against using the build cache. For everyday development, the cache is fantastic. The concern is specific to untrusted access paths. Most teams do not face a realistic risk of cache poisoning, because their caches are per-developer and per-runner and the attackers they worry about do not have filesystem access. For teams that do share caches across runners or use remote cache protocols, the risk is real enough to design for.
How Safeguard Helps
Safeguard tracks the GOCACHE configuration of every build environment it observes and flags setups that share caches across projects or runners without integrity controls. When a release build is attested, Safeguard records whether the cache was empty, per-runner, or shared, so that artifact provenance captures the cache posture at build time. Policy gates can require that production builds run with GOCACHE=off or with a signed remote cache protocol, and Safeguard compares cached and from-scratch builds of the same source to catch divergences that could indicate poisoning.