Back to glossary

Maven Central

Maven Central is the default public repository for Java and JVM libraries, serving immutable, signed releases that Maven and Gradle download by coordinates.

Also known as Central Repository, The Central Repository, Maven Central Repository, repo1.maven.org

Maven Central is the default public repository for Java and other JVM libraries, also called the Central Repository. Publishers prove control of a groupId namespace, then upload immutable releases with POM metadata, PGP signatures, and checksums. Maven, Gradle, and sbt fetch artifacts by coordinates. Its security model rests on namespace ownership, which builds almost never verify.

The mechanism is simple. Every artifact has coordinates, groupId:artifactId:version, and the groupId is a reversed domain name or code-host account that the publisher had to prove it controls. Central then guarantees that a published release never changes. Those two properties make Maven Central harder to poison than npm or PyPI in one specific way: an attacker cannot publish com.fasterxml.jackson.core:jackson-databind:2.99 without controlling fasterxml.com or a Portal account authorized for that namespace. The checks stop there. Maven verifies only SHA-1 and MD5 checksums fetched from the same server as the JAR. In Maven 3.9, a checksum mismatch is a warning, not an error. Maven itself never checks the PGP signatures that Central requires every publisher to upload.

Our position: namespace verification does not stop Java supply chain attacks; it reshapes them. None of the Maven Central incidents we have written up involved a stolen namespace. Two used new namespaces built to resemble real projects, and one arrived through an automated npm-to-Maven mirror. The useful defense is to analyze what is inside the JAR before its coordinates land in a POM, because nothing in the default download path will look.

What is Maven Central?

#

Maven Central is a static HTTPS file tree at repo1.maven.org/maven2, mirrored at repo.maven.apache.org/maven2. Sonatype operates it as a free service. The Apache Maven project defines the repository format and the client. Every Maven installation trusts Central without configuration because the Super POM, compiled into Maven itself, declares it. This excerpt comes from maven-model-builder inside Maven 3.9.11:

HTML, XML
<repositories>
  <repository>
    <id>central</id>
    <name>Central Repository</name>
    <url>https://repo.maven.apache.org/maven2</url>
    <layout>default</layout>
    <snapshots>
      <enabled>false</enabled>
    </snapshots>
  </repository>
</repositories>

Resolution needs no registry API. Coordinates map to file paths by a fixed rule from the Maven repository layout: replace the dots in the groupId with slashes, then append artifactId/version/artifactId-version.extension. A client resolves com.github.scribejava:scribejava-core:8.3.3 by fetching the POM at com/github/scribejava/scribejava-core/8.3.3/scribejava-core-8.3.3.pom, reading its dependencies, and recursing. When two paths in the tree ask for different versions of one library, Maven picks the "nearest definition" to your project, so a transitive dependency declared one level up beats a newer one declared three levels down.

Maven Central hosts releases only. The Apache upload guide states the rule plainly: "The Central Repository will not change or replace a release after it is published." Snapshot builds live in a separate repository that deletes them after 90 days, and snapshots are disabled for central in the Super POM above.

Immutability has two consequences. No one, including a hijacked maintainer, can swap the bytes behind an existing version. And a bad release cannot be fixed in place: publishers must ship a new version, while Central's operators remove confirmed malware from the tree entirely. Removal does not reach every copy: MVN Repository, a third-party index, was still serving the malicious XZ for Java impostor after Central removed it in December 2024.

How do you publish to Maven Central?

#

You publish to Maven Central by verifying a namespace in the Central Publisher Portal, then uploading a signed bundle that passes automated validation. The legacy OSSRH publishing service reached end of life on June 30, 2025, so older guides that point at oss.sonatype.org no longer work.

The namespace step is where Central's security model lives. Central offers three ways to verify a namespace:

  • Domain namespaces such as com.example require a DNS TXT record on example.com containing a verification key.
  • Code-hosting namespaces such as io.gitlab.<user> require a temporary public repository named after the verification key.
  • GitHub sign-up grants a verified io.github.<github username> namespace automatically, with no further proof.

The bundle is a zip laid out in repository format. Every non-POM project needs a main JAR, a -sources.jar, and a -javadoc.jar. Every file except the checksums needs a .asc PGP signature, and every file except the signatures needs .md5 and .sha1 checksums. The POM must carry name, description, url, a license, developer information, and SCM URLs.

