đź’ˇ Full working example available on GitHub: sign-pdf-with-vault-managed-key-java

The Old Way Was Painful

The runbook for a Java signing service tends to contain a step nobody likes writing down: copy signing.pfx to the new host, then put the password in the environment. From that moment the private key exists in at least three more places than anyone intended. It is in the image layer, in the backup of the host, and in the memory of the JVM that loads it, where a heap dump will happily show it to whoever has the dump.

External signing is a GroupDocs.Signature capability for Java that hands the document digest to a callback you provide instead of signing with a private key the application loads. It is the piece that lets you delete that runbook step. The signing key moves into HashiCorp Vault, the service keeps a token instead of a key, and the only value that crosses the wire during signing is a 32-byte hash.

Teams reach for this when a review asks the obvious question - who else can sign in your name - and the honest answer is “anyone who ever had access to that file”. Locking the file down with permissions and an encrypted volume narrows the window; it does not remove the copy.

There’s a Better Way

Vault’s Transit engine does cryptography as a service. You send it a value, it signs with a key it holds, and it returns the result. A key created inside Transit can be marked non-exportable, so there is nothing to copy onto a host in the first place, and Vault does not store what you send it.

That only helps if the signing library can delegate, which is where GroupDocs.Signature comes in. DigitalSignOptions.setCustomSignHash takes a callback. The library does everything up to the cryptography: it opens the PDF, works out the byte range the signature covers, hashes it, calls your callback with the digest, then embeds whatever bytes come back together with the certificate.

You need JDK 8 or later, Maven, Docker for a local Vault, and BouncyCastle for the certificate. The sample runs with GroupDocs.Signature for Java 26.10; without a licence, evaluation mode accepts documents of up to 2 pages.

The New Way: a Callback Instead of a Key File

Step 1 - Read the key from Vault

The service asks Vault for the key it is allowed to use and gets a version number with a public key. The private half is never in the response.

VaultKey key = vault.readKey(KEY_NAME);
if (key == null) {
    vault.enableTransit();
    vault.createKey(KEY_NAME, "rsa-2048");
    key = vault.readKey(KEY_NAME);
}

A 404 from Vault means either the key or the Transit mount is missing, so the sample treats it as first-run setup instead of an error. In production an administrator enables Transit and creates the key ahead of time, and the application’s token is not allowed to do either.

Read the version once and keep it. A certificate holds exactly one public key, so the certificate and every signature have to come from the same version.

Step 2 - Build a certificate around the public half

A PDF signature carries an X.509 certificate so that a verifier can find the public key. The JDK has no way to build a certificate whose signature comes from elsewhere, but BouncyCastle does: X509v3CertificateBuilder delegates to a ContentSigner, and a ContentSigner backed by Vault is about fifteen lines.

@Override
public byte[] getSignature() {
    try {
        byte[] digest =
            MessageDigest.getInstance("SHA-256").digest(data.toByteArray());
        return vault.signHash(keyName, keyVersion, digest, "sha2-256");
    } catch (IOException e) {
        throw new UncheckedIOException(e);
    }
}

BouncyCastle writes the to-be-signed bytes into the signer’s stream and then asks for the signature. The bytes are hashed locally and only the digest goes to Vault. The interface declares no checked exceptions, so a Vault failure has to travel out wrapped and be unwrapped by the caller; skip that and a 403 from Vault surfaces as a puzzling runtime error instead.

Step 3 - Sign, then verify in the same run

DigitalSignature digitalSignature = new DigitalSignature();
digitalSignature.setX509Certificate(certificate);

DigitalSignOptions options = new DigitalSignOptions();
options.setSignature(digitalSignature);
options.setCustomSignHash(signer::customSignHash);
options.setHashAlgorithm(HashAlgorithm.Sha256);

ByteArrayOutputStream signedPdf = new ByteArrayOutputStream();
signature.sign(signedPdf, options);
Files.write(outputPdf, trimTrailingZeros(signedPdf.toByteArray()));

Inside the callback the digest goes to transit/sign with two fields that decide whether the result is valid: prehashed = true, because the input is already a hash, and signature_algorithm = pkcs1v15, because Vault defaults to PSS for RSA keys while a PDF signature is sha256WithRSAEncryption.

Then verify before the file goes anywhere. Signing and verifying in one run is what turns the only silent failure mode into an immediate one.

Side-by-Side: Before vs. After

PFX on the host Vault Transit callback
Copies of the private key one per host, image and backup one, inside Vault
What the service holds the key and its password a token scoped to two operations
Deploy step for a new host copy the PFX, set the password nothing
Record of each signature application logs, if written one Vault audit entry
After a suspected leak revoke, re-issue, redeploy everywhere rotate in Vault, re-issue once
Who can sign as you anyone with the file and password anyone with a valid Vault token

The last row is worth stating plainly: this moves the secret rather than abolishing it. The difference is that a token is short-lived, revocable in one place, limited to reading one key and calling sign, and logged every time it is used.

Real-World Example: a Statement Service

A service renders monthly statements and signs each one before dropping it in a customer portal. With the old arrangement its container image carried the certificate, and three people had the password because somebody had to rotate it. With the callback, the image carries no secret at all: the pod gets an AppRole token at startup, the policy in vault/sample-signer.hcl allows reading the key and signing with it, and anything else the token tries returns 403 permission denied.

When the auditor asks how many documents were signed in March and with which key version, the answer comes from Vault’s audit device rather than from grepping application logs.

Rotation is where this design earns its keep, and also where it can bite. After transit/keys/:name/rotate Vault signs with the newest version unless you say otherwise, so a certificate built for version 1 combined with a signature from version 2 yields a PDF that reports no error and does not verify. Because the signer is constructed with one version, that cannot happen by accident here: the statement service keeps signing with the version its certificate belongs to until somebody issues a new certificate for the new version. Setting min_encryption_version on the key afterwards makes the old version unusable, which Vault reports as a refusal to sign rather than as a bad signature.

What quirks should I expect on version 26.10?

Four, all found while running this build. The ICustomSignHash method name is obfuscated in the jar, so pass a method reference rather than implementing the interface. The signed output arrives with trailing zero bytes outside the signature, so trim them. DigitalVerifyOptions wants a certificate file, not a stream. And the visible signature box needs Arial installed.

What Else Can You Do with GroupDocs.Signature?

The callback has no idea what is behind it, so the same three steps work against a hardware module, a smart card or a cloud KMS. I ported this from the .NET and Python versions of the same sample, and the Vault request body came over unchanged; the certificate construction and those four quirks are the only genuinely Java-shaped parts. Beyond digital signatures the library also adds and searches text, image, barcode, QR code and metadata signatures across PDF, Word, Excel, PowerPoint and image formats.

Conclusion

Signing a PDF in Java without holding the private key takes a callback, a certificate built around a public key, and a token with two rights. The sample runs against a dev-mode Vault in two commands, and the same code points at a production Vault by changing VAULT_ADDR and VAULT_TOKEN.

Additional Resources