Maven Security & Reproducible Builds
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Apache Maven 3.10.0, maven-gpg-plugin 3.2.8, maven-artifact-plugin 3.7.0 and
cyclonedx-maven-plugin 2.9.3. Every example was run on JDK 26 on 2026-10-16 (a second JDK, 21, where a page says so), and
the same example was also run on Maven 3.9.16 and on Maven 4.0.0-rc-7 (a release candidate, not a final release) when
the behaviour differs. The scratch projects are copies of the tidepool project (a parent POM and the tidepool-model,
tidepool-engine and tidepool-cli modules, every plugin version pinned, maven.compiler.release 21) and a few tiny
io.tidepool.lab libraries in a repository on disk (file://). Nothing was published anywhere, no real credentials were
used, and paths in the console output were shortened to <work>. Output that was filtered with grep or sed says so.
The cross-tool view of this topic (Ant, Maven and Gradle together, tool distributions and wrappers, the 2026 security
releases) is in Supply Chain Security. This page
is the Maven side with hands-on runs: what Maven protects, what it leaves to you, and how to build the same bytes twice.
The basics of reproducible archives (the outputTimestamp default and artifact:compare) are in
Packaging: JARs, WARs & Manifests
and are not repeated here.
The Maven security model
The Maven security page starts from one sentence: Maven performs the actions defined in the pom.xml, which usually means
compiling and running code and using plugins and dependencies from the configured repositories, so the security model
assumes you trust the pom.xml and the code, dependencies and repositories that are used in your build. Building untrusted
code safely is left to you, through isolation (a container, a throwaway virtual machine, a build without credentials).
Source: Maven: Security Model
The same page lists the published vulnerabilities. They show where Maven did harden itself:
| Issue | Affected, and fix | What it was about |
|---|---|---|
CVE-2021-26291 |
Maven 3.6.3 and earlier; fixed in 3.8.1 |
Maven Central contains POMs with custom repositories on |
Archive extraction (no CVE number on the page) |
Dependency Plugin 3.1.0 and earlier, EAR 3.0.0 and earlier, Javadoc 2.5 to 3.0.0, WAR 2.1-alpha-1 to 3.2.0, Plugin Plugin 3.0 to 3.5.1 |
Path-traversal file names in a crafted archive could write outside the target directory when a plugin unpacked a compromised dependency (found by the Snyk Security Research Team). Low severity. |
CVE-2013-0253 |
Maven 3.0.4 and Wagon 2.1 to 2.3; fixed in Maven 3.0.5 and Wagon 2.4 |
Maven 3.0.4 shipped a non-secure SSL mode by default that disabled all certificate checking. |
CVE-2012-6153 |
Wagon WebDAV provider 2.12 and earlier; fixed in 3.0.0 |
Host name verification in an old Commons HttpClient, allowing spoofed SSL servers. |
Source: Maven: Security Vulnerabilities
The page adds that no binary patches are produced for individual vulnerabilities: to get a fix you upgrade to a Maven that contains it. The 2026 hardening of Maven 3.10 is described (and run) below.
What Maven checks when it downloads, and what it does not
Maven’s checks on a download are the repository layout and the checksum files next to each artifact; the rest is policy that you configure.
-
HTTPS only for external repositories. The default
maven-default-http-blockermirror inconf/settings.xmlrejects anhttp://repository (the blocker, and the mistake of switching it off, are shown in Repositories, Mirrors & Repository Managers). -
Checksums. A download is compared with the
.sha1(or stronger) file the repository serves. The default policy iswarn: a mismatch is logged and the build goes on.<checksumPolicy>fail</checksumPolicy>on a repository, or-C/--strict-checksumson the command line, makes it an error (shown on the same page). A checksum from the server that supplies the file proves the download was not corrupted, not that the server is honest. -
Trusted checksums and repository filtering. The Resolver can compare downloads with checksums you provided ahead of time and can refuse artifacts from a repository that should not serve them (a group id or a path prefix); both are configured with
aether.*keys and are covered in Maven Resolver. -
Credentials. Passwords belong in
settings.xml(encrypted or from the environment), and since 3.10 are offered only to the origin they are declared for, see settings.xml & Password Encryption.
What Maven does not do is verify PGP signatures of what it downloads. That is demonstrated after the next section, which shows how artifacts are signed.
On a project that you build with untrusted plugins there is another place for a guard: the
Enforcer can ban plugins (bannedPlugins) and repositories
(bannedRepositories, requireNoRepositories) and require pinned plugin versions (requirePluginVersions).
Signing artifacts with the maven-gpg-plugin
Maven Central requires every artifact to carry a PGP signature (see
Publishing to Maven Central), and other
repositories may too. The gpg:sign goal signs all the attached artifacts of a project with GnuPG and attaches the .asc files, so
that install and deploy publish them next to the files. Sign in a profile so that day-to-day builds do not need a key:
<profile>
<id>sign</id>
<build>
<plugins>
<plugin>
<artifactId>maven-gpg-plugin</artifactId>
<version>3.2.8</version>
<executions>
<execution>
<id>sign-artifacts</id>
<phase>verify</phase>
<goals><goal>sign</goal></goals>
<configuration>
<bestPractices>true</bestPractices>
<gpgArguments>
<arg>--pinentry-mode</arg>
<arg>loopback</arg>
</gpgArguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
Source: Sign artifacts with GnuPG
The documented way to hand the passphrase to a batch build is the environment variable MAVEN_GPG_PASSPHRASE (the name is the
passphraseEnvName parameter); on a workstation prefer gpg-agent. The plugin’s own documentation discourages a passphrase on
the command line, in settings.xml or in the POM, and bestPractices set to true makes the build fail if one of those
is used. loopback pinentry is what lets gpg take the passphrase from the environment instead of opening a dialog.
Source: Security considerations
A throwaway key
To run this without touching a real keyring, a key was created in a temporary GnuPG home, with a placeholder passphrase
(changeit-example), no expiry and an address that cannot exist. GnuPG talks to its agent through a socket in its home
directory, and a long scratch path made gpg fail with can't connect to the agent: File name too long, so the home
(<work>/gnupg) was used through a short symbolic link, /tmp/g6sec-gnupg:
$ export GNUPGHOME=/tmp/g6sec-gnupg LC_ALL=C
$ gpg --batch --pinentry-mode loopback --passphrase 'changeit-example' --quick-generate-key 'Tidepool Example <tidepool-example@example.invalid>' ed25519 sign never
$ gpg --list-secret-keys --keyid-format long
/tmp/g6sec-gnupg/pubring.kbx
----------------------------
sec ed25519/528A555E00CA2FB2 2026-10-16 [SC]
4D6C2255797C90E6B2354F01528A555E00CA2FB2
uid [ultimate] Tidepool Example <tidepool-example@example.invalid>
(gpg printed Spanish messages on this machine; LC_ALL=C gives the English ones shown on this page.) The standalone
tidepool-model project at version 1.0.0 with the sign profile above was then deployed to a repository on disk, with the passphrase in the environment
and the home and key given as user properties:
$ MAVEN_GPG_PASSPHRASE=changeit-example mvn -B -Psign -Dgpg.homedir=/tmp/g6sec-gnupg -Dgpg.keyname=528A555E00CA2FB2 deploy -DaltDeploymentRepository=local::file://<work>/repo | grep -E "gpg|Uploading|BUILD"
[INFO] --- gpg:3.2.8:sign (sign-artifacts) @ tidepool-model ---
[INFO] Signer 'gpg' is signing 2 files with key 528A555E00CA2FB2
[INFO] Uploading to local: file://<work>/repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.pom
[INFO] Uploading to local: file://<work>/repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.jar
[INFO] Uploading to local: file://<work>/repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.jar.asc
[INFO] Uploading to local: file://<work>/repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.pom.asc
[INFO] BUILD SUCCESS
Two files were signed (the POM and the JAR), and the repository listing shows the .asc signatures next to the files and the .md5 and
.sha1 checksum files that the deploy plugin writes:
$ find repo -type f | sort
repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.jar
repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.jar.asc
repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.jar.md5
repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.jar.sha1
repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.pom
repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.pom.asc
repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.pom.md5
repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.pom.sha1
repo/io/tidepool/tidepool-model/maven-metadata.xml
repo/io/tidepool/tidepool-model/maven-metadata.xml.md5
repo/io/tidepool/tidepool-model/maven-metadata.xml.sha1
The signature is ordinary detached OpenPGP, so gpg itself is the verifier. Verifying the JAR, then a copy of it with one byte
appended, and finally with a keyring that does not have the public key (the situation of a user who downloads a signed
artifact for the first time):
$ gpg --verify tidepool-model-1.0.0.jar.asc tidepool-model-1.0.0.jar
gpg: Signature made Fri Oct 16 16:17:47 2026 CEST
gpg: using EDDSA key 4D6C2255797C90E6B2354F01528A555E00CA2FB2
gpg: Good signature from "Tidepool Example <tidepool-example@example.invalid>" [ultimate]
$ gpg --verify tidepool-model-1.0.0.jar.asc tampered/tidepool-model-1.0.0.jar # exit status 1
gpg: Signature made Fri Oct 16 16:17:47 2026 CEST
gpg: using EDDSA key 4D6C2255797C90E6B2354F01528A555E00CA2FB2
gpg: BAD signature from "Tidepool Example <tidepool-example@example.invalid>" [ultimate]
$ GNUPGHOME=/tmp/g6sec-empty gpg --verify tidepool-model-1.0.0.jar.asc tidepool-model-1.0.0.jar
gpg: Signature made Fri Oct 16 16:17:47 2026 CEST
gpg: using EDDSA key 4D6C2255797C90E6B2354F01528A555E00CA2FB2
gpg: Can't check signature: No public key
A signature only means something when the verifier already trusts the key (the key id or fingerprint comes from the project’s web
site, a keys file or a keyserver the consumer checked). The signature in the first block is "Good" and [ultimate] only because the key
is in the same throwaway keyring that made it. gpg:sign is part of the release flow shown in
Releasing and
Install, Deploy & Distribution Management.
Hardware keys, GnuPG with a real agent and the signing of a real Central release were not run here.
Maven does not verify the signature on download
A consumer build can use this repository, but is the .asc ever looked at? To find out, the signature file in a copy of
the repository was replaced by the text this is not a signature, and a project depending on
io.tidepool:tidepool-model:1.0.0 resolved it with an empty local repository, on Maven 3.10.0 and on 4.0.0-rc-7
(dependency:resolve, filtered):
$ mvn -B -Dmaven.repo.local=<work>/m2s dependency:resolve | grep -E "tidepool-model|BUILD"
[INFO] Downloading from signed: file://<work>/repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.pom
[INFO] Downloaded from signed: file://<work>/repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.pom (2.3 kB at 755 kB/s)
[INFO] Downloading from signed: file://<work>/repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.jar
[INFO] Downloaded from signed: file://<work>/repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.jar (5.8 kB at 5.8 MB/s)
[INFO] io.tidepool:tidepool-model:jar:1.0.0:compile -- module tidepool.model (auto)
[INFO] BUILD SUCCESS
$ ls <work>/m2s/io/tidepool/tidepool-model/1.0.0
_remote.repositories
tidepool-model-1.0.0.jar
tidepool-model-1.0.0.jar.sha1
tidepool-model-1.0.0.pom
tidepool-model-1.0.0.pom.sha1
The .asc was not even downloaded; only the .sha1 checksums that Maven verifies were. The result was the same on Maven 4.0.0-rc-7. Signatures are for
people and for dedicated tools, so if you want to check signatures of your dependencies you need something outside Maven core: a
plugin such as the third-party PGPVerify Maven plugin (not run here), a rebuild comparison like the one in the
reproducible-builds part of this page, or a repository manager that checks signatures on the way in (see
Repositories, Mirrors & Repository Managers).
Maven 3.10 hardening for POMs from repositories
The 3.10.0 release notes list, under "Security Hardening", that a model resolved from a repository (a dependency, a parent or an
imported BOM) "contributes less to the build": restricted interpolation, profile activation limited to platform-derived criteria,
recessive repository merging and rejection of system-scoped dependencies, with the property
maven.model.dependencyInterpolation.full as an opt-out. The point is that a POM you download is data from someone
else: it should not read your environment or your files. To see what that means, small libraries were deployed to a repository on disk
(<work>/restricted/repo, with deploy:deploy-file) and a consumer project depended on one of them. Each library has a
POM with one questionable feature; the tree is printed with dependency:tree. (The consumer’s own POM is not
restricted; only models read from a repository are.)
The first library, lib-interp, declares a dependency on io.tidepool.lab:helper whose version is
${env.TIDEPOOL_HELPER_VERSION}, an environment variable. With TIDEPOOL_HELPER_VERSION=1.0 set, the filtered output of
mvn dependency:tree on Maven 3.10.0 shows that the placeholder is no longer resolved:
$ TIDEPOOL_HELPER_VERSION=1.0 mvn -B dependency:tree -Dlib=lib-interp | grep -E "tidepool.lab:|WARNING|BUILD"
[WARNING] The POM for io.tidepool.lab:helper:jar:${env.TIDEPOOL_HELPER_VERSION} is missing, no dependency information available
[INFO] \- io.tidepool.lab:lib-interp:jar:1.0:compile
[INFO] \- io.tidepool.lab:helper:jar:${env.TIDEPOOL_HELPER_VERSION}:compile
On Maven 3.9.16 the same project resolved helper 1.0 (the environment variable was used to choose a version, which is how a
repository POM could have influenced your build from your environment). With -Dmaven.model.dependencyInterpolation.full=true
on Maven 3.10.0 the old behaviour came back and helper:jar:1.0 was in the tree. On Maven 4.0.0-rc-7 helper was
simply not in the tree and the build succeeded.
The other libraries test the profile activation of a POM that comes from a repository. Each library has a profile that adds helper:2.0
when it is active; the table says whether helper 2.0 was in the tree:
| Activation in the library’s POM | 3.9.16 | 3.10.0 | 4.0.0-rc-7 |
|---|---|---|---|
|
yes |
no |
no |
|
yes |
yes |
yes |
|
yes |
yes |
yes |
So a repository POM can no longer activate a profile because a file exists on your disk, which is the kind of probing that
profile activation allowed, while the JDK and the properties you pass yourself on the command line (the user properties)
still activate profiles. Another library declared a dependency with <scope>system</scope> and
<systemPath>/etc/hosts</systemPath>. The release notes list the rejection of system-scoped dependencies from such models,
but in this run it was not rejected: the dependency was in the tree and dependency:build-classpath printed
.../lib-system-1.0.jar:/etc/hosts on both 3.9.16 and 3.10.0, and also in the tree of 4.0.0-rc-7. Do not rely on 3.10 to
block that case; system scope is deprecated anyway (see
Dependencies, Scopes & Mediation).
Other 3.10 changes that touch security
-
Credentials are scoped to an origin. A
<server>insettings.xmlis offered only to repositories and mirrors with that exact id, and a repository activated through a profile needs<repositoryOrigins>to say where its credentials may go, or setsmaven.repository.credentialScope=strict. This is run, with the warnings it prints, in settings.xml & Password Encryption. -
Coordinates are validated. Resolver 2 validates artifact and metadata coordinates before mapping them to a repository path.
maven.resolver.validationis documented in the release notes with the valuesdefault,mildandoff. Run here on Maven 3.10.0,-Dmaven.resolver.validation=offand=mildfailed withInternal error: java.lang.IllegalArgumentException: No enum constant org.apache.maven.internal.aether.MavenValidatorFactory.ValidationLevel.off, while the upper-caseOFFandMILDwere accepted. Use the upper-case values until the documentation and the code agree. -
Invalid coordinates in a repository POM were already handled. A library POM with the dependency artifact id
../helper/../helperproduced, on Maven 3.9.16 and on 3.10.0 alike,The POM for io.tidepool.lab:lib-coord:jar:1.0 is invalid, transitive dependencies (if any) will not be availableand anartifactId ... does not match a valid id patternerror, and the build went on without the transitive dependencies. Maven 4.0.0-rc-7 failed that resolution for another reason: the files had been copied into the repository without checksum files, and it reportedChecksum validation failed, no checksums availablewhere 3.9.16 and 3.10.0 only warned. -
Stricter POM validation. Duplicate dependencies and plugins are now errors, which closes the loophole of a second, hidden declaration (the 3.10 breaking changes are in Versions & Release History).
The project you open is code
Because Maven trusts the project, merely running mvn in a directory you did not write can run its code: the plugins of the POM, but
before that, the files of the .mvn directory (see
Configuring Maven). A project with only a POM and two lines in .mvn:
$ cat .mvn/maven.config .mvn/jvm.config
-Dtidepool.demo=from-maven-config
-Dtidepool.jvm=from-jvm-config
$ mvn -q help:evaluate -Dexpression=tidepool.demo -DforceStdout
from-maven-config
$ mvn -q help:evaluate -Dexpression=tidepool.jvm -DforceStdout
from-jvm-config
Both values were set by files of the project, without anything on the command line. maven.config adds command-line options
and jvm.config options of the JVM that runs Maven, so a hostile jvm.config could name a Java agent, and a hostile
.mvn/extensions.xml makes Maven download and load a core extension before the build starts (see
Extensions). The prudent practice is the one of the security
model: review those files and the POM before building a project from a stranger, and build it in an isolated
environment with no credentials (settings.xml, tokens and keys) in reach.
SBOMs and vulnerability scanning
A software bill of materials lists the components of a build so that a later advisory can be matched against it. The CycloneDX
Maven plugin (a third-party plugin of the CycloneDX project, 2.9.3 on Central) creates one. It is shown, with the other tools'
SBOMs and the format, in
Supply Chain Security; here is only what it adds to reproducibility. makeBom was
run on tidepool-engine twice, two seconds apart, with JSON output, and the two files compared with diff:
$ mvn -B org.cyclonedx:cyclonedx-maven-plugin:2.9.3:makeBom -pl tidepool-engine -DoutputFormat=json
[INFO] --- cyclonedx:2.9.3:makeBom (default-cli) @ tidepool-engine ---
[INFO] CycloneDX: Writing and validating BOM (JSON): <work>/r1/tidepool-engine/target/bom.json
[INFO] attaching as tidepool-engine-1.0.0-SNAPSHOT-cyclonedx.json
$ diff bom1.json bom2.json # Maven 3.10.0: no output
$ diff b39_1.json b39_2.json # Maven 3.9.16
7c7
< "timestamp" : "2026-10-16T14:22:35Z",
---
> "timestamp" : "2026-10-16T14:22:39Z",
On Maven 3.10.0 the file is byte for byte the same (it has no timestamp in its metadata; its serialNumber was the same in every run on both Maven versions), on 3.9.16 the only difference is the creation time. So under 3.10’s reproducible default
the SBOM can be compared and signed like any other output. Its purl entries listed commons-csv 1.14.1, commons-io 2.20.0
and commons-codec 1.19.0 as the dependencies of the engine.
Source: CycloneDX Maven plugin
An SBOM does not tell you which components are vulnerable; a scanner compares it, or the dependency tree, with a vulnerability database. The OWASP Dependency-Check Maven plugin (13.0.0 on Central) is the well-known one; it needs to download the NVD data (rate-limited without an API key), so it was not run here. The Enforcer’s third-party Sonatype OSS Index rule (see Enforcer) queries an online service and was not run either. Run scanners in CI against the dependency tree, look at what is really in the tree with Dependency Analysis & Convergence, and pin what you accept with Dependency Management & BOMs.
Reproducible builds
A build is reproducible when the same sources, build environment and build instructions give bit-by-bit identical files, so
that anyone can rebuild a published release and compare. Maven’s contribution is project.build.outputTimestamp, the single
timestamp that its archive plugins write into every entry; since Maven 3.10.0 the super POM sets it (to
1980-02-01T00:00:00Z), so all projects are in the reproducible mode (see
Packaging: JARs, WARs & Manifests,
which also has the first artifact:compare runs). That is the necessary part, not the sufficient one. The official guide says
what still leaks into the output: plugins that do not support the timestamp, dependency version ranges, different line endings on
Windows and Unix, and the major version of the JDK that compiled the classes.
Leaks that were measured
The experiments use the engine JAR of tidepool (the sha256 prefixes are the first 16 hexadecimal digits).
-
The directory and the time do not matter, here. The project was copied into two directories and built on Maven 3.10.0 two seconds apart (
mvn package -DskipTests -pl tidepool-engine -am): both JARs had the hashacc73b37f6487efd. -
The JDK does. Built with a JDK 21 (
JAVA_HOMEpointing to it) instead of JDK 26, with the samemaven.compiler.release21, the hash was443b0d61feb4ad5f. The manifest differs (Build-Jdk-Spec: 26againstBuild-Jdk-Spec: 21, everything else equal), and so does one of the two classes:TideEngine.classcompiled by JDK 26 and by JDK 21 had different SHA-256 values, whileStationReader.classdid not. -
${maven.build.timestamp}does. The one place where this build used the real time was a filtered resource file (see Resources & Filtering) with the linebuilt=${maven.build.timestamp}. Built twice, four seconds apart, the JAR hash changed on every version:Maven First build Second build 3.9.16
built=2026-10-16T14:20:46Z, hashe44f74394784a86ebuilt=2026-10-16T14:20:50Z, hashc2b55183a7f93b5a3.10.0
built=2026-10-16T14:20:55Z, hash865d653120ba4dbfbuilt=2026-10-16T14:20:59Z, hashd2c3f0099c29904b4.0.0-rc-7
built=2026-10-16T14:21:04Z, hash77a3a877f795c6edbuilt=2026-10-16T14:21:09Z, hash65df270a5d229597Replacing the line by
built=${project.build.outputTimestamp}fixed it: on Maven 3.10.0 and 4.0.0-rc-7 the file saidbuilt=1980-02-01T00:00:00Zand both builds had the hash5fb5694fdfe5206eon both versions. Maven 3.9.16 does not define the property, so the line stayed unresolved (built=${project.build.outputTimestamp}); with-Dproject.build.outputTimestamp=2026-10-16T00:00:00Zit was filled and two builds had the hashac6b42cd31c6cab1. The rule: anything you put into the output about "when" must come fromoutputTimestamp, never from the clock.
artifact:check-buildplan
artifact:check-buildplan lists the plugins in the build plan that are known not to produce reproducible output; the list (a
properties file inside the plugin, overridable with pluginIssues) names the first fixed version of the jar, war, source,
shade, assembly, archetype and other plugins. The tidepool parent with the jar plugin deliberately downgraded to 3.1.2 (the
list requires 3.2.0), filtered with grep on the lines of the goal:
$ mvn -B clean package artifact:check-buildplan -pl tidepool-model -DskipTests | grep -E "check-buildplan|outputTimestamp|non-reproducible|No known|BUILD"
[INFO] --- artifact:3.7.0:check-buildplan (default-cli) @ tidepool-model ---
[INFO] <project.build.outputTimestamp> property (= 1980-02-01T00:00:00Z) is inherited, you can override in pom.xml
[ERROR] plugin with non-reproducible output: org.apache.maven.plugins:maven-jar-plugin:3.1.2, require minimum 3.2.0
[INFO] No known issue in 5 plugins
[INFO] BUILD SUCCESS
Note that the message is at [ERROR] level but the build still succeeded, with exit status 0, although the goal documents
failOnNonReproducible (user property check.failOnNonReproducible) with a default of true; -Dcheck.failOnNonReproducible=true made no difference
in this run. With the parameter set to false the message became a [WARNING]. So treat the output as a report to read (or
grep in CI), and use artifact:compare as the check that fails. On Maven 3.9.16, where the property is not set, the goal
adds the line Reproducible Build not activated by project.build.outputTimestamp property with a link to the guide.
Source: artifact:check-buildplan
artifact:compare against another environment
artifact:compare rebuilds and compares with a reference build in a local or remote repository (the basic run is on the
packaging page). Its more interesting use is across environments. A standalone tidepool-model 1.0.0 was built and
deployed to a file repository on JDK 26 and the local copy removed. Rebuilding with the same JDK matched; rebuilding with JDK 21
failed, and the goal says which environment differs (filtered with grep):
$ JAVA_HOME=<a JDK 21> mvn -B clean verify -DskipTests artifact:compare -Dreference.repo=ref::file://<work>/refrepo | grep -E "reference build|Current build|rebuild comparison|mismatch|BUILD"
[INFO] reference build java.version: 26 (from MANIFEST.MF Build-Jdk-Spec)
[ERROR] Current build java.version: 21 (from MANIFEST.MF Build-Jdk-Spec)
[INFO] reference build os.name: Unix (from pom.properties newline)
[ERROR] size mismatch tidepool-model-1.0.0.jar: investigate with diffoscope target/reference/io.tidepool/tidepool-model-1.0.0.jar target/tidepool-model-1.0.0.jar
[ERROR] [Reproducible Builds] rebuild comparison result: 1 files match, 1 differ
[ERROR] BUILD FAILURE
The POM matched and the JAR did not (ok=1, ko=1 in target/tidepool-model-1.0.0.buildcompare, which ends with the
diffoscope command to run; diffoscope is not installed here, so the final step was not run). The JDK line is the answer: a reproducible
release needs its build JDK to be recorded and used by whoever verifies it (a toolchain, see
Compiling & Toolchains, or the build
description of a release). The older goal artifact:buildinfo records the Java version, the vendor, the operating system, the
line separator, the Maven version and the SHA-512 of every output, but it printed a warning that the goal is deprecated, because
buildinfo is an internal format, prefer the describe-build-output or compare goals.
Source: artifact:compare
Rebuilding releases independently
A local comparison, as the guide warns, does not prove that the build does not depend on its environment (the user name, the
directory, the line endings of a Windows checkout). The proof is a rebuild by somebody else, from the tagged sources, on a
different machine: Apache projects do it with mvn verify artifact:compare -Dreference.repo=<staging URL> while voting on a
release, and the Reproducible Central project lists releases on Central that were rebuilt independently. The guide also covers
keeping project.build.outputTimestamp current: the release plugin 3.0.0-M1 and later updates it in the commit that sets the
release version, and the versions plugin’s versions:set updates it from 2.9.0 (see
Releasing). Rebuilding a published release from outside was not
run here, since nothing real was published.
Source: Reproducible builds: FAQ
Changes between Maven versions
| Version | What changes for security and reproducibility |
|---|---|
3.9.16 |
No |
3.10.0 |
The super POM sets |
4.0.0-rc-7 (release candidate) |
Same default timestamp; same restricted interpolation and profile activation of repository POMs in the runs above; a
repository file without checksum files was an error where 3.x warned. Encrypting passwords uses the new |
Where older material is outdated
-
Maven 2 and early Maven 3 material accepts any repository over
http://; since 3.8.1 that is blocked by default. -
Builds were not reproducible by default, and
maven.build.timestampin a manifest or a properties file was the usual way of recording the build time; it is the first thing to remove when you want identical rebuilds. -
The unpack-and-trust attitude to dependencies (a plugin that unpacks a dependency into the project) is exactly what the archive-extraction advisory above was about; keep the plugins that unpack archives current.
-
Passphrases in
settings.xmlor on the command line forgpg:signare deprecated by the plugin itself; usegpg-agentorMAVEN_GPG_PASSPHRASE.
Equivalents in Ant and Gradle
| Maven | Ant | Gradle |
|---|---|---|
HTTPS-only repositories, checksums |
Ivy and the Resolver Ant Tasks have their own repository and checksum handling; see Ivy Settings & Resolvers and Maven Resolver Ant Tasks. |
Repositories refuse insecure protocols unless |
|
The |
The |
|
|
Archive tasks with reproducible file order and no file timestamps. |
Source: Gradle: the Signing plugin
Source: Gradle: reproducible archives
The cross-tool picture is in Comparing Ant, Maven and Gradle.
Summary
-
Maven trusts the POM, its plugins, dependencies and repositories; isolate and review what you do not trust.
-
Maven blocks external HTTP, verifies checksums (strictly if you ask), and scopes credentials (3.10); it does not verify PGP signatures on download (a bad
.ascwas never fetched). -
Sign releases in a profile with
gpg:sign, passphrase fromgpg-agentorMAVEN_GPG_PASSPHRASE,bestPracticeson; verify withgpg. -
Maven 3.10 ignores
${env.*}in dependency versions and file-based profile activation of repository POMs (opt-outmaven.model.dependencyInterpolation.full); thesystem-scope rejection was not observed. -
The
.mvndirectory runs before the build: read it first. -
Reproducible:
outputTimestamp(default since 3.10), nomaven.build.timestamp, same JDK, pinned plugins, no version ranges; check withartifact:compare, readartifact:check-buildplan. -
A CycloneDX SBOM is repeatable on 3.10; vulnerability scanners were not run here.