Plain text
io/github/example/demo-lib/1.0.0/
  demo-lib-1.0.0.pom          .pom.asc          .pom.md5          .pom.sha1
  demo-lib-1.0.0.jar          .jar.asc          .jar.md5          .jar.sha1
  demo-lib-1.0.0-sources.jar  -sources.jar.asc  -sources.jar.md5  -sources.jar.sha1
  demo-lib-1.0.0-javadoc.jar  -javadoc.jar.asc  -javadoc.jar.md5  -javadoc.jar.sha1

Build plugins usually produce and upload the bundle, but the underlying call is one request to the Portal publisher API. The credential is a Portal user token, base64-encoded as username:password:

Bash
curl --request POST \
  --header "Authorization: Bearer $(printf '%s:%s' "$TOKEN_USER" "$TOKEN_PASS" | base64)" \
  --form bundle=@central-bundle.zip \
  "https://central.sonatype.com/api/v1/publisher/upload?publishingType=AUTOMATIC"

The response is a deployment ID. The deployment moves through PENDING, VALIDATING, and VALIDATED, then PUBLISHING and PUBLISHED. USER_MANAGED, the default, waits for a human to click Publish in the Portal. AUTOMATIC publishes as soon as validation passes.

Two dated caveats apply as of October 2026. The Portal's documented credential is a long-lived user token, and Central has announced no OIDC-based trusted publishing of the kind npm and PyPI offer. And since October 1, 2026, Central enforces monthly per-organization publishing limits, set where the top 10% of publishers by volume begin. The reference 90th-percentile figures are 1,167 files, 78 MB, and 7 releases a month.

Publish-time checks versus download-time checks

#

Maven Central verifies namespace control at publish time; Maven verifies almost nothing at download time. The two halves of the system check different things, and the gap between them is where attacks live.

At publish time, the Portal's security-relevant checks reduce to four: the uploader controls the namespace, the POM carries the required metadata, every file has a PGP signature that validates against a public key on a keyserver, and every file has matching checksums. None of these checks reads the code, so a look-alike namespace such as io.github.leetcrunch passes all four. A signature proves that someone holding a key signed the file; Central does not check that the key belongs to the project the name suggests. Nothing checks that the -sources.jar was compiled into the main JAR.

At download time, Maven 3.9 fetches the JAR and its .sha1 or .md5 from the same host. The checksum detects transfer corruption; anyone able to replace the JAR can replace its checksum too. A mismatch only logs a warning, because the default checksum policy is warn, a default that the long-standing Maven issue MNG-5728 proposes switching to fail. We confirmed the default by instantiating the policy class from the Maven 3.9.11 distribution with a ten-line program:

Plain text
$ java -cp "/opt/maven/lib/*:." Q
ArtifactRepositoryPolicy() checksumPolicy = warn
ArtifactRepositoryPolicy(true,null,null) checksumPolicy = warn

The second line matters because the Super POM declares central without a checksumPolicy, so the null falls through to warn. The -C or --strict-checksums flag upgrades mismatches to failures. Maven has no flag that verifies signatures. The resolver configuration reference lists keys for generating GPG and Sigstore signatures and none for verifying them.

The irony is written into Maven's own documentation. The upload guide says: "If there are no signatures, then users have no guarantee that they are downloading the original artifact." Central collects a signature for every file, and the default client never reads one.

CheckAt publish (Central Portal)At download (Maven 3.9 default)
Uploader controls the groupIdYes: DNS TXT record, code-host repository, or GitHub sign-upNo
POM metadata presentYes: name, description, URL, license, developers, SCMNo
PGP signatureMust be present and valid against a keyserver keyNot checked
Signing key belongs to the real projectNoNo
Checksums.md5 and .sha1 requiredSHA-1 and MD5 from the same host; mismatch is a warning
Sources JAR matches binary JARNoNo
Name resembles another projectNoNo
What the code doesNoNo

Central added optional Sigstore signature validation in January 2025. Sigstore ties a signature to an identity such as a GitHub account instead of a long-lived PGP key, which is a real improvement for tracing who published. It does not change the download column: consumers still have to verify the bundle themselves, and nothing in Maven does. It does not touch the last row either. A malicious publisher with a fresh GitHub account produces a perfectly valid Sigstore bundle.

How do attackers get malware onto Maven Central?

#

Attackers get malware onto Maven Central by acquiring a namespace that looks like a real project's, not by stealing the real one. Two paths common on npm are mostly closed. Namespace ownership blocks claiming an unregistered name that matches an internal package. Plain version strings block auto-upgrades: a POM that declares 1.2.3 gets 1.2.3, and Maven has no caret ranges to pull in a malicious 1.2.4 until someone edits the POM or merges a bot's upgrade pull request. Explicit ranges such as [1.0,2.0) exist, but plain versions are the norm. Look-alike namespaces are still cheap.

