The Gradle build cache is one of those features that most teams enable because it makes CI 40% faster, and then never think about again. That is a mistake. A remote build cache is a trusted input to every build that reads from it, and if you treat it as trusted without verifying it, you have quietly added a high-privilege component to your supply chain that nobody in your security review ever evaluated. I have watched two teams now discover, months after the fact, that their cache had been populated with outputs from a compromised laptop and that those outputs had been consumed by production builds.
This post covers what the Gradle build cache actually is, where the security seams are, what changed in Gradle 8.x that matters, and the concrete configuration that gets you to a defensible posture without giving up the performance win that justified the cache in the first place.
What the cache actually caches
The Gradle build cache is a key-value store keyed by the hash of task inputs, where the value is the task's output files plus metadata. When a task runs, Gradle computes a cache key from the hash of every declared input: source files, classpath JARs, task configuration, JVM version, and any property marked with @Input. If the key exists in the cache, Gradle skips the task and restores the outputs. This is a brilliant design, and it also means that whoever can write to the cache can influence the output of any build whose inputs hash to the same key.
There are two caches: the local cache, which lives in ~/.gradle/caches/build-cache-1 by default, and the remote cache, which is typically an HTTP endpoint backed by something like Gradle Enterprise, a generic HTTP bucket, or increasingly S3 with the community S3 backend. The local cache is a single-user concern; the remote cache is a multi-writer trust boundary.
The cache poisoning attack
The core attack is simple. An attacker who can populate the remote cache with entries under keys that future builds will request controls the outputs those builds will restore. Because Gradle restores the cached outputs without rebuilding, the attacker has silently injected arbitrary bytes into the build output. If the task is compileJava, those bytes are class files. If the task is jar, those bytes are the published artifact. No source code was modified. No pom was altered. Nothing in git changed. The attack leaves almost no trace on the repo side.
How does an attacker populate the cache? In the default configuration of most teams I have audited, any CI agent with network access to the cache can write. Gradle 7.x and earlier did not distinguish push from pull permissions by default, and the common deployment pattern was a single service account with read-write everywhere. Compromise any one of the dozens of CI agents, or any developer laptop configured with push credentials, and you can seed the cache.
What Gradle 8.x changed
Gradle 8.0 shipped in February 2023, 8.5 in November 2023, and 8.7 in March 2024, and across those releases the team has tightened the cache story. Gradle 8.1 added stricter input snapshotting for file collections. Gradle 8.4 introduced configuration cache improvements that reduce the ways a task can have non-declared inputs, which is important because non-declared inputs are how cache keys collide across otherwise-different builds. Gradle 8.7 improved the handling of remote cache entries produced by different Gradle versions, reducing the false-positive restore rate that tempts teams to disable integrity checking.
The most important security-relevant flag, org.gradle.caching.debug=true, has been in the plugin for years but is underused. Turn it on in CI and you get a log of every cache hit and miss with the key, which is what you need to investigate a suspected poisoning after the fact.
The hardening checklist
The configuration that I recommend for a production Gradle remote cache, in rough order of impact:
First, split push and pull. The remote cache should accept pushes only from a small set of trusted builds, typically a single "canonical build" CI job that runs on a trusted branch. Every other build, including developer workstations, pulls only. In Gradle 8.x this is configured in the settings.gradle.kts with remote<HttpBuildCache> { push = System.getenv("TRUSTED_BUILD") == "true" }. The environment variable is set only in the trusted CI job. Every other consumer gets cache hits but cannot contribute entries.
Second, sign cache entries. Gradle Enterprise supports signed cache entries natively; for self-hosted HTTP caches, put the cache behind a verifying proxy that attaches a signature header on push and validates it on pull. The signature binds the cache key, the blob content, and the identity of the pushing build. Without signatures, there is no way after the fact to distinguish a legitimate entry from a poisoned one.
Third, scope the cache by branch or project. A single global cache across a monorepo of forty projects is a giant shared attack surface. Prefixing cache keys by project or by branch reduces the blast radius of any one compromise. Gradle supports this through custom remote cache implementations, or at the proxy layer.
Fourth, disable the cache for tasks that produce signed or release artifacts. The publishToMavenRepository and signMavenPublication tasks should never be cache hits. Mark them with outputs.cacheIf { false } or use the @DisableCachingByDefault annotation introduced in Gradle 7.4. Anything that touches signing keys should rebuild from source every time.
Fifth, lock down the input declarations. Tasks that have hidden inputs, like environment variables or system time, will collide in the cache with other builds that have different real-world inputs but the same declared inputs. Audit with ./gradlew --write-verification-metadata and the Build Scan input reports. Plugins with @Input on mutable objects are the classic offender.
Cache entries and dependency verification
Gradle 6.2 introduced dependency verification, a separate mechanism that hashes every downloaded dependency against a checked-in verification-metadata.xml. Dependency verification is not the same as the build cache, but they interact. If you enable verification and an attacker has poisoned the build cache with outputs from a compromised dependency, the verification check fires on the dependency download but the cache restore bypasses that check for tasks that consumed the dependency. This is not a bug in verification; it is a reminder that the cache is a separate trust boundary that needs its own defenses.
In practice, you want both: verification on dependencies, signing on cache entries, and signed provenance on final artifacts via build-provenance or SLSA-style attestation. Each layer catches a different attack.
The CI-secret leak that nobody thinks about
One last thing. Cache entries can contain secrets. If your build reads an environment variable and writes it into a resource file that a downstream task packages into a JAR, and that task is cacheable, the cache entry contains the secret. The next consumer who hits that cache key, potentially on a different CI agent or even a developer laptop, gets the secret. I have seen this happen with a CodeArtifact token baked into a properties file. Audit your cacheable task outputs for secrets the same way you audit build logs. Gradle 8.2 added a build-cache-sanitization plugin for this purpose; it is worth configuring.
How Safeguard Helps
Safeguard treats the Gradle build cache as a first-class supply chain input, not just a performance optimization. We monitor cache push activity from your CI agents and flag anomalous pushers, unusual cache key patterns, and entries that would be restored into release builds. When paired with our dependency verification integration, Safeguard correlates cache hits against the provenance of the inputs that produced them, so a poisoned entry raises an alert before the restored outputs reach a release artifact. Teams running Gradle 8.x with our hardening policy get automatic checks against the full checklist above, plus remediation guidance for the specific tasks in their builds that are unsafely cacheable.