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).

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 http:// URLs, a target for a man-in-the-middle attack. Maven now blocks external HTTP repositories by default through a mirror in conf/settings.xml (the <blocked> flag and the external:http:* selector). The page notes it is a vulnerability if you connect to remote repositories directly instead of through a repository manager. See Repositories, Mirrors & Repository Managers.

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.

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-blocker mirror in conf/settings.xml rejects an http:// 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 is warn: a mismatch is logged and the build goes on. <checksumPolicy>fail</checksumPolicy> on a repository, or -C / --strict-checksums on 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>

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.

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

<file><exists>/etc/hosts</exists></file>

yes

no

no

<jdk>[17,)</jdk> (a platform criterion)

yes

yes

yes

<property><name>tidepool.extra</name></property>, run with -Dtidepool.extra=1

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> in settings.xml is 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 sets maven.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.validation is documented in the release notes with the values default, mild and off. Run here on Maven 3.10.0, -Dmaven.resolver.validation=off and =mild failed with Internal error: java.lang.IllegalArgumentException: No enum constant org.apache.maven.internal.aether.MavenValidatorFactory.ValidationLevel.off, while the upper-case OFF and MILD were 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/../helper produced, 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 available and an artifactId ... does not match a valid id pattern error, 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 reported Checksum validation failed, no checksums available where 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.

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 hash acc73b37f6487efd.

  • The JDK does. Built with a JDK 21 (JAVA_HOME pointing to it) instead of JDK 26, with the same maven.compiler.release 21, the hash was 443b0d61feb4ad5f. The manifest differs (Build-Jdk-Spec: 26 against Build-Jdk-Spec: 21, everything else equal), and so does one of the two classes: TideEngine.class compiled by JDK 26 and by JDK 21 had different SHA-256 values, while StationReader.class did 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 line built=${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, hash e44f74394784a86e

    built=2026-10-16T14:20:50Z, hash c2b55183a7f93b5a

    3.10.0

    built=2026-10-16T14:20:55Z, hash 865d653120ba4dbf

    built=2026-10-16T14:20:59Z, hash d2c3f0099c29904b

    4.0.0-rc-7

    built=2026-10-16T14:21:04Z, hash 77a3a877f795c6ed

    built=2026-10-16T14:21:09Z, hash 65df270a5d229597

    Replacing the line by built=${project.build.outputTimestamp} fixed it: on Maven 3.10.0 and 4.0.0-rc-7 the file said built=1980-02-01T00:00:00Z and both builds had the hash 5fb5694fdfe5206e on 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:00Z it was filled and two builds had the hash ac6b42cd31c6cab1. The rule: anything you put into the output about "when" must come from outputTimestamp, 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.

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.

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.

Changes between Maven versions

Version What changes for security and reproducibility

3.9.16

No project.build.outputTimestamp unless the project sets it: archives and the SBOM differ between builds. A repository POM’s ${env.*} and file-based profile activation are honoured. Credentials of a <server> are not scoped to an origin. The Resolver is the 1.9 line.

3.10.0

The super POM sets project.build.outputTimestamp, so archives and the CycloneDX SBOM are repeatable. POMs from repositories get restricted interpolation, and file-based profile activation is ignored (run above); credentials are scoped to the origin of the repository (<repositoryOrigins>); coordinates are validated by Resolver 2. -Dmaven.resolver.validation took the upper-case values in this run.

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 mvnenc tool (see settings.xml & Password Encryption).

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.timestamp in 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.xml or on the command line for gpg:sign are deprecated by the plugin itself; use gpg-agent or MAVEN_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 allowInsecureProtocol is set; dependency verification in Dependency Locking, Verification & Caching.

gpg:sign

The signjar task signs JARs with a keystore; see Packaging Archives.

The signing plugin; see Publishing.

project.build.outputTimestamp, artifact:compare

SOURCE_DATE_EPOCH and the modificationtime of the archive tasks (the Ant packaging page).

Archive tasks with reproducible file order and no file timestamps.

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 .asc was never fetched).

  • Sign releases in a profile with gpg:sign, passphrase from gpg-agent or MAVEN_GPG_PASSPHRASE, bestPractices on; verify with gpg.

  • Maven 3.10 ignores ${env.*} in dependency versions and file-based profile activation of repository POMs (opt-out maven.model.dependencyInterpolation.full); the system-scope rejection was not observed.

  • The .mvn directory runs before the build: read it first.

  • Reproducible: outputTimestamp (default since 3.10), no maven.build.timestamp, same JDK, pinned plugins, no version ranges; check with artifact:compare, read artifact:check-buildplan.

  • A CycloneDX SBOM is repeatable on 3.10; vulnerability scanners were not run here.