A Java package manager like Maven or Gradle does not just download the libraries you asked for; it resolves an entire transitive graph, and that graph is where most supply chain risk in Java projects actually lives. When you add one line to a pom.xml or build.gradle, you may be pulling in dozens of packages you never named, from a mirror you never audited. Understanding how resolution works is the first step to using it safely.
What a Java package manager actually does
Maven and Gradle both do the same core job: read your declared dependencies, resolve versions across the transitive graph, fetch artifacts from a repository (usually Maven Central), and put them on the compile and runtime classpath. The difference is in how they resolve conflicts.
Maven uses "nearest wins": when two paths in the dependency tree pull different versions of the same artifact, the one closest to your project in the tree is selected. Gradle, since version 5, defaults to selecting the highest requested version instead. This matters for security, because a transitive dependency can silently downgrade or upgrade a library that has a known vulnerability, and the two tools will make different choices from the same inputs.
To see what you actually resolved, use the built-in tree commands:
# Maven
mvn dependency:tree
# Gradle
./gradlew dependencies --configuration runtimeClasspath
Read that output before you trust a build. It is common to find a logging or serialization library three levels deep that no one on the team chose deliberately.
Where the risk hides in the dependency graph
The Log4Shell incident is the clearest example. CVE-2021-44228 affected Apache Log4j 2 versions from 2.0-beta9 through 2.14.1, and it was remotely exploitable through crafted log messages that triggered a JNDI lookup. The fix landed in 2.17.1 for Java 8 (2.12.4 for Java 7, 2.3.2 for Java 6). Countless teams were exposed not because they added Log4j directly, but because a framework buried it deep in the transitive graph.
That is the pattern to internalize: your direct dependencies are the small part of your attack surface. The transitive ones are the large part, and they change every time a direct dependency releases a new version. Software composition analysis exists precisely to watch this graph. An SCA tool can flag a vulnerable transitive package that your pom.xml never mentions and tell you which direct dependency to bump to evict it.
Pin versions and control resolution
Floating or range versions make builds non-reproducible and let a compromised release slip in. In Maven, use dependencyManagement to pin exact versions in one place:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.1</version>
</dependency>
</dependencies>
</dependencyManagement>
In Gradle, use a platform or dependency constraints, and turn on dependency locking so the resolved graph is written to a lockfile and enforced on every build:
dependencyLocking {
lockAllConfigurations()
}
Run ./gradlew dependencies --write-locks to generate the lock state, commit it, and any future resolution that would change a version fails the build until a human reviews it. Jackson's jackson-databind is a good example of why pinning matters; it has a long history of deserialization CVEs, and unmanaged version drift there is a real hazard. If you work with it heavily, our Jackson databind security guide goes deeper.
Verify what you download
A package manager will happily install a tampered artifact if the repository serves one. Two defenses matter.
First, verify checksums and signatures. Maven Central publishes SHA and PGP signatures for every artifact. Maven can enforce checksum policy:
mvn -C clean install # fail the build on a checksum mismatch
Second, control your sources. Do not let builds resolve from arbitrary mirrors. Point every project at a single internal repository proxy (Nexus or Artifactory) that you control, and disable fallback to unknown repositories. This also protects against dependency confusion, where an attacker publishes a public package with the same name as your internal one and a higher version number so the "highest wins" logic grabs the malicious copy.
Bake scanning into the build
Security that lives only on a developer laptop does not scale. Wire dependency scanning into CI so a pull request that introduces a vulnerable package is flagged before merge. The mechanics are simple:
# GitHub Actions example
- name: Build
run: mvn -B verify
- name: Dependency scan
run: |
# your SCA scanner of choice runs against the resolved graph
sca-scan --manifest pom.xml --fail-on high
Set the failure threshold deliberately. Failing on every low-severity advisory trains developers to ignore the gate; failing on high and critical keeps signal high. Generate a software bill of materials (SBOM) as a build artifact too, so you can answer "are we affected by the next Log4Shell?" in minutes instead of days.
A practical hardening checklist
Pin exact versions and commit a lockfile. Route all resolution through an internal proxy you control. Enforce checksum and signature verification. Run dependency:tree or the Gradle equivalent during review of any dependency change. Scan the resolved graph in CI with a sensible severity gate, and produce an SBOM on every build. None of these are exotic; the failure mode is almost always that a team did one or two and assumed they were covered.
FAQ
Is Maven or Gradle more secure?
Neither is inherently more secure; they resolve version conflicts differently (Maven picks the nearest, Gradle the highest), which changes which version ends up on your classpath. Security comes from how you configure either one: pinned versions, lockfiles, verified sources, and CI scanning.
How do I find vulnerable transitive dependencies?
Run mvn dependency:tree or ./gradlew dependencies to see the full resolved graph, then cross-reference it against a vulnerability database. A software composition analysis tool automates this and maps each finding back to the direct dependency you need to change.
What is dependency confusion and how do I prevent it?
Dependency confusion is when an attacker publishes a public package with the same name as your private one and a higher version, tricking the resolver into fetching the malicious copy. Prevent it by routing all resolution through a controlled internal repository and never mixing public and private namespaces.
Should I use version ranges in Maven or Gradle?
Avoid open version ranges for anything you ship. They make builds non-reproducible and let a new, potentially compromised release enter without review. Pin exact versions and use a lockfile so upgrades are an explicit, reviewed decision.