Skip to main content

PEM Encodings in Java (JEP 538/542): Read and Write Keys and Certificates Without BouncyCastle

JDK 27 adds PEMDecoder and PEMEncoder (JEP 538, third preview; final in JDK 28 as JEP 542). Tested on JDK 27 and a JDK 28 early-access build: loading RSA, EC and Ed25519 keys for JWT signing, building a TLS server from PEM files, encrypted private keys, every failure message, and what changes when you drop BouncyCastle.

If you have ever needed to load an RSA key to sign a JWT, or a certificate chain to start a TLS server, you have met the same wall: your key is a text file that begins with -----BEGIN PRIVATE KEY-----, and Java’s standard library has no one-line way to read it. So Java developers pasted the same dozen lines from Stack Overflow — strip the header, strip the newlines, Base64-decode, wrap in PKCS8EncodedKeySpec, pick a KeyFactory — or they added BouncyCastle, a large third-party dependency, for one method call. JDK 27 adds the missing piece: java.security.PEMDecoder and java.security.PEMEncoder. This article starts from what a PEM file actually is, shows the smallest working program, then spends most of its length on what goes wrong — because the API is small and the surprises are in the files you feed it. Every output block below comes from a real run of the companion code, pem-api in the javademos repository.
Versions and one correction. Tested on Temurin 27.0.1 (JDK 27 reached GA on 15 September 2026) and on the JDK 28 early-access build 28-ea+21 (22 October 2026). Comparison libraries: BouncyCastle bcpkix-jdk18on 1.86, Spring Security 7.1.2 (spring-security-core), checked in Spring Boot 4.1.2. Keys made with OpenSSL 3.0.13.

JEP 538 vs JEP 542. These are the same API at two stages, not two features. JEP 538 is the third preview in JDK 27, so on JDK 27 you must compile and run with --enable-preview. JEP 542 finalizes it “without further change” and targets JDK 28 (general availability is scheduled for 9 March 2027). The history is JEP 470 (JDK 25), JEP 524 (JDK 26), JEP 538 (JDK 27), JEP 542 (JDK 28). I ran JDK 28 only as an early-access build, so “final in 28” below means “works without the flag in 28-ea+21”, not “shipped”.

What a PEM file actually is