IncidentLegitimate coordinatesMalicious coordinatesHow the namespace was obtained
XZ for Java impersonation, published May 12, 2024org.tukaani:xzio.github.xz-java:xz-javaGitHub account named xz-java
ScribeJava credential theft, published January 25, 2024com.github.scribejava:scribejava-coreio.github.leetcrunch:scribejava-coreGitHub account named leetcrunch
Jackson typosquat, December 2025com.fasterxml.jackson.core:jackson-databindorg.fasterxml.jackson.core:jackson-databindRegistration of fasterxml.org, which satisfies DNS verification for org.fasterxml

Each case is typosquatting at the namespace level. The artifactId is identical, so a search for scribejava-core on Maven Central returned the real library and the copy side by side. The XZ for Java impostor published two benign versions before adding a backdoor in version 1.9 that listened on port 11337 and executed whatever shell script arrived. The ScribeJava copy shipped six companion artifacts under the same fake groupId to look widely used, and exfiltrated OAuth credentials to Pastebin only on the 15th of each month. The Jackson typosquat went further: it bought a matching domain in another TLD, then dropped Cobalt Strike beacons on Linux and macOS, according to the reporting.

The ScribeJava case exploits a quirk of Central's history. Central stopped accepting new com.github namespaces on April 1, 2021, so new GitHub-based projects can only get io.github.<user>. Older projects such as ScribeJava still live under com.github. An io.github copy of a com.github library therefore looks like a plausible migration, not an obvious fake.

Two more paths lead around namespace verification:

  • Expired ownership. In January 2024, Oversecured's MavenGate research found 3,710 of 26,163 domains behind Maven Central groupIds available to buy or at auction. Whoever buys a lapsed domain can pass the DNS TXT check for its groupId and publish new versions under it. Oversecured reports that Central then blocked the vulnerable groupIds it found. The structural issue remains: control of the domain is the whole proof.
  • Automated mirroring. The org.mvnpm namespace republishes npm packages as Maven artifacts so Java web frameworks can consume them. During the second Shai-Hulud wave, that pipeline rebuilt a compromised PostHog release as org.mvnpm:posthog-node:4.18.1, carrying the same Bun-based payload the npm worm used. Central purged it the same day we reported it. A namespace check verifies the mirror operator, not the upstream code it ingests.

Classic dependency confusion is harder here than on npm or PyPI, because an attacker cannot claim your company's com.yourcompany groupId without your DNS. The risk reappears inside corporate artifact repositories that merge Central with internal repositories, and in internal groupIds built on domains the company never registered, which anyone can buy.

Where does a Maven dependency's code run?

#

A Maven dependency's code can run during the build, at compile time, during tests, at application startup, and when called, but downloading it executes nothing. Maven has no equivalent of npm's install scripts, so a JAR sitting in ~/.m2/repository is inert. That is a real advantage, and it is narrower than it sounds.

StageDoes dependency code run?Example
ResolveNo. POMs and JARs are copied into ~/.m2/repository.None: Maven has no install scripts
Build pluginsYes. Plugins are artifacts downloaded from Maven Central by default and run with the build's permissions.Any plugin in the POM
CompileYes on JDK 22 and earlier, without configuration. Annotation processors on the classpath run inside javac.Our 1,872-byte probe below
TestYes. Test-scoped dependencies load into the test JVM on every CI run.Test frameworks and mocks
StartupYes, without your code calling the dependency. ServiceLoader and Spring Boot auto-configuration instantiate classes named in JAR metadata.Jackson typosquat's Spring @Configuration loader
CallYes, when your application uses the API.ScribeJava copy: theft on the 15th of each month. XZ for Java copy: shell on port 11337

Compile time is the least appreciated of these. javac discovers annotation processors through META-INF/services/javax.annotation.processing.Processor in any JAR on the classpath and runs them inside the compiler. Maven passes your dependencies to javac as the classpath. We built a 1,872-byte JAR containing one processor and compiled an empty class against it with JDK 21.0.12:

Java
@SupportedAnnotationTypes("*")
public class BuildTimeProbe extends AbstractProcessor {
  @Override public synchronized void init(ProcessingEnvironment env) {
    super.init(env);
    // Runs inside javac. Any code here executes on the build machine.
    System.err.println("[probe] running inside javac as " + System.getProperty("user.name")
        + ", env vars visible: " + System.getenv().size());
  }
  @Override public SourceVersion getSupportedSourceVersion() { return SourceVersion.latestSupported(); }
  @Override public boolean process(Set<? extends TypeElement> a, RoundEnvironment r) { return false; }
}
Plain text
$ javac -d . BuildTimeProbe.java
$ echo com.example.BuildTimeProbe > META-INF/services/javax.annotation.processing.Processor
$ jar cf innocent-util-1.0.jar META-INF/services com
$ javac -cp innocent-util-1.0.jar -d out App.java
Note: Annotation processing is enabled because one or more processors were found
  on the class path. A future release of javac may disable annotation processing
  unless at least one processor is specified by name (-processor), or a search
  path is specified (--processor-path, --processor-module-path), or annotation
  processing is enabled explicitly (-proc:only, -proc:full).
  Use -Xlint:-options to suppress this message.
  Use -proc:none to disable annotation processing.
[probe] running inside javac as root, env vars visible: 182
$ javac -proc:none -cp innocent-util-1.0.jar -d out App.java
$

The processor ran with the build's full environment, which in CI often means deploy tokens and cloud credentials. With -proc:none it did not run. JDK 23 made -proc:none the default when no processor is configured explicitly, so builds on JDK 23 or later stop running classpath processors implicitly. Builds on JDK 22 and earlier still run them, with only the note above as a warning.

Startup is the other path that needs no call from your code. Java's ServiceLoader and Spring Boot auto-configuration instantiate classes named in a JAR's metadata files. The Jackson typosquat used Spring @Configuration to run its loader when the application started. Nothing in the dependency tree had to be invoked by name.

Defenses that pin bytes, and defenses that read code

#

The defenses that work for Maven Central dependencies either pin the exact bytes you already reviewed or inspect new artifacts before they enter the build. Most teams run neither.

ControlWhat it verifiesCatchesMisses
Maven defaultSHA-1/MD5 from the same host, warn onlyCorrupted downloads, sometimesAny malicious artifact, any tampered mirror
mvn -CSame checksums, fail on mismatchCorrupted downloadsAnything served with a matching checksum
Maven trusted checksumsSHA-512 of every resolved artifact, recorded once and committedAny change to a byte you already acceptedA dependency that was malicious when you recorded it
Gradle dependency verificationRecorded checksums, optionally PGP keys you trustChanged bytes; a new signing key on a known artifactNew coordinates you add yourself
PGP verification against a pinned key per groupIdSignature against a key you chose for each groupIdA hijacked namespace signing with a new keyA legitimate maintainer key used to sign malware
Pre-install behavioral analysisWhat the JAR, sources JAR, and POM actually contain and doLook-alike namespaces, backdoors, credential theftNothing is complete; dormant logic can still hide

Trusted checksums are Maven's closest equivalent to a lockfile. Enable them in .mvn/maven.config and record once in a clean environment:

Plain text
--strict-checksums
-Daether.checksums.algorithms=SHA-512,SHA-1,MD5
-Daether.trustedChecksumsSource.summaryFile=true
-Daether.trustedChecksumsSource.summaryFile.basedir=${session.rootDirectory}/.mvn/checksums/
-Daether.artifactResolver.postProcessor.trustedChecksums=true
-Daether.artifactResolver.postProcessor.trustedChecksums.checksumAlgorithms=SHA-512
-Daether.artifactResolver.postProcessor.trustedChecksums.failIfMissing=true

Verdict: turn on trusted checksums in Maven or dependency verification in Gradle, because they cost one commit and make every later byte change fail the build. Then accept what they cannot do. Every row above the last one assumes the artifact was safe the first time you saw it, and none of the three incidents above changed bytes after publication. They were malicious from the first download. Only analysis of new artifacts addresses that case.

That is where we work. Socket analyzes every file in a Maven package, including the compiled JAR and the -sources.jar, which is how we flagged the XZ for Java backdoor: its source comments described the backdoor, while developers who only consume binary JARs would never see them. Socket Firewall Enterprise proxies Maven traffic and blocks packages we have flagged before they reach ~/.m2; the free tier covers only JavaScript, Python, and Rust. Neither replaces pinning. They cover the first download, which pinning cannot.

The deeper open problem is the gap between the sources JAR and the binary JAR. Maven Central requires both, and nothing proves one was built from the other. Rebuilding releases from source and comparing bytes is the only real answer, and projects such as Reproducible Central do it for a small fraction of artifacts. Until a rebuild is the norm, the code in a Maven Central JAR is whatever its publisher compiled, and the only way to know what it does is to look.