Java Post-Quantum Cryptography with Bouncy Castle
Java developers can use Bouncy Castle 1.86 to access NIST’s finalized post-quantum standards through familiar Java cryptography APIs. This guide uses ML-KEM (FIPS 203) for key encapsulation and ML-DSA (FIPS 204) for digital signatures.
The current algorithm names are ML-KEM and ML-DSA. Kyber and Dilithium were the names of the pre-standard candidates. New applications should not use the old KYBER or DILITHIUM identifiers as substitutes for the final standards because pre-standard encodings and final standardized algorithms are not automatically interoperable.
Before selecting a provider, compare Bouncy Castle’s scope, compliance boundary, and protocol integration with the alternatives in the post-quantum cryptography library guide.
Why Bouncy Castle for PQC in Java
Java 21 introduced the generic javax.crypto.KEM API. Java 24 added standard ML-KEM and ML-DSA parameter names to NamedParameterSpec, but a Java API or standard name does not by itself guarantee that every installed provider implements the algorithm. Bouncy Castle remains useful when you need a current provider across a mixed Java estate or want the same provider behavior in development and production.
Key advantages:
- Implements ML-KEM, ML-DSA, and SLH-DSA using their finalized NIST definitions
- Exposes standard JCA/JCE names such as
ML-KEMandML-DSA - Supports Java 8 and later through the
bcprov-jdk18onartifact - Provides both provider-based APIs and lower-level primitives
- Is actively maintained; version 1.86 is the current general release as of September 2026
- Uses the permissive Bouncy Castle licence
The ordinary bcprov package and the separately validated BC-FJA module are different products and security boundaries. Do not infer FIPS validation for ML-KEM or ML-DSA merely because an application uses Bouncy Castle.
Maven Setup
Use the current provider artifact. PQC support is included in bcprov; a separate bcpqc-jdk18on dependency is not required.
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.86</version>
</dependency>
For Gradle:
implementation 'org.bouncycastle:bcprov-jdk18on:1.86'
Despite its name, bcprov-jdk18on targets Java 8 and later. Pin the version, review Bouncy Castle security advisories, and update deliberately rather than relying on an unbounded dependency range.
Registering the Provider
Register the standard Bouncy Castle provider once during application startup:
import java.security.Security;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
public final class PQCSetup {
private PQCSetup() {}
static {
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
}
The examples below use provider name BC. They do not register the legacy BCPQC provider.
ML-KEM Key Encapsulation (FIPS 203)
ML-KEM lets a sender encapsulate a newly generated shared secret to a receiver’s public key. The receiver decapsulates it with the private key. Applications then feed that shared secret into an approved key-derivation and symmetric-encryption design; ML-KEM does not encrypt arbitrary application data by itself.
Parameter sets:
- ML-KEM-512: NIST security category 1
- ML-KEM-768: NIST security category 3
- ML-KEM-1024: NIST security category 5
Choose a parameter set from your threat model, protocol, compliance requirements, and interoperability target. Do not select ML-KEM-1024 solely because it has the largest number.
Generate an ML-KEM key pair
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.SecureRandom;
import java.security.Security;
import org.bouncycastle.jcajce.spec.MLKEMParameterSpec;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
public class MLKEMKeyGen {
public static void main(String[] args) throws Exception {
Security.addProvider(new BouncyCastleProvider());
KeyPairGenerator generator =
KeyPairGenerator.getInstance("ML-KEM", "BC");
generator.initialize(
MLKEMParameterSpec.ml_kem_768,
new SecureRandom()
);
KeyPair keyPair = generator.generateKeyPair();
System.out.println(keyPair.getPublic().getAlgorithm());
}
}
Encapsulate and decapsulate on Java 21+
The Java KEM API was introduced in Java 21. This example asks Bouncy Castle to derive a 256-bit AES key from ML-KEM-768.
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.security.Security;
import javax.crypto.KEM;
import javax.crypto.SecretKey;
import org.bouncycastle.jcajce.spec.KTSParameterSpec;
import org.bouncycastle.jcajce.spec.MLKEMParameterSpec;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
public class MLKEMExample {
public static void main(String[] args) throws Exception {
Security.addProvider(new BouncyCastleProvider());
KeyPairGenerator generator =
KeyPairGenerator.getInstance("ML-KEM", "BC");
generator.initialize(
MLKEMParameterSpec.ml_kem_768,
new SecureRandom()
);
KeyPair receiver = generator.generateKeyPair();
KTSParameterSpec keySpec =
new KTSParameterSpec.Builder("AES", 256).build();
KEM kem = KEM.getInstance("ML-KEM", "BC");
KEM.Encapsulator encapsulator =
kem.newEncapsulator(receiver.getPublic(), keySpec, null);
KEM.Encapsulated encapsulated = encapsulator.encapsulate();
KEM.Decapsulator decapsulator =
kem.newDecapsulator(receiver.getPrivate(), keySpec);
SecretKey recovered =
decapsulator.decapsulate(encapsulated.encapsulation());
boolean match = MessageDigest.isEqual(
encapsulated.key().getEncoded(),
recovered.getEncoded()
);
System.out.println("Shared keys match: " + match);
}
}
If your runtime does not expose javax.crypto.KEM, use Bouncy Castle’s current lightweight ML-KEM API or upgrade the runtime. Avoid copying examples built around the deprecated KyberKEMGenerator classes.
ML-DSA Digital Signatures (FIPS 204)
ML-DSA signs and verifies data. It can support application signatures, documents, software artifacts, and custom protocols, but adopting it in JWT, X.509, or code-signing ecosystems still depends on format and verifier support.
Parameter sets:
- ML-DSA-44: NIST security category 2
- ML-DSA-65: NIST security category 3
- ML-DSA-87: NIST security category 5
Generate, sign, and verify
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.SecureRandom;
import java.security.Security;
import java.security.Signature;
import org.bouncycastle.jcajce.spec.MLDSAParameterSpec;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
public class MLDSAExample {
public static void main(String[] args) throws Exception {
Security.addProvider(new BouncyCastleProvider());
KeyPairGenerator generator =
KeyPairGenerator.getInstance("ML-DSA", "BC");
generator.initialize(
MLDSAParameterSpec.ml_dsa_65,
new SecureRandom()
);
KeyPair keyPair = generator.generateKeyPair();
byte[] message = "Post-quantum signed".getBytes(StandardCharsets.UTF_8);
Signature signer = Signature.getInstance("ML-DSA", "BC");
signer.initSign(keyPair.getPrivate());
signer.update(message);
byte[] signature = signer.sign();
Signature verifier = Signature.getInstance("ML-DSA", "BC");
verifier.initVerify(keyPair.getPublic());
verifier.update(message);
System.out.println("Signature valid: " + verifier.verify(signature));
}
}
Key and Signature Sizes
PQC migration affects storage, transport, certificate chains, and message limits. The values below are the raw sizes defined by the standards; encoded Java keys include format overhead.
| Algorithm | Public key | Private key | Signature or ciphertext |
|---|---|---|---|
| ML-DSA-44 | 1,312 bytes | 2,560 bytes | 2,420-byte signature |
| ML-DSA-65 | 1,952 bytes | 4,032 bytes | 3,309-byte signature |
| ML-DSA-87 | 2,592 bytes | 4,896 bytes | 4,627-byte signature |
| ML-KEM-512 | 800 bytes | 1,632 bytes | 768-byte ciphertext |
| ML-KEM-768 | 1,184 bytes | 2,400 bytes | 1,088-byte ciphertext |
| ML-KEM-1024 | 1,568 bytes | 3,168 bytes | 1,568-byte ciphertext |
Measure the encoded objects your application actually sends. Base64, JSON, certificates, and protocol framing add overhead beyond the raw algorithm values.
Storing PQC Keys
getEncoded() commonly produces X.509 SubjectPublicKeyInfo for public keys and PKCS #8 for private keys. Restore them with the standardized algorithm name:
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
KeyFactory factory = KeyFactory.getInstance("ML-DSA", "BC");
PublicKey publicKey = factory.generatePublic(
new X509EncodedKeySpec(storedPublicKeyBytes)
);
PrivateKey privateKey = factory.generatePrivate(
new PKCS8EncodedKeySpec(storedPrivateKeyBytes)
);
Do not assume that keys generated with pre-standard Dilithium or Kyber identifiers can be relabeled as ML-DSA or ML-KEM. Inventory and test legacy key material separately.
Hybrid Designs Need a Protocol
Using a classical and a post-quantum algorithm together can reduce migration risk, but simply concatenating two signatures or two secrets is not automatically a secure, interoperable protocol. Define:
- how both algorithms and parameter sets are identified
- how keys, signatures, or shared secrets are encoded and combined
- whether verification requires both components
- downgrade protection and failure behavior
- versioning and key rotation
For TLS 1.3, use standardized protocol work instead of inventing an application combiner. RFC 10024 defines X25519MLKEM768, SecP256r1MLKEM768, and SecP384r1MLKEM1024. It also obsoletes the earlier experimental Kyber draft groups.
TLS Support Is Separate from Primitive Support
Adding bcprov gives an application cryptographic primitives. It does not automatically make Java’s JSSE, Bouncy Castle JSSE, a reverse proxy, browsers, and remote peers negotiate the same post-quantum TLS group.
Before enabling hybrid TLS, verify all of the following for the exact versions you deploy:
- the TLS implementation supports the RFC 10024 group
- the group is enabled by policy and configuration
- the client and server negotiate it successfully
- middleboxes and observability tooling tolerate the larger handshake
- fallback does not silently defeat your migration objective
Primitive support, TLS implementation support, and interoperable deployment are three separate checks.
Oracle JDK October 2026 status: primitives versus hybrid TLS
Oracle JDK 25.0.5, released October 20, adds TLS 1.3 hybrid key exchange using X25519MLKEM768, SecP256r1MLKEM768 and SecP384r1MLKEM1024. Oracle documents X25519MLKEM768 as enabled and preferred by default when the peer also supports the group. This is a concrete JSSE implementation update, not merely the presence of a KEM provider.
| Oracle runtime | Verified distinction |
|---|---|
| JDK 25.0.5 | Hybrid TLS 1.3 groups are implemented; peer compatibility and policy still determine negotiation |
| JDK 21.0.13 | October release includes ML-KEM/ML-DSA primitive support; do not infer hybrid TLS from that alone |
| JDK 17.0.21 | October release includes ML-KEM/ML-DSA primitive support; do not infer hybrid TLS from that alone |
The 21.0.13 and 17.0.21 release notes establish the current primitive backports. Oracle’s LTS roadmap separately schedules hybrid TLS for JDK 21/17 in the first half of 2027. A roadmap is not shipped support, and other JDK vendors can have different backport schedules.
For the deployed Oracle build, enable javax.net.debug=ssl:handshake in a controlled diagnostic environment and inspect the ServerHello key_share group. Do not expose sensitive handshake logs publicly. Hybrid key agreement does not make the certificate’s signature post-quantum or establish FIPS validation for the whole application.
Performance and Testing
Do not copy universal sub-millisecond benchmarks into a capacity plan. Results depend on CPU, JVM warm-up, provider version, parameter set, entropy source, concurrency, and encoding work.
Benchmark these operations in your environment:
- key generation
- encapsulation and decapsulation
- signing and verification
- serialized key and message sizes
- allocation rate and garbage collection under concurrency
- provider startup and warm-up
Test failure paths as well as round trips. A useful suite includes altered ciphertext, altered signatures, wrong keys, truncated encodings, unknown parameter sets, provider absence, and migration from any stored legacy keys.
Common Pitfalls
- Using obsolete examples:
KYBER,DILITHIUM,KyberParameterSpec, andDilithiumParameterSpecare not the right names for new standardized integrations. - Adding a nonexistent extra dependency: current PQC implementations are in
bcprov-jdk18on; do not addbcpqc-jdk18onbased on old snippets. - Registering the wrong provider: current examples use
BouncyCastleProviderand provider nameBC. - Confusing API availability with provider support: the Java platform can define an API or standard name even when a particular runtime/provider combination does not implement it.
- Treating primitive support as TLS support: provider-level ML-KEM does not automatically enable RFC 10024 in your TLS stack.
- Ignoring compliance boundaries: ordinary BC, BC-FJA, algorithm validation, and an application’s overall compliance status are distinct questions.
- Sharing mutable engines across threads: create or pool
KeyPairGeneratorandSignatureinstances according to their documented thread-safety behavior.
Migration Checklist
- Inventory JDK versions, security providers, stored keys, protocols, and verifiers.
- Upgrade to a supported Bouncy Castle release and pin the dependency.
- Use
ML-KEMandML-DSAnames in new code. - Test encoded keys and messages with every system that consumes them.
- Benchmark the exact provider, JDK, hardware, and parameter sets you plan to deploy.
- Design cryptographic agility and rollback without permitting silent downgrade.
- Treat hybrid TLS as a protocol deployment, not a provider toggle.
- Recheck compliance requirements against the exact module and algorithm certificate.
Frequently Asked Questions
Is Bouncy Castle’s PQC implementation production-ready?
Bouncy Castle implements the finalized NIST algorithms, but production readiness depends on your provider version, protocol design, key handling, interoperability testing, patching process, and compliance requirements. An implementation claim is not the same as validation of your deployed system.
What Java version do I need?
The bcprov-jdk18on artifact supports Java 8 and later. The javax.crypto.KEM example in this guide targets Java 21 or later. Java 24 added standard ML-KEM and ML-DSA parameter constants, but you must still confirm which provider supplies the implementation on your runtime.
Can I use ML-KEM for TLS in Java?
Yes, but not merely by registering bcprov. RFC 10024 now standardizes three hybrid ML-KEM groups for TLS 1.3. Your exact Java TLS implementation and the peer must support and enable the same group. Confirm this with an interoperability test rather than inferring it from application-level ML-KEM support.
Should I use ML-KEM-512, ML-KEM-768, or ML-KEM-1024?
Choose according to the consuming protocol and security requirements. ML-KEM-768 is a common balanced choice and is part of the widely targeted X25519MLKEM768 TLS group, but it is not a universal default for every application.
Can I combine Bouncy Castle PQC with Spring Boot?
Yes. Register the provider once during startup and keep cryptographic policy in a dedicated service. Avoid repeatedly changing the global provider list per request.
Sources
- Bouncy Castle Java 1.86 release: current release and security fixes
- Bouncy Castle PQC Almanac: provider examples for ML-KEM and ML-DSA
- Bouncy Castle ML-KEM parameter API: current parameter names
- NIST FIPS 203: ML-KEM standard
- NIST FIPS 204: ML-DSA standard
- Java KEM API: Java’s provider-based KEM interface
- Java NamedParameterSpec: standard ML-KEM and ML-DSA constants added in Java 24
- RFC 10024: standardized post-quantum/traditional hybrid groups for TLS 1.3
- RFC 9935: standardized ML-KEM key identifiers and the incompatibility with pre-standard Kyber