Before you start

JEP 538 is a third preview in JDK 27. Compile and run with —enable-preview. Do not ship preview bytecode.

You need a JDK 27 build. Confirm the toolchain first:

java --version
javac --version

Preview must be enabled at compile time and run time, with source and target 27:

javac --enable-preview --release 27 PemLab.java
java --enable-preview PemLab

For scratch experiments, jshell --enable-preview works the same way. This post teaches the JDK 27 shape only: PEMEncoder, PEMDecoder, BinaryEncodable, and PEM in java.security. If an older tutorial talks about DEREncodable or PEMDecoder.withFactory(...), it is describing a superseded preview.

This is not a PKI course. You will not mint a CA, design a trust store, or walk X.509 extensions. The job is narrower: turn PEM text you already have into the JDK types you already use, and write those types back out.

The problem it solves

TLS files on disk, Kubernetes tls.crt / tls.key secrets, a Let’s Encrypt chain emailed as .pem — PEM is the format everyone already uses. The JDK has long had the objects (PrivateKey, X509Certificate, X509CRL) and a Base64 encoder. The glue was missing.

Encoding a public key is a header, Base64 with 64-column wraps, and a footer. Decoding is worse: split the fences, pick a KeyFactory by algorithm, handle encrypted PKCS#8, and hope the input is one object rather than a chain. That tedium is why code reached for BouncyCastle — or for sun.security.util types that were never a public contract.

JEP 538 puts the glue in java.security. Two immutable, reusable helpers sit over a sealed BinaryEncodable marker:

PEMDecoder decoder = PEMDecoder.of();
X509Certificate cert = decoder.decode(pem, X509Certificate.class);

CertificateFactory could already parse PEM certificates. Keys, encrypted keys, CRLs, and encoding were the holes. This API closes them with the same two types.

When it shipped

ReleaseStatusSpec
Java 25First previewJEP 470
Java 26Second previewJEP 524
Java 27Third previewJEP 538

Still a preview on Java 27. --enable-preview is required on javac and java. The types live in java.base, so there is no module to require and no dependency to declare.

Note: The third preview renamed and reshaped members. DEREncodable is now BinaryEncodable. PEMDecoder.withFactory is now withFactoriesOf. PEM is an ordinary class, not a record. Encryption and decryption failures now throw CryptoException. Code written against the JDK 25 or 26 preview will not compile unchanged.

Mental model

PEM, per RFC 7468, is Base64 of a binary encoding, wrapped in BEGIN / END fences that name the type. The Java objects already knew how to become that binary. BinaryEncodable is the sealed interface that says so.

ConceptWhat it isExamples
BinaryEncodableMarker for objects that have a standard binary formAsymmetricKey, KeyPair, X509Certificate, X509CRL, EncryptedPrivateKeyInfo, PKCS8EncodedKeySpec, X509EncodedKeySpec, PEM
PEMEncoderImmutable encoder: object → PEM textof(), encode, encodeToString, withEncryption
PEMDecoderImmutable decoder: PEM text → objectof(), decode, withDecryption, withFactoriesOf
PEMCatch-all for types with no JDK classPKCS#10 certification requests, plus any leading bytes before the header

Encoders and decoders are thread-safe and retain nothing from the last call. Configure encryption or a provider by calling withEncryption / withDecryption / withFactoriesOf — each returns a new instance, leaving the original unchanged.

The fence type decides the default Java class:

PEM headerdecode(text) returns
CERTIFICATEX509Certificate
X509 CRLX509CRL
PUBLIC KEYPublicKey
PRIVATE KEYPrivateKey, or KeyPair if the encoding also carries a public key
ENCRYPTED PRIVATE KEYEncryptedPrivateKeyInfo (or a decrypted key if the decoder has a password)
Anything elsePEM

Pass a Class when you already know the type. A mismatch throws ClassCastException.

The sealed list is not exhaustive. A switch over BinaryEncodable needs a default (or a case BinaryEncodable) so later subtypes do not break compilation.

Encode a key pair

Start with a throwaway EC pair so there is nothing to steal from disk. KeyPairGenerator is ordinary JDK API; PEMEncoder only wraps the result.

import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PEMEncoder;
import java.security.spec.ECGenParameterSpec;

public class EncodeKeys {
    public static void main(String[] args) throws Exception {
        KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
        kpg.initialize(new ECGenParameterSpec("secp256r1"));
        KeyPair pair = kpg.generateKeyPair();

        PEMEncoder encoder = PEMEncoder.of();
        System.out.println(encoder.encodeToString(pair.getPublic()));
        System.out.println(encoder.encodeToString(pair.getPrivate()));
    }
}
java --enable-preview EncodeKeys.java

The public key comes back as an SPKI block. The private key is PKCS#8. Headers and 64-column wrapping are the encoder’s job:

-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...
-----END PUBLIC KEY-----

encode(...) returns the same text as byte[] in ISO-8859-1, which is what you want when writing a file. Encoding a KeyPair (both halves) uses PKCS#8 v2.0 OneAsymmetricKey and a PRIVATE KEY fence — OpenSSL and other stacks can round-trip that.

Read a PEM file into a Key and a Certificate

This is the Kubernetes / TLS-files case. Assume a directory with tls.crt and tls.key. decode(InputStream, Class) reads one PEM object from the stream; decode(String, Class) is the in-memory twin.

import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.PEMDecoder;
import java.security.PrivateKey;
import java.security.cert.X509Certificate;

public class LoadTlsMaterial {
    public static void main(String[] args) throws Exception {
        PEMDecoder decoder = PEMDecoder.of();
        Path dir = Path.of(args[0]);

        X509Certificate cert;
        try (InputStream in = Files.newInputStream(dir.resolve("tls.crt"))) {
            cert = decoder.decode(in, X509Certificate.class);
        }

        PrivateKey key = decoder.decode(
                Files.readString(dir.resolve("tls.key")),
                PrivateKey.class);

        System.out.println(cert.getSubjectX500Principal());
        System.out.println(key.getAlgorithm());
    }
}
java --enable-preview LoadTlsMaterial.java ./tls
CN=lab
EC

Bytes on an InputStream are interpreted as ISO-8859-1. Data before the first BEGIN line is ignored — unless you decode to PEM.class, which preserves it as leadingData().

Note: One decode call returns one object. A CA bundle is several CERTIFICATE blocks. Loop on the stream until it is exhausted, or keep CertificateFactory.generateCertificates for the bundle-only case. PEMDecoder is the unified path when the same code also has to handle keys.

If you do not already have files, mint a one-day lab pair with OpenSSL and stop there — the rest of this post stays in Java:

openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
    -keyout tls/tls.key -out tls/tls.crt -days 1 -nodes -subj "/CN=lab"

That openssl x509 -in tls.crt -noout -subject line you already know prints the same subject the Java snippet printed. The point of JEP 538 is that you no longer shell out, or paste fences around cert.getEncoded(), to get there.

Decode when the type is not known

Pass no Class and pattern-match the result. This is the shape for a file that might be a key or a cert:

PEMDecoder decoder = PEMDecoder.of();
switch (decoder.decode(Files.readString(path))) {
    case PublicKey publicKey -> System.out.println("public " + publicKey.getAlgorithm());
    case PrivateKey privateKey -> System.out.println("private " + privateKey.getAlgorithm());
    case X509Certificate cert -> System.out.println(cert.getSubjectX500Principal());
    case X509CRL crl -> System.out.println("crl " + crl.getIssuerX500Principal());
    default -> throw new IllegalArgumentException("unsupported PEM");
}

Unparseable PEM throws IllegalArgumentException. Keep the default — BinaryEncodable will grow.

If you need a specific KeyFactory or CertificateFactory provider, configure it once and reuse the decoder:

PEMDecoder decoder = PEMDecoder.of().withFactoriesOf(provider);
X509Certificate cert = decoder.decode(pem, X509Certificate.class);

A provider that cannot produce the requested type throws IllegalArgumentException.

Encrypt a private key

withEncryption(char[]) returns a new encoder that writes ENCRYPTED PRIVATE KEY. The password array is cloned into that instance. Only PrivateKey, KeyPair, and PKCS8EncodedKeySpec are legal inputs; anything else throws IllegalArgumentException.

char[] password = "lab-only".toCharArray();
String pem = PEMEncoder.of()
        .withEncryption(password)
        .encodeToString(pair.getPrivate());

PrivateKey restored = PEMDecoder.of()
        .withDecryption(password)
        .decode(pem, PrivateKey.class);

A decoder configured with withDecryption still decodes unencrypted objects. Decode an encrypted block without a password and you get EncryptedPrivateKeyInfo instead of a key — decrypt later with getKey or getKeyPair:

EncryptedPrivateKeyInfo epki =
        PEMDecoder.of().decode(pem, EncryptedPrivateKeyInfo.class);
PrivateKey key = epki.getKey(password);

The default password-based encryption algorithm is PBEWithHmacSHA256AndAES_128, from the jdk.epkcs8.defaultAlgorithm security property. The PEM text stores the algorithm name and parameters, so a later default change does not strand existing files. For a non-default algorithm or provider, call EncryptedPrivateKeyInfo.encrypt(...) yourself and pass the result to PEMEncoder.encode.

Encryption and decryption failures throw CryptoException, a runtime exception added in this preview. Wipe your password copy when you are done; the encoder’s clone is its own problem.