A key or certificate has a binary encoding — a byte string defined by a standard. Binary does not survive email, copy-paste or a text editor, so the bytes are turned into Base64 text and wrapped in two marker lines. That wrapper is PEM, defined in RFC 7468. The line after BEGIN is a label that says what kind of object the bytes encode.
What is in the file-----BEGIN PUBLIC KEY-----MIIBIjANBgkqhkiG9w0BAQEFAAOC...AQ8AMIIBCgKCAQEAybOBX3YVfWzr...(64 Base64 characters per line)-----END PUBLIC KEY-----blue: the label, which says what kind of objectgreen: Base64 of the binary (DER) encodingWhat the JDK does with it1. read the label: PUBLIC KEY2. Base64-decode the body to bytes3. pick a factory, build the objectresult: a java.security.PublicKeyThe label decides the Java type. Everything between the dashes is only Base64, which is whyPEM text can be pasted into an email, an environment variable or a YAML file without damage.
The diagram shows the two halves of the job. The label is the only information the reader has about the type, so a decoder must look at it first; the body is just bytes. Notice what the label does not tell you: whether the key is RSA or EC. That is inside the bytes, and the JDK finds it there. The labels you will meet, and what the JDK 27 decoder returns for each (each row is something I ran against a real file, except the CRL row, which comes from the JEP text):
LabelMeaningDecoder result
PUBLIC KEYpublic key, X.509 SubjectPublicKeyInfoPublicKey
PRIVATE KEYprivate key, PKCS#8 (any algorithm)PrivateKey, or KeyPair when the file also holds the public key
ENCRYPTED PRIVATE KEYPKCS#8, password-protectedEncryptedPrivateKeyInfo (a PrivateKey if you supply the password)
CERTIFICATEX.509 certificateX509Certificate
X509 CRLrevocation lista CRL object (per the JEP; not run here)
RSA PRIVATE KEYold OpenSSL RSA format (PKCS#1)PrivateKey — works, but not when encrypted
EC PRIVATE KEYold OpenSSL EC format (SEC1)PEM — not decoded

The smallest program that works

Two entry points, both static factories: PEMDecoder.of() and PEMEncoder.of(). Decoding takes text and, optionally, the class you expect. Encoding takes any key, certificate or key pair and returns text. The snippets use a three-method helper class, Fx (read a fixture file, print a section header, assert), in src/Fx.java. (Per javap on JDK 27, PEMDecoder has decode for a String or an InputStream, plus withDecryption(char[]) and withFactoriesOf(Provider); PEMEncoder has encodeToString, encode and withEncryption(char[]).)
PEMDecoder decoder = PEMDecoder.of();
PEMEncoder encoder = PEMEncoder.of();

Fx.h("decode a public key, a private key and a certificate");
String pubText = Fx.read("rsa-public.pem");
PublicKey pub = decoder.decode(pubText, PublicKey.class);
System.out.println(pub.getAlgorithm() + " " + pub.getFormat() + " " + ((RSAPublicKey) pub).getModulus().bitLength() + " bits");

PrivateKey priv = decoder.decode(Fx.read("rsa-private-pkcs8.pem"), PrivateKey.class);
System.out.println(priv.getAlgorithm() + " " + priv.getFormat());

X509Certificate cert = decoder.decode(Fx.read("server.pem"), X509Certificate.class);
System.out.println(cert.getSubjectX500Principal() + " issued by " + cert.getIssuerX500Principal());

Source: src/RoundTrip.java. Run with java --enable-preview on JDK 27. Output, from output/01-round-trip.txt:

== decode a public key, a private key and a certificate
RSA X.509 2048 bits
RSA PKCS#8
CN=localhost issued by CN=pem-api demo CA
The three input files were written by OpenSSL, not by Java. That matters: it shows the decoder reads what the rest of the world writes. Writing goes the other way, and the check in the program is that encoding the decoded object gives back the original text:
== encode them again: the same text openssl wrote, apart from the line terminator
PEMEncoder line terminator is CRLF: true; the openssl file uses LF: true
ok: raw text differs from the file (CRLF vs LF)
ok: public key re-encodes to identical text once CRLF is normalised to LF
ok: private key re-encodes to identical text (LF-normalised)
ok: certificate re-encodes to identical text (LF-normalised)
The encoder writes CRLF line endings. The first line of that output is the surprise: PEMEncoder separates lines with \r\n, while OpenSSL on Linux writes \n. The text is identical once the line terminators are normalised, and decoders accept either (the decode of CRLF text works, shown in the failure section). But if you commit generated PEM files to git, compare them byte-for-byte or hash them, expect a diff against files made by other tools. Strip \r if you need stable files.
PEM textin a Stringdecode(text)returns BinaryEncodabledecode(text, PrivateKey.class)returns exactly that typePrivateKey, PublicKey or KeyPairX509CertificateEncryptedPrivateKeyInfoPEM (label not understood: not decoded)your type, or ClassCastExceptionUntyped decode answers "what is in this file?" and the answer can be several types, so a cast or apattern-matching switch is needed. The typed form says "I need a PrivateKey" and fails loudly if thefile holds anything else. Use the typed form for configuration you load at start-up.
Here is the second thing worth knowing before any real code. decode(text) with no class returns whatever the file contains, as a BinaryEncodable (the common interface; it was called DEREncodable in the earlier previews). decode(text, SomeClass.class) returns that type or throws. The untyped output on real files:
== what does decode(String) with no target class hand back?
rsa-public.pem -> PublicKey RSA
rsa-private-pkcs8.pem -> PrivateKey RSA
ed25519-private.pem -> PrivateKey EdDSA
server.pem -> X509Certificate CN=localhost
rsa-private-encrypted.pem -> EncryptedPrivateKeyInfo PBEWithHmacSHA256AndAES_256

That is the end of the happy path. Everything from here on is about files that do not look like these.

Going deeper: what “preview” means for your build A preview API is part of the JDK but disabled unless you opt in, and class files that use it are stamped so they only run on the exact JDK feature release that compiled them. The transcript output/07-preview-flag.txt shows the three situations. Compiling on JDK 27 without the flag:
$ javac RoundTrip.java Fx.java   (no flags)     (27.0.1.0.2+1)
src/Fx.java:27: error: PEM is a preview API and is disabled by default.
        if (o instanceof java.security.PEM p) return "PEM(type=" + p.type() + ") - NOT decoded";
                                      ^
  (use --enable-preview to enable preview APIs)
Running a class compiled with the flag but launched without it:
$ java RoundTrip   (compiled with --enable-preview, run without it)     (27.0.1.0.2+1)
Error: LinkageError occurred while loading main class RoundTrip
	java.lang.UnsupportedClassVersionError: Preview features are not enabled for RoundTrip (class file version 71.65535). Try running with '--enable-preview'
The practical consequence: do not ship a preview-API class to a JDK 28 runtime. Class file version 71.65535 means “JDK 27 preview”; you recompile for 28, where (per JEP 542) the flag is no longer needed. Maven needs <compilerArgs><arg>--enable-preview</arg></compilerArgs> and --enable-preview in the surefire argLine; I did not run that Maven configuration (the demos use plain javac), so treat it as the documented shape rather than a tested one.

What goes wrong first: the file is not what you thought

Almost every failure with this API is the same failure: the file contains a different kind of object than the code assumed. The decoder is strict about it. Asking for a class the file cannot produce throws ClassCastException, with a message that names both types:
Fx.h("the file holds a different kind of object than the class you asked for");
Fx.tryIt("certificate as PublicKey", () -> d.decode(Fx.read("server.pem"), PublicKey.class));
Fx.tryIt("private key as PublicKey", () -> d.decode(Fx.read("rsa-private-pkcs8.pem"), PublicKey.class));
Fx.tryIt("RSA private key as KeyPair", () -> d.decode(Fx.read("rsa-private-pkcs8.pem"), KeyPair.class));

Source: src/Failures.java. Output, from output/05-failures.txt:

== the file holds a different kind of object than the class you asked for
certificate as PublicKey !! java.lang.ClassCastException: Cannot cast sun.security.x509.X509CertImpl to java.security.PublicKey
private key as PublicKey !! java.lang.ClassCastException: Cannot cast sun.security.rsa.RSAPrivateCrtKeyImpl to java.security.PublicKey
RSA private key as KeyPair !! java.lang.ClassCastException: Cannot cast sun.security.rsa.RSAPrivateCrtKeyImpl to java.security.KeyPair
Read the second half of each message: sun.security.rsa.RSAPrivateCrtKeyImpl is an RSA private key, so a private key file asked for as PublicKey fails. The usual real-world cause is a configuration property pointing at the wrong file, or a certificate file where a bare public key was expected — the first line of the output. The untyped trap. Now the one that costs an afternoon. Both of these files hold a private key; ask the untyped decoder:
== untyped decode: the result type depends on what the file contains
RSA PKCS#8 -> PrivateKey RSA
EC PKCS#8 (openssl embeds the public key) -> KeyPair
EC PKCS#8, asked for PrivateKey -> PrivateKey EC
EC PKCS#8, asked for KeyPair -> KeyPair
OpenSSL writes EC private keys in PKCS#8 with the public key included, so the untyped decoder returns a KeyPair, not a PrivateKey. Code written as (PrivateKey) decoder.decode(text) works with an RSA key in testing and throws ClassCastException when someone deploys an EC key. The typed call decode(text, PrivateKey.class) handles both — it extracts the private half from a KeyPair — which is why the rest of this article uses it.
The fingerprint of the old OpenSSL formats. A file starting -----BEGIN EC PRIVATE KEY----- (SEC1, what openssl ecparam -genkey and older tools write) is not decoded. The decoder does not fail; it returns a raw PEM object holding the undecoded bytes, and the typed call fails with Cannot cast java.security.PEM to java.security.PrivateKey. Convert the file once with openssl pkcs8 -topk8 -nocrypt -in ec-private-sec1.pem -out ec-private-pkcs8.pem. The migration section below proves the converted key has the same secret value as the SEC1 original.

RSA PRIVATE KEY (PKCS#1) does decode. The asymmetry is easy to miss: RSA keys in the old format work, EC keys in the old format silently do not.
== the old OpenSSL formats
RSA PRIVATE KEY (PKCS#1) -> PrivateKey RSA
EC PRIVATE KEY (SEC1), untyped -> PEM(type=EC PRIVATE KEY) - NOT decoded
EC PRIVATE KEY (SEC1), asked for PrivateKey !! java.lang.ClassCastException: Cannot cast java.security.PEM to java.security.PrivateKey
as a PEM object: type=EC PRIVATE KEY, decoded bytes=121
Malformed text throws IllegalArgumentException from the String overloads (the wrapped cause names the problem) and IOException from the InputStream overloads. The messages are short and identifiable:
== malformed text
empty string !! java.lang.IllegalArgumentException: java.io.EOFException: No data available
no PEM in the text !! java.lang.IllegalArgumentException: java.io.EOFException: No PEM data found
footer missing !! java.lang.IllegalArgumentException: java.io.EOFException: Incomplete header
body is not Base64 of a key !! java.lang.IllegalArgumentException: java.io.IOException: No recognized algorithm detected in encoding
Windows line endings (CRLF) -> PublicKey RSA
comment lines before the header -> PublicKey RSA
Two of those lines are good news: CRLF text and a comment line before the header both decode. Real files from Windows machines and from tools that add a leading note will load. One more behaviour that will surprise anyone loading a certificate bundle: decode(String) returns the first object in the text and ignores the rest.
== several objects in one string: only the first is returned
decode(String) on a 2-certificate bundle -> CN=localhost
To read all of them, use the InputStream overload in a loop, as the TLS section does.
Going deeper: why the decoder returns a KeyPair at all A PKCS#8 file can carry an optional public key next to the private one (this is the “version 2” form of the structure the JEP calls PKCS#8 v2.0). OpenSSL includes it for EC keys; it does not for RSA or Ed25519. When it is present, the decoder has more information than a bare PrivateKey can hold, so the untyped call returns the richer KeyPair. The reverse also holds: encoding a KeyPair writes the public key into the PKCS#8 body, so the private key’s own encoding is no longer byte-for-byte what it was. The demo asserts this (transcript 01): the private scalar and public key survive the round trip, but getEncoded() of the decoded private key differs from the original’s.

Loading a key to sign JWTs

The most common reason to read a PEM key in a Spring Security application is JWT signing: an RSA or EC private key signs tokens, the public key verifies them. The key arrives as a file, a Kubernetes secret or an environment variable — all of which are PEM text.
private.pem(PKCS#8 text)PEMDecoderPrivateKey.classSignatureRS256 / ES256 / EdDSAheader.payload.signatureSHA256withECDSADER: 0x30 ... , 70 to 72 bytesa JWT library on the other side rejects itSHA256withECDSAinP1363FormatR then S, always 64 byteswhat RFC 7518 requires for ES256The PEM side is three lines. The part that bites is the signature algorithm name: both ECDSA namesproduce a valid signature, only one produces a valid JWT.
The demo does not use a JWT library on purpose, so the only moving part is the key. It builds the header and payload, loads the private key from PEM, signs with java.security.Signature and verifies with the public key, also from PEM.
PrivateKey rsaPriv = d.decode(Fx.read("rsa-private-pkcs8.pem"), PrivateKey.class);
PublicKey rsaPub = d.decode(Fx.read("rsa-public.pem"), PublicKey.class);
String in = signingInput("RS256");
byte[] sig = sign("SHA256withRSA", rsaPriv, in);
String jwt = in + "." + B64.encodeToString(sig);
System.out.println("header.payload = " + in);
System.out.println("signature bytes = " + sig.length + ", jwt parts = " + jwt.split("\\.").length);
Fx.check(verify("SHA256withRSA", rsaPub, in, sig), "RS256 signature verifies with the public PEM");

Source: src/JwtSigning.java. Output, from output/02-jwt-signing.txt:

== RS256 with an RSA key from PEM
header.payload = eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbmt1ciIsImlzcyI6Imh0dHBzOi8vYW5rdXJtLmNvbSJ9
signature bytes = 256, jwt parts = 3
ok: RS256 signature verifies with the public PEM
RSA is the easy case. EC is where people get a token that looks right and that every other system rejects. A JWT signed with ES256 must carry the signature as the two integers R and S joined into 64 bytes (RFC 7518). Java’s default ECDSA signature is a DER structure of variable length, 70 to 72 bytes. Both verify inside Java; only one is a JWT.
byte[] der = sign("SHA256withECDSA", ecPriv, in2);
byte[] raw = sign("SHA256withECDSAinP1363Format", ecPriv, in2);
== ES256 with an EC key from PEM (the trap is the signature format)
SHA256withECDSA              -> DER, starts 0x30, length varies run to run, 70-72: true   (JWT readers reject this)
SHA256withECDSAinP1363Format -> R||S, length 64   (what RFC 7518 requires for ES256)
ok: ES256 signature is exactly 64 bytes
ok: ES256 signature verifies with the public PEM
The ES256 signature-length check is worth a unit test. If a downstream service says “invalid signature” for tokens that your own code verifies happily, count the bytes. 64 means P1363 format, correct. 70, 71 or 72 means DER, wrong for JWT. Ed25519 keys decode the same way (EdDSA in the output above); that PKCS#8 file holds no public key, so the demo only signs and does not verify.
What about Spring Security? Spring Security 7.1.2 ships org.springframework.security.converter.RsaKeyConverters, with exactly two static methods: pkcs8() and x509(). They read RSA keys only, and they only read the PRIVATE KEY and PUBLIC KEY labels. I ran both against the same fixtures:
== 6. Spring Security RsaKeyConverters (RSA only; PKCS#8 and X.509 only)
ok: pkcs8() reads the RSA private key
ok: x509() reads the RSA public key
pkcs8() on an EC key !! java.lang.IllegalArgumentException: java.security.spec.InvalidKeySpecException: java.security.InvalidKeyException: Invalid RSA private key
pkcs8() on the PKCS#1 key !! java.lang.IllegalArgumentException: Key is not in PEM-encoded PKCS#8 format, please check that the header begins with -----BEGIN PRIVATE KEY-----
JDK on the EC key -> PrivateKey EC
Same files, same keys, and the JDK decoder is the only one that handles the EC key. If your service signs with EC or EdDSA, or your private key arrives as BEGIN RSA PRIVATE KEY, that converter is not an option and PEMDecoder is. I did not wire the decoded keys into NimbusJwtEncoder or NimbusJwtDecoder for this article; the keys are standard java.security objects, which is what those classes consume, but that integration is documented behaviour here, not something executed.
Going deeper: Spring Boot has its own PEM parser (and it does not call the JDK one) Spring Boot’s spring.ssl.bundle.pem.* properties (documented in the Boot reference; I did not run a Boot application for this article) load PEM certificates and keys for TLS. Inspecting the spring-boot 4.1.2 jar shows the package org.springframework.boot.ssl.pem with its own PemPrivateKeyParser, PemCertificateParser and a PKCS#8 decryptor, and none of those classes reference PEMDecoder. So Boot SSL bundles keep working on JDKs where the PEM API is a disabled preview — you do not need --enable-preview just to use spring.ssl.bundle.pem. You need the JDK API only where your code reads PEM: a JWT key bean, a custom trust store, a PEM from a secrets manager. The related mTLS setup is in Securing Spring Boot Microservices: Token Relay, Service-to-Service JWT and mTLS.

Building a TLS server from three PEM files

A certificate chain and its private key are the other half of everyday PEM work. The classic route is to convert them to a PKCS12 file with openssl pkcs12 -export and then load that. With the PEM API you can skip the conversion: decode the key and the certificates, put them in an in-memory KeyStore, and hand it to the TLS machinery.
server-key.pemserver-chain.pemca.pemPEMDecoderPrivateKeyX509Certificate x2X509CertificateKeyStoreKeyManagerFactorySSLContextTrustManagerHttpsServerport 0HttpClientNo keytool and no PKCS12 file on disk: three PEM files go in, a working TLS handshake comes out.The client trusts only ca.pem, so the handshake succeeds because the chain ends at that certificate;the same request with the JVM default trust store fails (shown below).
The diagram shows two independent pipelines. The server side needs a private key and the chain presented to clients (leaf first, then the CA). The client side needs only the CA certificate, as its trust anchor. Reading the chain file is the one place a loop is needed, because the String overload returns only the first certificate:
static List<Certificate> readAll(String pemText) throws Exception {
    List<Certificate> out = new ArrayList<>();
    try (InputStream in = new java.io.ByteArrayInputStream(pemText.getBytes())) {
        PEMDecoder d = PEMDecoder.of();
        while (true) {
            try { out.add(d.decode(in, X509Certificate.class)); }
            catch (EOFException end) { break; }
        }
    }
    return out;
}

Then the server side, nine lines from PEM to SSLContext:

PrivateKey key = d.decode(Fx.read("server-key.pem"), PrivateKey.class);
char[] pw = new char[0];
KeyStore ks = KeyStore.getInstance("PKCS12");
ks.load(null, null);
ks.setKeyEntry("server", key, pw, chain.toArray(new Certificate[0]));
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(ks, pw);
SSLContext serverCtx = SSLContext.getInstance("TLS");
serverCtx.init(kmf.getKeyManagers(), null, null);

Source: src/TlsFromPem.java. Output, from output/03-tls.txt:

== read a two-certificate chain file
  CN=localhost
  CN=pem-api demo CA
ok: the stream decoder returns the two certificates one by one
== server side: key + chain -> KeyStore -> KeyManagerFactory -> SSLContext
== client trusting only the CA certificate from ca.pem
HTTP 200 hello over TLS  (TLSv1.3)
server presented: CN=localhost
ok: request over TLS succeeds
== same request with the JVM's default trust store (the CA is not in it)
The last two lines are the control experiment. The same server, the same request, but a client that does not know the demo CA, fails the handshake with the standard PKIX path building failed message. That message in your own logs means “the client does not trust the CA that signed this certificate”, nothing to do with PEM parsing.
javax.net.ssl.SSLHandshakeException: (certificate_unknown) PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
These certificates are throw-away. The CA, the server certificate and every key in the repository were generated for this demo (fixtures/README.md) and protect nothing. Never commit real private keys to a repository, and do not copy a demo CA into a production trust store.
Going deeper: order matters, and so does the password Two details the demo glosses. First, the chain order: the leaf certificate must come first in the array given to setKeyEntry, with each following certificate signing the one before it; the demo’s file is leaf then CA. Second, the char[] pw in the demo is empty because the in-memory KeyStore never touches disk, so the protection is moot; if you ever serialise that store, use a real password. For production you would usually not include the root CA in the chain a server sends at all, since clients already hold it — the demo does because it is the only way to show a two-object file being read.
  • Source: src/TlsFromPem.java.
  • Hot-reloading rotated certificates is a separate topic; this demo reads the files once at start-up.

Encrypted private keys

A private key on disk should be encrypted. The PEM label for that is ENCRYPTED PRIVATE KEY: a PKCS#8 structure whose body is the key encrypted with a password. The encoder produces it when you ask:
String enc = e.withEncryption(PW).encodeToString(key);
System.out.println(enc.lines().filter(l -> l.startsWith("-----")).toList());
EncryptedPrivateKeyInfo info = (EncryptedPrivateKeyInfo) d.decode(enc);
System.out.println("default algorithm: " + info.getAlgName());
PBEParameterSpec spec = info.getAlgParameters().getParameterSpec(PBEParameterSpec.class);
System.out.println("default iteration count: " + spec.getIterationCount() + ", salt bytes: " + spec.getSalt().length);

Fx.h("read it back");
Fx.check(d.withDecryption(PW).decode(enc, PrivateKey.class).equals(key), "withDecryption(password) returns the original key");

Source: src/EncryptedKeys.java. Output, from output/04-encrypted.txt:

== write an encrypted key with the defaults
[-----BEGIN ENCRYPTED PRIVATE KEY-----, -----END ENCRYPTED PRIVATE KEY-----]
default algorithm: PBEWithHmacSHA256AndAES_128
default iteration count: 4096, salt bytes: 20
== read it back
ok: withDecryption(password) returns the original key
Note the defaults, because they decide how hard an attacker has to work on a stolen file: AES-128 with HMAC-SHA256 key derivation and 4096 iterations. A 4096-iteration derivation is fast for you and also fast for someone guessing passwords. The OpenSSL file in this repository (openssl pkcs8 -topk8 -v2 aes-256-cbc) used AES-256. If you want stronger parameters, choose them explicitly with EncryptedPrivateKeyInfo.encrypt and encode the result:
PBEParameterSpec custom = new PBEParameterSpec(new SecureRandom().generateSeed(16), 600_000);
EncryptedPrivateKeyInfo strong = EncryptedPrivateKeyInfo.encrypt(key, PW, "PBEWithHmacSHA512AndAES_256", custom, null);
String strongPem = e.encodeToString(strong);
EncryptedPrivateKeyInfo again = (EncryptedPrivateKeyInfo) d.decode(strongPem);
System.out.println(again.getAlgName() + ", iterations " + again.getAlgParameters().getParameterSpec(PBEParameterSpec.class).getIterationCount());
Fx.check(again.getKey(PW).equals(key), "getKey(password) on the decoded EncryptedPrivateKeyInfo works too");
== choose the algorithm and cost yourself
PBEWithHmacSHA512AndAES_256, iterations 600000
ok: getKey(password) on the decoded EncryptedPrivateKeyInfo works too
600,000 iterations is my choice for the demo, not a JDK recommendation; pick the number from current guidance for your threat model and measure the unlock time. Now the failure modes. These are the messages you will actually see:
== failures
no password, ask for PrivateKey !! java.lang.ClassCastException: Cannot cast javax.crypto.EncryptedPrivateKeyInfo to java.security.PrivateKey
no password, no class -> EncryptedPrivateKeyInfo PBEWithHmacSHA256AndAES_256
wrong password !! javax.crypto.CryptoException: java.security.InvalidKeyException: Cannot retrieve the PKCS8EncodedKeySpec
password on a key that is not encrypted -> PrivateKey RSA
openssl 'traditional' encrypted (Proc-Type: 4,ENCRYPTED) !! java.lang.IllegalArgumentException: java.security.InvalidKeyException: Invalid PKCS#1 encoding
Four lessons in five lines. Forgetting the password on a typed call is a ClassCastException that says EncryptedPrivateKeyInfo cannot be cast to PrivateKey — the file is fine, you just have not unlocked it. A wrong password is a CryptoException, a new class in JEP 538 for cryptographic failures, whose message does not say “wrong password”: Cannot retrieve the PKCS8EncodedKeySpec is what a bad password looks like. Supplying a password for an unencrypted key is accepted without complaint. And the last line is the other half of the old-format story.
The “traditional” encrypted format is not supported. A file with Proc-Type: 4,ENCRYPTED and DEK-Info: headers inside the PEM (what openssl rsa -traditional -aes256 writes, and what many older tutorials produce) fails with Invalid PKCS#1 encoding — the decoder tries to read the encrypted bytes as if they were an RSA key. Convert it once: openssl pkcs8 -topk8 -v2 aes-256-cbc -in old.pem -out new.pem. BouncyCastle reads the old format; the migration section shows it.
Going deeper: handling the password safely The API takes char[] rather than String so you can overwrite the array after use; the demo does not bother because the password is a literal in a demo. In an application, read it from a secrets manager or an environment variable, keep it out of logs, and note that PEMDecoder.withDecryption(char[]) returns a new decoder holding the password — do not cache that decoder in a long-lived static field you do not control. The same applies to withEncryption. I did not test whether the decoder copies the array, so clear your own copy only after you are done with the decoder.

Migrating from BouncyCastle

If your codebase already reads PEM, it probably does so with BouncyCastle’s PEMParser. The migration is mostly mechanical, with four places where behaviour differs. I wrote the same jobs against BouncyCastle 1.86 and the JDK in one program (src/Migration.java) and let the program assert that the results are equal.
JobBouncyCastle (bcpkix)JDK 27 / 28
Read a PKCS#8 private keyPEMParser → PrivateKeyInfo → JcaPEMKeyConverterdecode(text, PrivateKey.class)
Read a certificatePEMParser → X509CertificateHolder → JcaX509CertificateConverterdecode(text, X509Certificate.class)
Write a public keyJcaPEMWriterencodeToString(key)
Write an RSA private keyJcaPEMWriter → RSA PRIVATE KEY (PKCS#1)PEMEncoder → PRIVATE KEY (PKCS#8)
Read encrypted PKCS#8JceOpenSSLPKCS8DecryptorProviderBuilder + BC providerwithDecryption(password)
Read EC PRIVATE KEY (SEC1)works (PEMKeyPair)not decoded, convert first
Read Proc-Type: 4,ENCRYPTEDworks (PEMEncryptedKeyPair)not supported, convert first
PrivateKey viaBc = conv.getPrivateKey((PrivateKeyInfo) bcParse(Fx.read("rsa-private-pkcs8.pem")));
PrivateKey viaJdk = jdk.decode(Fx.read("rsa-private-pkcs8.pem"), PrivateKey.class);
Fx.check(viaBc.equals(viaJdk), "BouncyCastle and the JDK produce equal keys");

Source: src/Migration.java. Output, from output/06-migration.txt:

== 1. load an RSA private key (PKCS#8)
ok: BouncyCastle and the JDK produce equal keys
BC parser returned: PrivateKeyInfo
== 2. load a certificate
ok: same certificate either way
== 3. write keys
ok: JcaPEMWriter and PEMEncoder write identical public-key text (line terminators aside)
JcaPEMWriter on an RSA private key writes: -----BEGIN RSA PRIVATE KEY-----
PEMEncoder on an RSA private key writes:   -----BEGIN PRIVATE KEY-----
The first two checks pass: same key, same certificate. The write check shows both libraries produce the same public-key text, once line terminators are ignored. The last two lines are the first behavioural difference: BouncyCastle writes an RSA private key as BEGIN RSA PRIVATE KEY, the JDK as BEGIN PRIVATE KEY. They are different encodings of the same key. Anything that reads the file you write — an old library, a script using openssl rsa, a legacy appliance — might require one specifically. Most modern readers accept PKCS#8, but check before swapping the writer in a pipeline that other systems consume.
== 4. encrypted PKCS#8 key (BC needs its own provider for AES/CBC/PKCS7Padding; the JDK does not)
ok: same key either way
== 5. what BouncyCastle reads and the JDK does not
EC PRIVATE KEY (SEC1): BC -> PEMKeyPair, JDK -> PEM(type=EC PRIVATE KEY) - NOT decoded
ok: after 'openssl pkcs8 -topk8 -nocrypt' the JDK reads the same secret scalar BC read from SEC1
Proc-Type: 4,ENCRYPTED: BC -> PEMEncryptedKeyPair decrypted to RSA
ok: ...and it is the same RSA key
Proc-Type: 4,ENCRYPTED: JDK !! java.lang.IllegalArgumentException: java.security.InvalidKeyException: Invalid PKCS#1 encoding
That is the second and third differences, in one block. The two old OpenSSL formats that BouncyCastle reads and the JDK does not are both real-world common, so inventory your key files before deleting the dependency. The converted EC key reads back with the same secret value (the “same secret scalar” line), so a one-time openssl pkcs8 -topk8 conversion loses nothing. The fourth difference is not in the output: BouncyCastle is still the answer for things the JEP does not cover. Its goals are keys, certificates and certificate revocation lists in PKCS#8, X.509 and PKCS#8 v2.0 encrypted form, so certificate signing requests and CMS/PKCS#7 content, for example, stay with a library.
A migration test that costs ten minutes. Before removing BouncyCastle, run every PEM file your application loads in production (keys, trust bundles, test fixtures) through PEMDecoder and assert the result is not a raw PEM and does not throw. That one loop finds every SEC1 and Proc-Type file. The comparison program in the repository is a working template.
Going deeper: what dropping BouncyCastle gets and costs you Gets: one fewer dependency to patch (and to explain to a security reviewer), no provider registration (the BouncyCastle decrypt call above needed new BouncyCastleProvider() to find AES/CBC/PKCS7Padding; my first attempt without it failed with Cannot find any provider supporting AES/CBC/PKCS7Padding, the JDK needs no such step), and code that reads as one call. Costs: the preview flag on JDK 27, the two unsupported formats, and the PKCS#1-versus-PKCS#8 write difference. If you must support JDK 21 or 25 LTS and cannot use preview features, you keep BouncyCastle or hand-rolled parsing until your baseline reaches 28; I found no mention of a backport in the JEPs. The Java 25 LTS has the first preview of this API, under different class names (PEMRecord, DEREncodable, per the JEP histories; I did not run it).

Does it change in JDK 28?

JEP 542 says it finalizes the API “without further change”. I tested that claim on the JDK 28 early-access build: the same sources, compiled without --enable-preview and run without it, and the output of each demo diffed against the JDK 27 run.

Output, from output/08-jdk28-final.txt:

$ javac src/{Fx,RoundTrip,JwtSigning,TlsFromPem,EncryptedKeys,Failures}.java   (no flags)     (28-ea+21-1707)
exit=0

$ java RoundTrip   (no flags)
exit=0
output identical to the JDK 27 run
The first line is the compile without any flag; it succeeds. The rest of that file repeats the pattern for every demo that uses only the JDK API (the BouncyCastle comparison is excluded because it carries extra jars), and each one reports output identical to the JDK 27 run. This is an early-access build, so it shows the API is unchanged and unflagged today; it does not replace the GA release in March 2027. Plan: write your code against JDK 27 with the flag, and when 28 ships, drop the flag and recompile.
  • Source: run.sh (the JDK28 branch).

Should you use this now?

Yes for new code on JDK 27 or 28; wait on a preview API in production. If your runtime is JDK 27 you must accept the preview flag, and class files compiled with it will not load on another JDK. If your runtime is Java 21 or 25 LTS, this API is not available as a final feature and you should keep what you have. The right moment for most teams is when JDK 28 is released in March 2027 and your build is ready to move off 25, because then the API is stable and the flag disappears. Before you remove BouncyCastle, check your files for the two unsupported formats. And if your application reads PEM only through Spring Boot’s spring.ssl.bundle.pem or through Spring Security’s RSA converters, you have no reason to change anything today.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.