Catch-all: the PEM class

When the fence type has no JDK object — a PKCS#10 CERTIFICATE REQUEST is the usual example — decode returns a PEM. You can also ask for PEM.class when you want the Base64 payload and any leading commentary:

PEM raw = PEMDecoder.of().decode(text, PEM.class);
System.out.println(raw.type());      // e.g. CERTIFICATE REQUEST
byte[] der = raw.decode();           // Base64 payload, decoded

PEMEncoder will write a PEM back out without validating the content. That is the escape hatch, not the happy path for keys and certs.

Gotchas

Preview names moved. JDK 25/26 samples that import DEREncodable or call withFactory will not compile on 27. Use BinaryEncodable and withFactoriesOf.

PEM is no longer a record. Accessors are still type(), content(), leadingData(), and decode(), but construction is via constructors that accept Base64 as String or byte[].

One block per decode. A concatenated bundle is a loop, not a single call. Empty trailing data after the last fence is fine; a second fence is another object.

Wrong Class is ClassCastException. That is distinct from malformed PEM (IllegalArgumentException) and from a bad password (CryptoException).

withEncryption is not for certificates. Configuring encryption and then encoding an X509Certificate throws. Encrypt private key material only.

Do not reach for sun.security.util. The internal PEM helpers existed because this API did not. They are not a supported alternative on JDK 27, and they will not track the preview.

It is not BouncyCastle. OpenSSL-specific BEGIN RSA PRIVATE KEY (PKCS#1) and OpenSSH key files are outside RFC 7468’s PKIX set. If you must ingest those, you still need a library that speaks them — or convert them to PUBLIC KEY / PRIVATE KEY first.

Cheat sheet

Java 27 / JEP 538: third preview — --enable-preview --release 27
Package: java.security (java.base)

Entry point
  PEMEncoder encoder = PEMEncoder.of();
  PEMDecoder decoder = PEMDecoder.of();

Encode
  String pem = encoder.encodeToString(keyOrCert);
  byte[] pem = encoder.encode(keyOrCert);          // ISO-8859-1 bytes
  encoder.withEncryption(password).encodeToString(privateKey);

Decode
  BinaryEncodable obj = decoder.decode(pem);       // or decode(InputStream)
  X509Certificate c = decoder.decode(pem, X509Certificate.class);
  PrivateKey k = decoder.decode(in, PrivateKey.class);
  decoder.withDecryption(password).decode(pem, PrivateKey.class);
  decoder.withFactoriesOf(provider);

EncryptedPrivateKeyInfo
  encrypt(key, password) then PEMEncoder.encode(...)
  getKey(password) / getKeyPair(password)

PEM catch-all: type(), content(), leadingData(), decode()

Fence → type
  CERTIFICATE              X509Certificate
  X509 CRL                 X509CRL
  PUBLIC KEY               PublicKey
  PRIVATE KEY              PrivateKey or KeyPair
  ENCRYPTED PRIVATE KEY    EncryptedPrivateKeyInfo
  other                    PEM

Exceptions
  IllegalArgumentException  bad PEM / unsupported combo
  ClassCastException        Class argument does not match
  CryptoException           encrypt / decrypt failure (runtime)
  IOException               decode(InputStream) I/O

Renames from 25/26: DEREncodable → BinaryEncodable
                    withFactory → withFactoriesOf
                    PEM is a class, not a record

Do:

  • Share one PEMDecoder.of() / PEMEncoder.of() per component; they are immutable and thread-safe.
  • Pass the Class you want (X509Certificate.class, PrivateKey.class) when the file’s type is known.
  • Keep a default on every BinaryEncodable switch.
  • Use withEncryption / EncryptedPrivateKeyInfo for private keys at rest.

Don’t:

  • Compile or run without --enable-preview on JDK 27.
  • Copy DEREncodable or withFactory from a JDK 25 tutorial.
  • Treat one decode as a certificate chain.
  • Import sun.security PEM helpers, or assume PKCS#1 / OpenSSH files are in scope.
  • Ship this preview to production.

Wrap-up

The PEM API is a plumbing fix. The JDK already had keys, certificates, and CRLs; what it lacked was a supported way to read and write the text format every TLS file and Kubernetes secret already uses. PEMEncoder and PEMDecoder are that way, over a small sealed BinaryEncodable set, with PEM as the catch-all and CryptoException for cryptographic failure.

Reach for it when you load tls.crt / tls.key, persist a generated key pair, or stop dragging BouncyCastle into a service that only needed fences around getEncoded(). Keep --enable-preview on until JEP 538 finalizes — three previews in three releases is the signal that names can still move.

Next optional step in the series Hybrid key exchange on TLS before the quantum deadline. Post-Quantum TLS: Hybrid Key Exchange Before the Quantum Deadline