Signing with Apache PDFBox & EU DSS

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

A signature on a PDF is a CMS container stored in a placeholder that was reserved inside the file. Two libraries that are free of the iText licence can produce it. Apache PDFBox is a general PDF library, licensed under Apache-2.0, that reserves the placeholder, computes the byte range and writes the incremental update, and leaves the CMS container to you. The European Commission’s Digital Signature Service (EU DSS, licensed under LGPL-2.1) is a framework that builds the container, the timestamps and the validation data for you and produces PAdES baseline signatures at all four levels, using PDFBox underneath to write the file. This page shows both with the Bookshelf Billing invoice: a signature with a callback, a signature with the key outside the process, a visible signature, a signature timestamp and a document timestamp, validation data for long-term validation, the DSS equivalent of each step, and the case where the PDF was produced by JasperReports and has to be signed afterwards. Versions are stated as of 2026-10-09: Apache PDFBox 3.0.8, EU DSS 6.5, Bouncy Castle 1.86 and JasperReports 7.0.8 on Java 21. Every Java example on this page was compiled and run against exactly those versions, and the results were read back and verified; the section "What was run, and what was not" says what that does and does not prove.

What a signature is, why the file is signed in an incremental update, and how to read /ByteRange and /Contents, are in Digital Signatures: Fundamentals, which also owns the complete script that creates the test certificates used here. The four PAdES levels and the standards behind them are explained in PAdES Baseline Levels. The same flows with iText are in Signing PDFs with iText. Keeping the private key in an HSM, a key management service or a remote signing service is in Remote, HSM & Deferred Signing, and checking what was signed, and keeping a signature valid for years, is in Signature Validation & Long-Term Validity.

PDFBox or EU DSS

The two libraries are not rivals. PDFBox is the file layer and DSS is the signature layer built on it, so the decision is how much of the signature layer you want to write yourself.

Apache PDFBox 3.0.8 EU DSS 6.5

Licence

Apache-2.0

LGPL-2.1

What it gives you

Placeholder, /ByteRange, /Contents, incremental save, appearance streams, the hook SignatureInterface

Complete PAdES B-B, B-T, B-LT and B-LTA, extension of existing signatures, document timestamps, validation

What you write

The CMS container (Bouncy Castle), the signature timestamp, the validation data

Choice of parameters, a token, a TSP source, a certificate verifier

Dependencies

pdfbox plus Bouncy Castle (bcpkix-jdk18on)

Around ten modules; you must pick implementations for utilities, CMS and CRL parsing

Typical use

Simple approval signatures, signing in an existing PDFBox pipeline, learning how it works

Signatures that must reach B-LT or B-LTA, or be validated against the EU trusted lists

Both are an alternative to iText 9, which is released under the AGPLv3 or under a commercial licence (see iText Add-ons for how that licence is applied). iText’s PdfPadesSigner does the work of the DSS service in one call; PDFBox alone does less than iText’s low-level PdfSigner. If the documents leave the company and the AGPL network clause is a problem, one of the two libraries of this page is the usual answer, and DSS is the one that gives the baseline levels.

Setup: coordinates and test certificates

PDFBox signing needs pdfbox (which brings pdfbox-io and fontbox) and Bouncy Castle’s bcpkix-jdk18on for the CMS classes. PDFBox 3.0.8 declares Bouncy Castle 1.84 in its own build; the examples on this page used 1.86, the current version as of 2026-10-09, and the one place where that mattered is described in the section on validation data. DSS 6.5 is split in many modules, and the surprising part is that some are mandatory choices: without an implementation of the utilities, of CMS generation and of CRL parsing on the classpath the service fails at start-up. The artifacts below are the set that was needed, and each of the three failures that taught this was an exception that names the alternatives: No implementation found for IUtils in classpath, please choose between dss-utils-apache-commons or dss-utils-google-guava, No implementation found for CMSGenerator in the classpath, please choose between 'dss-cms-object' or 'dss-cms-stream'! and No implementation found for ICRLUtils in classpath, please choose between dss-crl-parser-stream or dss-crl-parser-x509crl.

<properties>
    <maven.compiler.release>21</maven.compiler.release>
    <dss.version>6.5</dss.version>
</properties>
<dependencies>
    <!-- Apache PDFBox, Apache-2.0 -->
    <dependency>
        <groupId>org.apache.pdfbox</groupId>
        <artifactId>pdfbox</artifactId>
        <version>3.0.8</version>
    </dependency>
    <dependency>
        <groupId>org.bouncycastle</groupId>
        <artifactId>bcpkix-jdk18on</artifactId>
        <version>1.86</version>
    </dependency>

    <!-- EU DSS, LGPL-2.1: the PAdES service, written on top of PDFBox -->
    <dependency>
        <groupId>eu.europa.ec.joinup.sd-dss</groupId>
        <artifactId>dss-pades-pdfbox</artifactId>
        <version>${dss.version}</version>
    </dependency>
    <dependency>
        <groupId>eu.europa.ec.joinup.sd-dss</groupId>
        <artifactId>dss-token</artifactId>
        <version>${dss.version}</version>
    </dependency>
    <dependency>
        <groupId>eu.europa.ec.joinup.sd-dss</groupId>
        <artifactId>dss-service</artifactId>
        <version>${dss.version}</version>
    </dependency>
    <!-- implementations that DSS looks up at start-up: pick one of each pair -->
    <dependency>
        <groupId>eu.europa.ec.joinup.sd-dss</groupId>
        <artifactId>dss-utils-apache-commons</artifactId>
        <version>${dss.version}</version>
    </dependency>
    <dependency>
        <groupId>eu.europa.ec.joinup.sd-dss</groupId>
        <artifactId>dss-cms-object</artifactId>
        <version>${dss.version}</version>
    </dependency>
    <dependency>
        <groupId>eu.europa.ec.joinup.sd-dss</groupId>
        <artifactId>dss-crl-parser-x509crl</artifactId>
        <version>${dss.version}</version>
    </dependency>
    <dependency>
        <groupId>org.slf4j</groupId>
        <artifactId>slf4j-simple</artifactId>
        <version>2.0.17</version>
    </dependency>
</dependencies>

The artifact names are those of dss-pades on Maven Central and of the DSS documentation; dss-pades itself arrives transitively through dss-pades-pdfbox. dss-cms-object keeps the CMS container in memory, while dss-cms-stream is meant for very large documents. dss-pades-openpdf is the alternative PDF layer on top of OpenPDF; it supports fewer features, for example no text and image together in a visible signature, according to the DSS documentation.

The examples reuse the naming convention of the section. The keystore billing-signer.p12 holds the private key under the alias billing-signer together with its certificate chain, billing-test-ca.p12 is the root CA that issued it, and billing-test-ca.pem is the CA certificate exported as text. The complete script is on Digital Signatures: Fundamentals; as a reminder, its core is the following, run with the store password in the environment variable BILLING_KEYSTORE_PASSWORD (a throw-away placeholder such as changeit-placeholder is fine for a test run, a real value never belongs in a file).

keytool -genkeypair -alias billing-test-ca -keyalg RSA -keysize 3072 -sigalg SHA256withRSA \
  -dname "CN=Bookshelf Billing Test CA, O=Bookshelf Test, C=ES" -validity 3650 \
  -ext bc:c -ext ku:c=keyCertSign,cRLSign \
  -keystore billing-test-ca.p12 -storetype PKCS12 -storepass:env BILLING_KEYSTORE_PASSWORD
keytool -genkeypair -alias billing-signer -keyalg RSA -keysize 3072 -sigalg SHA256withRSA \
  -dname "CN=Bookshelf Billing Signer, O=Bookshelf Test, C=ES" -validity 825 \
  -keystore billing-signer.p12 -storetype PKCS12 -storepass:env BILLING_KEYSTORE_PASSWORD
keytool -certreq -alias billing-signer -keystore billing-signer.p12 \
  -storepass:env BILLING_KEYSTORE_PASSWORD -file billing-signer.csr
keytool -gencert -alias billing-test-ca -keystore billing-test-ca.p12 -storepass:env BILLING_KEYSTORE_PASSWORD \
  -infile billing-signer.csr -outfile billing-signer.crt -rfc -validity 825 \
  -ext ku:c=digitalSignature,nonRepudiation -ext eku=emailProtection
keytool -exportcert -rfc -alias billing-test-ca -keystore billing-test-ca.p12 \
  -storepass:env BILLING_KEYSTORE_PASSWORD -file billing-test-ca.pem
keytool -importcert -noprompt -alias billing-test-ca -file billing-test-ca.pem \
  -keystore billing-signer.p12 -storepass:env BILLING_KEYSTORE_PASSWORD
cat billing-signer.crt billing-test-ca.pem > chain.pem
keytool -importcert -noprompt -alias billing-signer -file chain.pem \
  -keystore billing-signer.p12 -storepass:env BILLING_KEYSTORE_PASSWORD

The result of the last command is a PKCS#12 file whose billing-signer entry carries the chain signer certificate then CA certificate; keytool -list -v shows it as a certificate chain of length 2. Documentation for the tool is in the keytool reference for JDK 21. The first helper of the page loads the key and the chain, and the second creates a stand-in for the unsigned PDF with PDFBox itself, so that the examples need no other input; a JasperReports-generated file is used in its own section.

package billing;

import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;

/** The private key and certificate chain of the Bookshelf Billing test signer. */
public record SigningMaterial(PrivateKey privateKey, X509Certificate[] chain) {

    public static SigningMaterial load(Path pkcs12) throws IOException, GeneralSecurityException {
        char[] storePassword = System.getenv("BILLING_KEYSTORE_PASSWORD").toCharArray();
        KeyStore store = KeyStore.getInstance("PKCS12");
        try (InputStream in = Files.newInputStream(pkcs12)) {
            store.load(in, storePassword);
        }
        PrivateKey signerKey = (PrivateKey) store.getKey("billing-signer", storePassword);
        Certificate[] raw = store.getCertificateChain("billing-signer");
        X509Certificate[] chain = new X509Certificate[raw.length];
        for (int i = 0; i < raw.length; i++) {
            chain[i] = (X509Certificate) raw[i];
        }
        return new SigningMaterial(signerKey, chain);
    }

    public X509Certificate signerCertificate() {
        return chain[0];
    }
}
package billing;

import java.io.IOException;
import java.nio.file.Path;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;

/** Stand-in for the unsigned PDF that iText or JasperReports produces. */
public final class Invoice {

    private Invoice() {
    }

    public static void create(Path target, String number) throws IOException {
        try (PDDocument document = new PDDocument()) {
            PDPage page = new PDPage();
            document.addPage(page);
            try (PDPageContentStream content = new PDPageContentStream(document, page)) {
                content.beginText();
                content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 14);
                content.newLineAtOffset(72, 720);
                content.showText("Bookshelf Billing - invoice " + number);
                content.endText();
            }
            document.save(target.toFile());
        }
    }
}

Both examples derive from the PDFBox signing examples in CreateSignature.java, which loads a keystore and uses its first alias; the version above names the alias explicitly.

How PDFBox signs

The class PDSignature models the signature dictionary: its filter (Adobe.PPKLite), its SubFilter (adbe.pkcs7.detached or ETSI.CAdES.detached), the signer’s name, location, reason, contact information and signing date. PDFBox 3 declares the constants PDSignature.FILTER_ADOBE_PPKLITE, SUBFILTER_ADBE_PKCS7_DETACHED and SUBFILTER_ETSI_CADES_DETACHED, and SUBFILTER_ADBE_PKCS7_SHA1 is kept only for old files and must not be used for new signatures. The interface SignatureInterface has one method, sign(InputStream), that receives the bytes covered by /ByteRange and returns the encoded CMS container. Two methods of PDDocument use it.

  • addSignature(PDSignature, SignatureInterface, SignatureOptions) registers a signature and a callback. When you call saveIncremental(OutputStream), PDFBox writes the file with a zero-filled placeholder in /Contents, computes /ByteRange, calls your sign method with the covered bytes and patches the result into the file. This is the simple path, and it is the only one in which PDFBox produces the whole file.

  • addSignature(PDSignature) without a callback, followed by saveIncrementalForExternalSigning(OutputStream), returns an ExternalSigningSupport object. Its getContent() returns the covered bytes as a stream, and its setSignature(byte[]) patches the container in afterwards. Nothing forces the signing to happen inside the method that saved the file, which is what makes the path suitable for a signing service, a hardware token, or an approval step between the two calls.

The distinction between the two paths is only one of control flow. In both, the placeholder is a fixed number of bytes (the default reservation is around 9,500 bytes, and SignatureOptions.setPreferredSignatureSize changes it), and a container that does not fit fails the call. The container grows with every certificate that is embedded and with every timestamp, so a signature with a TSA token and a chain of three certificates needs more space than a bare B-B signature; the examples on this page reserve 18,000 bytes.

sequenceDiagram participant App as Billing service participant PB as PDFBox PDDocument participant Cms as CmsSigner (Bouncy Castle) participant Key as Private key or HSM App->>PB: addSignature(signature) and saveIncrementalForExternalSigning(out) PB-->>App: ExternalSigningSupport with the covered bytes App->>Cms: sign(content) Cms->>Key: sign the signed attributes Key-->>Cms: signature value Cms-->>App: CMS container (DER) App->>PB: setSignature(container) PB-->>App: signed PDF written to out

The diagram shows the external path. With the callback path the arrow from the application to CmsSigner is drawn by PDFBox itself inside saveIncremental.

The CMS container with Bouncy Castle

PDFBox does not build the container; the example below does, with Bouncy Castle’s CMSSignedDataGenerator. It signs with SHA256withRSA, embeds the whole chain, and adds the ESS signing-certificate-v2 attribute (RFC 5035) that the PAdES baseline requires in a ETSI.CAdES.detached signature, and which is the difference between a CMS signature and a CAdES signature. Without it the file holds a plain CMS signature under a CAdES SubFilter, which a PAdES validator may reject. When a TSA client is given, the class adds a signature timestamp as an unsigned attribute, which is the PAdES B-T level (the TSA client is shown in the section on timestamps). The content is read into memory with readAllBytes(); for files in the hundreds of megabytes, use a streaming CMSTypedData implementation, as the PDFBox example CMSProcessableInputStream does.

package billing;

import java.io.IOException;
import java.io.InputStream;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureInterface;
import org.bouncycastle.asn1.ASN1EncodableVector;
import org.bouncycastle.asn1.ASN1Primitive;
import org.bouncycastle.asn1.DERSet;
import org.bouncycastle.asn1.cms.Attribute;
import org.bouncycastle.asn1.cms.AttributeTable;
import org.bouncycastle.asn1.cms.CMSAttributes;
import org.bouncycastle.asn1.ess.ESSCertIDv2;
import org.bouncycastle.asn1.ess.SigningCertificateV2;
import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.cert.jcajce.JcaCertStore;
import org.bouncycastle.cms.CMSException;
import org.bouncycastle.cms.CMSProcessableByteArray;
import org.bouncycastle.cms.CMSSignedData;
import org.bouncycastle.cms.CMSSignedDataGenerator;
import org.bouncycastle.cms.DefaultSignedAttributeTableGenerator;
import org.bouncycastle.cms.SignerInformation;
import org.bouncycastle.cms.SignerInformationStore;
import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;

/**
 * Builds the detached CMS container that goes into /Contents. It is what PDFBox calls back
 * with the bytes covered by /ByteRange.
 */
public final class CmsSigner implements SignatureInterface {

    private final SigningMaterial material;
    private final TsaClient tsa; // null: no signature timestamp (B-B)

    public CmsSigner(SigningMaterial material, TsaClient tsa) {
        this.material = material;
        this.tsa = tsa;
    }

    @Override
    public byte[] sign(InputStream content) throws IOException {
        try {
            X509Certificate signer = material.signerCertificate();
            // PAdES baseline requires the signing-certificate-v2 attribute (ESS, RFC 5035)
            byte[] certDigest = MessageDigest.getInstance("SHA-256").digest(signer.getEncoded());
            ESSCertIDv2 certId = new ESSCertIDv2(new AlgorithmIdentifier(NISTObjectIdentifiers.id_sha256), certDigest);
            ASN1EncodableVector extra = new ASN1EncodableVector();
            extra.add(new Attribute(PKCSObjectIdentifiers.id_aa_signingCertificateV2,
                    new DERSet(new SigningCertificateV2(certId))));

            CMSSignedDataGenerator generator = new CMSSignedDataGenerator();
            generator.addSignerInfoGenerator(
                    new JcaSignerInfoGeneratorBuilder(new JcaDigestCalculatorProviderBuilder().build())
                            .setSignedAttributeGenerator(new DefaultSignedAttributeTableGenerator(
                                    new AttributeTable(extra)) {
                                @Override
                                @SuppressWarnings("rawtypes")
                                public AttributeTable getAttributes(Map parameters) {
                                    // PAdES baseline forbids the CMS signing-time attribute; the claimed time is /M
                                    return super.getAttributes(parameters).remove(CMSAttributes.signingTime);
                                }
                            })
                            .build(new JcaContentSignerBuilder("SHA256withRSA").build(material.privateKey()), signer));
            generator.addCertificates(new JcaCertStore(Arrays.asList(material.chain())));

            CMSSignedData signed = generator.generate(new CMSProcessableByteArray(content.readAllBytes()), false);
            if (tsa != null) {
                signed = addSignatureTimestamp(signed);
            }
            return signed.getEncoded();
        } catch (GeneralSecurityException | CMSException | OperatorCreationException e) {
            throw new IOException(e);
        }
    }

    /** B-T: the TSA token over the signature value goes into the unsigned attributes. */
    private CMSSignedData addSignatureTimestamp(CMSSignedData signed) throws IOException {
        List<SignerInformation> updated = new ArrayList<>();
        for (SignerInformation signerInfo : signed.getSignerInfos().getSigners()) {
            byte[] token = tsa.timestamp(signerInfo.getSignature());
            ASN1EncodableVector unsigned = new ASN1EncodableVector();
            if (signerInfo.getUnsignedAttributes() != null) {
                unsigned = signerInfo.getUnsignedAttributes().toASN1EncodableVector();
            }
            unsigned.add(new Attribute(PKCSObjectIdentifiers.id_aa_signatureTimeStampToken,
                    new DERSet(ASN1Primitive.fromByteArray(token))));
            updated.add(SignerInformation.replaceUnsignedAttributes(signerInfo, new AttributeTable(unsigned)));
        }
        return CMSSignedData.replaceSigners(signed, new SignerInformationStore(updated));
    }
}

One detail deserves attention. The official PDFBox example CreateSignatureBase builds the signer with cert.getSigAlgName(), which is the algorithm that the issuer used to sign the certificate. It is often, but not always, the right algorithm for the key in it, and a comment in the PDFBox example refers to the issue PDFBOX-5940 for that reason. The class above names the algorithm explicitly instead. The RSA key size and algorithm must come from the key in the keystore, not from the certificate: an RSA-PSS or ECDSA signer needs a different JcaContentSignerBuilder string, and ETSI TS 119 312 V2.1.1 prefers those to PKCS#1 v1.5 for new signatures, as explained in PAdES Baseline Levels.

The attribute is built from the SHA-256 digest of the signer certificate. The call to setSignedAttributeGenerator replaces the default generator of signed attributes with one that keeps the content type and message digest, adds the new attribute, and removes the signing-time attribute that Bouncy Castle adds by default: the PAdES baseline profile (ETSI EN 319 142-1) forbids the CMS signing-time attribute, and the claimed signing time goes in the /M entry of the signature dictionary instead, which PDFBox fills from setSignDate. Official documentation of the interface: SignatureInterface in the PDFBox 3.0.8 javadoc. The example derives from CreateSignatureBase.java.

Signing with addSignature

The first PDFBox example signs with the callback. It reserves 18,000 bytes, fills the signature dictionary and calls saveIncremental, which requires the output to be a different stream from the one the document was loaded from, and which appends to the original bytes instead of rewriting the file; that is why earlier signatures survive.

package billing;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Calendar;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.ExternalSigningSupport;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureInterface;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureOptions;

public final class PdfBoxSigning {

    private PdfBoxSigning() {
    }

    private static PDSignature dictionary(String reason) {
        PDSignature signature = new PDSignature();
        signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE);
        signature.setSubFilter(PDSignature.SUBFILTER_ETSI_CADES_DETACHED);
        signature.setName("Bookshelf Billing Signer");
        signature.setLocation("Pamplona");
        signature.setReason(reason);
        signature.setSignDate(Calendar.getInstance());
        return signature;
    }

    /** Callback style: PDFBox calls SignatureInterface.sign with the ByteRange content. */
    public static void signWithCallback(Path in, Path out, SignatureInterface signer) throws IOException {
        try (PDDocument document = Loader.loadPDF(in.toFile());
                OutputStream output = Files.newOutputStream(out);
                SignatureOptions options = new SignatureOptions()) {
            options.setPreferredSignatureSize(18_000); // bytes reserved for /Contents
            document.addSignature(dictionary("Invoice approval"), signer, options);
            document.saveIncremental(output);
        }
    }

    /** External style: PDFBox hands out the content, the caller returns the CMS container. */
    public static void signExternally(Path in, Path out, SignatureInterface signer) throws IOException {
        try (PDDocument document = Loader.loadPDF(in.toFile());
                OutputStream output = Files.newOutputStream(out)) {
            document.addSignature(dictionary("Invoice approval, external"));
            ExternalSigningSupport external = document.saveIncrementalForExternalSigning(output);
            byte[] cms;
            try (InputStream content = external.getContent()) {
                cms = signer.sign(content); // could just as well be a call to an HSM or a remote service
            }
            external.setSignature(cms);
        }
    }

    /** A document timestamp (PAdES B-LTA building block): subfilter ETSI.RFC3161, no certificate. */
    public static void addDocumentTimestamp(Path in, Path out, TsaClient tsa) throws IOException {
        SignatureInterface timestamper = content -> tsa.timestamp(content.readAllBytes());
        try (PDDocument document = Loader.loadPDF(in.toFile());
                OutputStream output = Files.newOutputStream(out);
                SignatureOptions options = new SignatureOptions()) {
            PDSignature signature = new PDSignature();
            signature.setType(COSName.DOC_TIME_STAMP);
            signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE);
            signature.setSubFilter(COSName.getPDFName("ETSI.RFC3161"));
            options.setPreferredSignatureSize(12_000);
            document.addSignature(signature, timestamper, options);
            document.saveIncremental(output);
        }
    }
}

signWithCallback and signExternally differ only in who calls sign. The external variant opens the ExternalSigningSupport and passes its content stream to a SignatureInterface; in a real system that call is the point at which an HSM, a cloud key service or a signing microservice is used, as described in Remote, HSM & Deferred Signing. A few rules apply to both.

  • Call setSignDate and the other metadata before addSignature; the dictionary is written when the document is saved.

  • Never modify the document between addSignature and the save, and never reuse the PDDocument after the save.

  • Do not reuse a PDSignature for a second signature.

  • A signature on a file that already has signatures needs saveIncremental and never save, and the previous signatures are not broken because the original bytes are preserved. The run for this page signed an already signed invoice a second time and read back two valid signatures with byte ranges 0, 1187, 37189, 779 and 0, 38444, 74446, 806.

The official examples are in the PDFBox repository: CreateSignature.java for the callback and CreateVisibleSignature2.java for the external path with a visible signature. The artifact org.apache.pdfbox:pdfbox-examples was not published for 3.0.8 (the newest version on Maven Central as of 2026-10-09 was 3.0.7), so these classes are source to read and copy, not a dependency to add.

Visible signatures

A visible signature is a signature field whose widget annotation has an appearance stream. PDFBox 3 has no ready-made designer class for it: the example builds a template PDF, a one-page document that holds only the signature field and its appearance, and hands it to SignatureOptions.setVisualSignature. PDFBox merges the widget into the target page and uses the template’s appearance. The class below draws a light blue box with a line of text; to add a logo, draw a PDImageXObject with PDPageContentStream.drawImage in the same stream.

package billing;

import java.awt.Color;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Calendar;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.common.PDStream;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureInterface;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureOptions;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;

public final class VisibleSignature {

    private VisibleSignature() {
    }

    public static void sign(Path in, Path out, SignatureInterface signer) throws IOException {
        PDRectangle box = new PDRectangle(72, 72, 220, 60); // x, y, width, height in points
        try (PDDocument document = Loader.loadPDF(in.toFile());
                OutputStream output = Files.newOutputStream(out)) {
            PDSignature signature = new PDSignature();
            signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE);
            signature.setSubFilter(PDSignature.SUBFILTER_ETSI_CADES_DETACHED);
            signature.setName("Bookshelf Billing Signer");
            signature.setReason("Invoice approval");
            signature.setSignDate(Calendar.getInstance());
            try (InputStream template = template(document, box, "Signed by Bookshelf Billing Signer");
                    SignatureOptions options = new SignatureOptions()) {
                options.setVisualSignature(template);
                options.setPage(0);
                options.setPreferredSignatureSize(18_000);
                document.addSignature(signature, signer, options);
                document.saveIncremental(output);
            }
        }
    }

    /** One-page PDF holding only the signature widget and its appearance stream. */
    private static InputStream template(PDDocument source, PDRectangle box, String text) throws IOException {
        try (PDDocument doc = new PDDocument()) {
            doc.addPage(new PDPage(source.getPage(0).getMediaBox()));
            PDAcroForm acroForm = new PDAcroForm(doc);
            doc.getDocumentCatalog().setAcroForm(acroForm);
            PDSignatureField field = new PDSignatureField(acroForm);
            PDAnnotationWidget widget = field.getWidgets().get(0);
            acroForm.setSignaturesExist(true);
            acroForm.setAppendOnly(true);
            acroForm.getCOSObject().setDirect(true);
            acroForm.getFields().add(field);
            widget.setRectangle(box);

            PDFormXObject form = new PDFormXObject(new PDStream(doc));
            form.setResources(new PDResources());
            form.setFormType(1);
            form.setBBox(new PDRectangle(box.getWidth(), box.getHeight()));
            PDAppearanceDictionary appearance = new PDAppearanceDictionary();
            appearance.getCOSObject().setDirect(true);
            PDAppearanceStream stream = new PDAppearanceStream(form.getCOSObject());
            appearance.setNormalAppearance(stream);
            widget.setAppearance(appearance);

            try (PDPageContentStream cs = new PDPageContentStream(doc, stream)) {
                cs.setNonStrokingColor(new Color(235, 242, 250));
                cs.addRect(0, 0, box.getWidth(), box.getHeight());
                cs.fill();
                cs.beginText();
                cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD), 10);
                cs.setNonStrokingColor(Color.BLACK);
                cs.newLineAtOffset(8, box.getHeight() - 18);
                cs.showText(text);
                cs.endText();
            }
            ByteArrayOutputStream bytes = new ByteArrayOutputStream();
            doc.save(bytes);
            return new ByteArrayInputStream(bytes.toByteArray());
        }
    }
}

Four details are easy to get wrong. The rectangle is in PDF user space, that is, from the bottom-left corner of the page in points, and the example’s PDRectangle(72, 72, 220, 60) is therefore a box one inch from the left and bottom edges. The template page must have the same media box as the target page, which is why the code copies it. The form carries setAppendOnly(true) and setSignaturesExist(true), as in the official example, to mark the file as one that has signatures. And the image must be drawn into the appearance stream, not onto the page: content drawn on the page changes the bytes that the signature covers and has to be added before signing. After running the class the file was rendered with PDFBox’s own PDFRenderer, and the box appeared at the bottom left with its text.

The official example, which also supports an existing empty signature field, a certification level and text from the certificate, is CreateVisibleSignature2.java. Its companion CreateEmptySignatureForm creates a field to be signed later, the PDFBox counterpart of the iText signature field described in Signing PDFs with iText.

Timestamps: signature and document

A timestamp is an RFC 3161 token issued by a time stamping authority, which proves that a piece of data existed at a given time. PAdES uses it in two places, and PDFBox supports both through the same SignatureInterface.

  • A signature timestamp is a token computed over the signature value and stored in the unsigned attributes of the CMS container. It turns a B-B signature into B-T. It was added by the CmsSigner class above when a TsaClient is given.

  • A document timestamp is a separate signature dictionary with /Type /DocTimeStamp and SubFilter ETSI.RFC3161, whose /Contents is the timestamp token itself, computed over the byte range of the whole file. It carries no certificate of the signer, it covers every earlier signature, and it is the building block of B-LTA. Document timestamps are specified by ETSI TS 119 142-3, and later ones can be added over time.

The client below is minimal and does not hide the protocol: it builds a TimeStampRequest for the SHA-256 digest of the data with a random nonce and certReq set (so the TSA’s certificate is returned inside the token), posts it with Content-Type: application/timestamp-query, checks the response against the request and returns the encoded token. It sends no credentials, and the only server used is the free test TSA at https://freetsa.org/tsr, which is not for production use; a commercial TSA, with a service level and a published policy, is described in PAdES Baseline Levels.

package billing;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.math.BigInteger;
import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
import org.bouncycastle.tsp.TSPException;
import org.bouncycastle.tsp.TimeStampRequest;
import org.bouncycastle.tsp.TimeStampRequestGenerator;
import org.bouncycastle.tsp.TimeStampResponse;
import org.bouncycastle.tsp.TimeStampToken;

/** A minimal RFC 3161 client: one POST of a timestamp query, no credentials. */
public final class TsaClient {

    private final URI tsaUrl;
    private final HttpClient http = HttpClient.newHttpClient();

    public TsaClient(String tsaUrl) {
        this.tsaUrl = URI.create(tsaUrl);
    }

    /** Returns the DER encoded TimeStampToken for the SHA-256 digest of the data. */
    public byte[] timestamp(byte[] data) throws IOException {
        try {
            byte[] digest = MessageDigest.getInstance("SHA-256").digest(data);
            TimeStampRequestGenerator generator = new TimeStampRequestGenerator();
            generator.setCertReq(true);
            TimeStampRequest request = generator.generate(
                    NISTObjectIdentifiers.id_sha256, digest, new BigInteger(64, new SecureRandom()));
            HttpRequest post = HttpRequest.newBuilder(tsaUrl)
                    .header("Content-Type", "application/timestamp-query")
                    .POST(HttpRequest.BodyPublishers.ofByteArray(request.getEncoded()))
                    .build();
            HttpResponse<byte[]> reply = http.send(post, HttpResponse.BodyHandlers.ofByteArray());
            if (reply.statusCode() != 200) {
                throw new IOException("TSA answered HTTP " + reply.statusCode());
            }
            TimeStampResponse response = new TimeStampResponse(reply.body());
            response.validate(request);
            TimeStampToken token = response.getTimeStampToken();
            return token.getEncoded();
        } catch (NoSuchAlgorithmException | TSPException e) {
            throw new IOException(e);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IOException(e);
        }
    }
}

The document timestamp is added with an ordinary addSignature call and a SignatureInterface that hands the covered bytes to the TSA, which is the method addDocumentTimestamp of PdfBoxSigning above. The two official examples are CreateSignedTimeStamp.java (a document timestamp, as here) and CreateEmbeddedTimeStamp.java (adding a timestamp token to the unsigned attributes of an existing signature, rewriting its container in place). The protocol is defined in RFC 3161.

The driver runs all of them. It creates the invoice, signs it three ways, then signs with a timestamp and appends a document timestamp.

package billing;

import java.nio.file.Files;
import java.nio.file.Path;

public final class PdfBoxDemo {

    public static void main(String[] args) throws Exception {
        Path dir = Path.of("out");
        Files.createDirectories(dir);
        Path invoice = dir.resolve("invoice.pdf");
        Invoice.create(invoice, "2026-0001");
        SigningMaterial material = SigningMaterial.load(Path.of("billing-signer.p12"));

        CmsSigner baseline = new CmsSigner(material, null);
        PdfBoxSigning.signWithCallback(invoice, dir.resolve("invoice-callback.pdf"), baseline);
        PdfBoxSigning.signExternally(invoice, dir.resolve("invoice-external.pdf"), baseline);
        VisibleSignature.sign(invoice, dir.resolve("invoice-visible.pdf"), baseline);

        TsaClient tsa = new TsaClient("https://freetsa.org/tsr");
        PdfBoxSigning.signWithCallback(invoice, dir.resolve("invoice-bt.pdf"), new CmsSigner(material, tsa));
        PdfBoxSigning.addDocumentTimestamp(dir.resolve("invoice-bt.pdf"), dir.resolve("invoice-bt-doc.pdf"), tsa);
    }
}

Validation data for B-LT with AddValidationInformation

B-T proves when the signature was made. B-LT also stores what was known about the certificates at that time: the chain, the OCSP responses (RFC 6960) and the CRLs, in the document security store, the /DSS dictionary of the catalog, optionally with a /VRI entry per signature. PDFBox has no public API for this. It has a source example, AddValidationInformation.java, that opens a signed file, collects the chain of the last signature (and of its timestamp), downloads an OCSP response or a CRL for each certificate from the addresses written in the certificates, and writes them into /DSS, with the /VRI entry, in an incremental update. It needs helper classes of the same package (CertInformationCollector, CertInformationHelper, OcspHelper, CRLVerifier and others) that must be copied together with it.

The example was copied from the 3.0.8 tag and run on the signed and timestamped file of the previous section, which produced a file with a /DSS dictionary holding certificates, one OCSP response, one CRL and a /VRI entry. Three observations matter in practice.

  1. With Bouncy Castle 1.86 on the classpath, the example failed with IOException reading content.: Extra data detected in stream, because it passes the zero-padded /Contents returned by PDSignature.getContents straight to new CMSSignedData(byte[]), and the parser of 1.86 apparently refuses the trailing bytes, which the 1.84 parser accepted. With Bouncy Castle 1.84, the version that PDFBox 3.0.8 declares in its own build, it ran. Either pin 1.84 for this tool, or trim the container to its first ASN.1 object before parsing, as the checking helper described near the end of this page does with an ASN1InputStream.

  2. The example fetched revocation data from the addresses in the certificates it found. The test signer has no OCSP or CRL address, so the OCSP response and the CRL in the output most likely belong to the free TSA’s certificate; a validator would still miss revocation data for the signer. For a real B-LT the signer certificate must come from a CA that publishes OCSP or CRL addresses.

  3. The example logged an OCSP error for the TSA certificate (the responder answered "status unknown") and still wrote the file. It reports errors in the log and does not stop, so a build pipeline must check the output.

Because of the first and third points, treat AddValidationInformation as a starting point to adapt, not as a library call. If the document must reach B-LT or B-LTA, the EU DSS service of the next sections does the same job with a supported API, a verifier that you configure, and PDFBox underneath.

EU DSS: the PAdES service

DSS separates what to sign from how the key signs. A PAdESService is configured with a CertificateVerifier, which knows the trusted certificates and the sources of revocation data, and optionally a TSPSource for timestamps. The caller creates PAdESSignatureParameters that describe the signature, and then signs in three steps: getDataToSign returns the ToBeSigned bytes, a signature token signs them, and signDocument embeds the SignatureValue and returns the new document. The three-step shape is deliberate: the middle step can run on another machine or in a hardware device, which is what Remote, HSM & Deferred Signing relies on.

The classes that matter, all of DSS 6.5 as compiled:

Class Role

PAdESService (dss-pades)

Creates, extends and timestamps PAdES documents. setPdfObjFactory selects the PDF layer, setTspSource the timestamp source.

PAdESSignatureParameters (dss-pades)

Signature level, digest algorithm, signing certificate and chain, signing date through bLevel(), reason, location, setContentSize, optional setImageParameters, setIncludeVRIDictionary.

SignatureLevel (dss-enumerations)

PAdES_BASELINE_B, PAdES_BASELINE_T, PAdES_BASELINE_LT and PAdES_BASELINE_LTA, among many others (XAdES, CAdES, JAdES).

Pkcs12SignatureToken (dss-token)

A SignatureTokenConnection over a PKCS#12 file; getKey(alias) returns a DSSPrivateKeyEntry with certificate and chain. Other tokens wrap PKCS#11 (Pkcs11SignatureToken), the Windows store, or a custom SignatureTokenConnection.

OnlineTSPSource (dss-service)

Timestamp client for a TSA URL; with TimestampDataLoader for HTTP.

CommonCertificateVerifier (dss-spi)

The verifier: trusted certificate sources, setCrlSource, setOcspSource, AIA source, and alerts such as setAlertOnMissingRevocationData.

PdfBoxNativeObjectFactory (dss-pades-pdfbox)

PDF layer using PDFBox, with the "native drawer" that writes visible text as real text.

DSS documentation states that its default is already the native drawer, and that setPdfObjFactory(new PdfBoxNativeObjectFactory()) or a META-INF/services/eu.europa.esig.dss.pdf.IPdfObjFactory file selects it explicitly. The alternative, the original drawer, rasterises text into an image, which makes it unsearchable. The class below is the full service of the page: the constructor builds the verifier and the service, and four methods cover B-B/B-T signing, a visible signature, extension and a standalone document timestamp. The comments mark the three steps of the signing flow.

package billing;

import eu.europa.esig.dss.alert.LogOnStatusAlert;
import eu.europa.esig.dss.enumerations.DigestAlgorithm;
import eu.europa.esig.dss.enumerations.SignatureLevel;
import eu.europa.esig.dss.model.DSSDocument;
import eu.europa.esig.dss.model.FileDocument;
import eu.europa.esig.dss.model.SignatureValue;
import eu.europa.esig.dss.model.ToBeSigned;
import eu.europa.esig.dss.pades.PAdESSignatureParameters;
import eu.europa.esig.dss.pades.PAdESTimestampParameters;
import eu.europa.esig.dss.pades.SignatureFieldParameters;
import eu.europa.esig.dss.pades.SignatureImageParameters;
import eu.europa.esig.dss.pades.SignatureImageTextParameters;
import eu.europa.esig.dss.pades.signature.PAdESService;
import eu.europa.esig.dss.pdf.pdfbox.PdfBoxNativeObjectFactory;
import eu.europa.esig.dss.service.http.commons.TimestampDataLoader;
import eu.europa.esig.dss.service.tsp.OnlineTSPSource;
import eu.europa.esig.dss.spi.DSSUtils;
import eu.europa.esig.dss.spi.validation.CertificateVerifier;
import eu.europa.esig.dss.spi.validation.CommonCertificateVerifier;
import eu.europa.esig.dss.spi.x509.CommonTrustedCertificateSource;
import eu.europa.esig.dss.spi.x509.revocation.crl.ExternalResourcesCRLSource;
import eu.europa.esig.dss.token.DSSPrivateKeyEntry;
import eu.europa.esig.dss.token.Pkcs12SignatureToken;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.KeyStore.PasswordProtection;
import java.util.Date;
import org.bouncycastle.operator.OperatorCreationException;

public final class DssSigning implements AutoCloseable {

    private final Pkcs12SignatureToken token;
    private final DSSPrivateKeyEntry signerEntry;
    private final PAdESService service;

    public DssSigning(Path signerP12, Path caPem, Path caP12, String tsaUrl)
            throws IOException, GeneralSecurityException, OperatorCreationException {
        char[] storePassword = System.getenv("BILLING_KEYSTORE_PASSWORD").toCharArray();
        token = new Pkcs12SignatureToken(signerP12.toFile(), new PasswordProtection(storePassword));
        signerEntry = token.getKey("billing-signer");

        // Trust anchor: the test CA exported by the keytool script
        CommonTrustedCertificateSource trusted = new CommonTrustedCertificateSource();
        trusted.addCertificate(DSSUtils.loadCertificate(caPem.toFile()));

        CertificateVerifier verifier = new CommonCertificateVerifier();
        verifier.setTrustedCertSources(trusted);
        // Test PKI only: the signer certificate has no CRL or OCSP address, so hand DSS a CRL
        verifier.setCrlSource(new ExternalResourcesCRLSource(new ByteArrayInputStream(TestCrl.issue(caP12))));
        // Log, instead of failing, when the TSA's own chain has no revocation data
        verifier.setAlertOnMissingRevocationData(new LogOnStatusAlert());

        service = new PAdESService(verifier);
        service.setPdfObjFactory(new PdfBoxNativeObjectFactory()); // write the PDF with PDFBox
        if (tsaUrl != null) {
            service.setTspSource(new OnlineTSPSource(tsaUrl, new TimestampDataLoader()));
        }
    }

    private PAdESSignatureParameters parameters(SignatureLevel level) {
        PAdESSignatureParameters parameters = new PAdESSignatureParameters();
        parameters.setSignatureLevel(level);
        parameters.setDigestAlgorithm(DigestAlgorithm.SHA256);
        parameters.setSigningCertificate(signerEntry.getCertificate());
        parameters.setCertificateChain(signerEntry.getCertificateChain());
        parameters.bLevel().setSigningDate(new Date());
        parameters.setReason("Invoice approval");
        parameters.setLocation("Pamplona");
        parameters.setContentSize(18_000); // bytes reserved for /Contents
        return parameters;
    }

    /** PAdES_BASELINE_B signs; PAdES_BASELINE_T also asks the TSP source for a signature timestamp. */
    public void sign(Path in, Path out, SignatureLevel level) throws IOException {
        sign(new FileDocument(in.toFile()), parameters(level), out);
    }

    /** Adds a visible appearance: a rectangle on page 1 with text, no image. */
    public void signVisible(Path in, Path out) throws IOException {
        PAdESSignatureParameters parameters = parameters(SignatureLevel.PAdES_BASELINE_T);
        SignatureFieldParameters field = new SignatureFieldParameters();
        field.setPage(1);
        field.setOriginX(72);
        field.setOriginY(640); // DSS measures from the top left corner of the page
        field.setWidth(220);
        field.setHeight(60);
        SignatureImageTextParameters text = new SignatureImageTextParameters();
        text.setText("Signed by Bookshelf Billing Signer");
        SignatureImageParameters image = new SignatureImageParameters();
        image.setFieldParameters(field);
        image.setTextParameters(text);
        parameters.setImageParameters(image);
        sign(new FileDocument(in.toFile()), parameters, out);
    }

    private void sign(DSSDocument document, PAdESSignatureParameters parameters, Path out) throws IOException {
        ToBeSigned toBeSigned = service.getDataToSign(document, parameters); // 1. what to sign
        SignatureValue value = token.sign(toBeSigned, parameters.getDigestAlgorithm(), signerEntry); // 2. sign it
        try (OutputStream output = Files.newOutputStream(out)) {
            service.signDocument(document, parameters, value).writeTo(output); // 3. embed it
        }
    }

    /** Raises an existing signature: T to LT, LT to LTA. */
    public void extend(Path in, Path out, SignatureLevel target) throws IOException {
        DSSDocument document = new FileDocument(in.toFile());
        try (OutputStream output = Files.newOutputStream(out)) {
            service.extendDocument(document, parameters(target)).writeTo(output);
        }
    }

    /** A standalone document timestamp: a new revision, no existing signature is touched. */
    public void timestamp(Path in, Path out) throws IOException {
        DSSDocument document = new FileDocument(in.toFile());
        try (OutputStream output = Files.newOutputStream(out)) {
            service.timestamp(document, new PAdESTimestampParameters()).writeTo(output);
        }
    }

    @Override
    public void close() {
        token.close();
    }
}

Several choices in the constructor are specific to a test PKI, and a production service would differ.

  • Trust. CommonTrustedCertificateSource takes the certificates that are trusted anchors; here, the test CA. In production the trusted certificates come from the EU List of Trusted Lists (LOTL) through TrustedListsCertificateSource and a TLValidationJob, as explained in Signature Validation & Long-Term Validity, and from the Adobe AATL for documents read in Acrobat.

  • Revocation. Real certificates carry OCSP and CRL addresses, and DSS fetches the data from them only when the verifier has online sources: CommonCertificateVerifier creates none by default, so a production service sets OnlineOCSPSource, OnlineCRLSource (both in dss-service) and DefaultAIASource explicitly with setOcspSource, setCrlSource and setAIASource. The test signer has no such addresses, so the class issues a CRL from the test CA with a short Bouncy Castle class (TestCrl, below) and passes it as an ExternalResourcesCRLSource.

  • Alerts. By default, DSS refuses to extend a signature to LT when revocation data is missing for any certificate in the chain, throwing Revocation data is missing for one or more certificate(s). The first attempt on this page failed with that message for both the signer certificate and the TSA’s own certificate. The code replaces the alert with LogOnStatusAlert, which logs a warning and goes on; that is acceptable for a demonstration and wrong for production, where the exception is exactly what protects you from producing a file labelled B-LT that cannot be validated.

The CRL helper issues an empty list signed by the test CA, valid for a week.

package billing;

import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.cert.X509Certificate;
import java.util.Date;
import org.bouncycastle.cert.X509CRLHolder;
import org.bouncycastle.cert.X509v2CRLBuilder;
import org.bouncycastle.cert.jcajce.JcaX509CRLConverter;
import org.bouncycastle.cert.jcajce.JcaX509v2CRLBuilder;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;

/** Issues an empty CRL from the test CA, so DSS has revocation data for the signer certificate. */
public final class TestCrl {

    private TestCrl() {
    }

    public static byte[] issue(Path caP12) throws IOException, GeneralSecurityException, OperatorCreationException {
        char[] storePassword = System.getenv("BILLING_KEYSTORE_PASSWORD").toCharArray();
        KeyStore store = KeyStore.getInstance("PKCS12");
        try (InputStream in = Files.newInputStream(caP12)) {
            store.load(in, storePassword);
        }
        PrivateKey caKey = (PrivateKey) store.getKey("billing-test-ca", storePassword);
        X509Certificate ca = (X509Certificate) store.getCertificate("billing-test-ca");
        Date now = new Date();
        X509v2CRLBuilder builder = new JcaX509v2CRLBuilder(ca, now);
        builder.setNextUpdate(new Date(now.getTime() + 7L * 24 * 3600 * 1000));
        X509CRLHolder crl = builder.build(new JcaContentSignerBuilder("SHA256withRSA").build(caKey));
        return new JcaX509CRLConverter().getCRL(crl).getEncoded();
    }
}

The driver signs the invoice at B-B and B-T, extends the B-T file to B-LT and then B-LTA, adds a standalone document timestamp, and signs a visible version. Each of the DSS operations is an ordinary call and none of them modifies the source file.

package billing;

import eu.europa.esig.dss.enumerations.SignatureLevel;
import java.nio.file.Path;

public final class DssDemo {

    public static void main(String[] args) throws Exception {
        Path out = Path.of("out");
        String tsa = "https://freetsa.org/tsr";
        try (DssSigning dss = new DssSigning(Path.of("billing-signer.p12"), Path.of("billing-test-ca.pem"),
                Path.of("billing-test-ca.p12"), tsa)) {
            Path invoice = out.resolve("invoice.pdf");
            dss.sign(invoice, out.resolve("dss-b.pdf"), SignatureLevel.PAdES_BASELINE_B);
            dss.sign(invoice, out.resolve("dss-t.pdf"), SignatureLevel.PAdES_BASELINE_T);
            dss.extend(out.resolve("dss-t.pdf"), out.resolve("dss-lt.pdf"), SignatureLevel.PAdES_BASELINE_LT);
            dss.extend(out.resolve("dss-lt.pdf"), out.resolve("dss-lta.pdf"), SignatureLevel.PAdES_BASELINE_LTA);
            dss.timestamp(out.resolve("dss-t.pdf"), out.resolve("dss-t-doc.pdf"));
            dss.signVisible(invoice, out.resolve("dss-visible.pdf"));
        }
    }
}

The levels map to the standard as follows, and the next diagram shows what each step adds to the file.

flowchart LR U["Unsigned PDF"] -->|"sign, PAdES_BASELINE_B"| B["B-B: CMS signature"] U -->|"sign, PAdES_BASELINE_T"| T["B-T: plus signature timestamp"] B -->|"extend, PAdES_BASELINE_T"| T T -->|"extend, PAdES_BASELINE_LT"| LT["B-LT: plus DSS dictionary with certificates and revocation data"] LT -->|"extend, PAdES_BASELINE_LTA"| LTA["B-LTA: plus document timestamp"] LTA -->|"timestamp again, years later"| LTA2["B-LTA renewed"]

The signing and extension API derives from the DSS documentation sections PAdES and Timestamping a PDF, and the classes are documented in the DSS API documentation.

Extending signatures

extendDocument takes a signed document and parameters whose SignatureLevel is the target. DSS validates the existing signatures, collects what the target level needs and adds an incremental update: for LT, a /DSS dictionary with the chain, the CRLs and the OCSP responses (without /VRI unless setIncludeVRIDictionary(true) is called, which DSS documents as not to be used for baseline signatures); for LTA, a document timestamp over the whole file. In the run on this page the results were read back with PDFBox:

File Signatures in the file /DSS dictionary

B-B

One ETSI.CAdES.detached signature, no signature timestamp

None

B-T

One signature with a signature timestamp in its unsigned attributes

None

B-LT

The same signature

Certificates (4 entries) and CRLs (1 entry)

B-LTA

The signature and one ETSI.RFC3161 document timestamp

The same as B-LT

The document timestamp of the B-LTA file used SHA-512 for its message imprint in this run, while the signature timestamp that this page’s own TsaClient requests uses SHA-256; the imprint of both matched a digest recomputed over the signed byte range. Run-time warnings must not be ignored: DSS logged that it found no revocation data for the TSA certificate chain, because that chain is not trusted by the verifier and DSS skips revocation checks for untrusted chains. For this reason the B-LT and B-LTA files above show the shape of the levels and exercise the API; they are not valid long-term signatures in the sense of the standard, a point taken up again in the last-but-one section of this page.

Renewing a B-LTA file years later means adding a new document timestamp over the previous state, which is what the iText method prolongSignatures does in Signing PDFs with iText; the DSS documentation describes the corresponding extension, and this page did not run it. The standalone timestamp method of the service adds a document timestamp to any PDF, signed or not, without touching its signatures, and can therefore be used alone as an inexpensive proof of existence for unsigned archives. The DSS documentation explains that a standalone timestamp is used on purpose, because adding a token to the CMS signature of PDF would break existing signatures.

A visible signature with DSS

PAdESSignatureParameters.setImageParameters takes SignatureImageParameters, which holds a SignatureFieldParameters (page, origin, width, height, optional field id) and either text parameters, an image, or both. signVisible above draws a text box at the left of the first page. Unlike PDFBox, DSS measures the origin from the top-left corner of the page, with y growing downwards, and it builds the field and its appearance itself with the native drawer, which the DSS documentation says writes the text as real, searchable text. Rendering the output with PDFBox showed the line of text near the bottom left of the page, where originY of 640 points from the top puts it; searchability was not tested.

Two behaviours are worth knowing. The file is much larger than the invisible one (156,209 bytes against 37,933): the last number of the /ByteRange of the visible file, which is the length of everything after /Contents, is 119,038 (0, 1169, 37171, 119038), because the native drawer embeds a font for the text. And when the file already has several signature fields, DSS can sign an existing empty field by naming it in SignatureFieldParameters.setFieldId.

Signing JasperReports output

JasperReports has no signing feature. The PDF exporter writes the document through OpenPDF in jasperreports-pdf, and its configuration (see PDF Exporter Configuration) offers encryption, PDF/A and metadata, but no signature, and the books on the library that were consulted for this section do not mention PDF/A, tagging, forms or signing at all (finding O27). The reliable pattern is to export to bytes, which is JasperExportManager.exportReportToPdf(JasperPrint), and then to sign the bytes with any of the libraries on this page or on the iText page. Signing always follows the export, because the exporter writes the whole file in one go and a signature cannot be part of it; the signature is an incremental update that covers the exported file exactly as it was.

<dependency>
    <groupId>net.sf.jasperreports</groupId>
    <artifactId>jasperreports</artifactId>
    <version>7.0.8</version>
</dependency>
<dependency>
    <groupId>net.sf.jasperreports</groupId>
    <artifactId>jasperreports-pdf</artifactId>
    <version>7.0.8</version>
</dependency>

The report is a minimal JasperReports 7 design, with the namespace-free <element kind="..."> syntax of the v7 format, stored as src/main/resources/reports/invoice.jrxml:

<jasperReport name="invoice" language="java" pageWidth="595" pageHeight="842"
              columnWidth="515" leftMargin="40" rightMargin="40" topMargin="40" bottomMargin="40">
  <parameter name="NUMBER" class="java.lang.String"/>
  <title height="60">
    <element kind="textField" x="0" y="0" width="515" height="30" fontSize="18" bold="true">
      <expression><![CDATA["Bookshelf invoice " + $P{NUMBER}]]></expression>
    </element>
  </title>
</jasperReport>

The class compiles the report, fills it with one empty row, exports to bytes, writes the unsigned file and signs it with the PdfBoxSigning class of this page. Two details were found by running it: the parameter map given to fillReport must be mutable, because the engine adds its own parameters to it, and Map.of fails with UnsupportedOperationException; and the exporter’s output is an ordinary PDF that PDFBox loads and signs without any configuration.

package billing;

import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.util.HashMap;
import java.util.Map;
import net.sf.jasperreports.engine.JREmptyDataSource;
import net.sf.jasperreports.engine.JRException;
import net.sf.jasperreports.engine.JasperCompileManager;
import net.sf.jasperreports.engine.JasperExportManager;
import net.sf.jasperreports.engine.JasperFillManager;
import net.sf.jasperreports.engine.JasperPrint;
import net.sf.jasperreports.engine.JasperReport;

/** JasperReports 7.0.8 has no signing: export to bytes first, then sign the bytes. */
public final class JasperThenSign {

    public static void main(String[] args) throws IOException, JRException, GeneralSecurityException {
        byte[] pdf;
        try (InputStream jrxml = JasperThenSign.class.getResourceAsStream("/reports/invoice.jrxml")) {
            JasperReport report = JasperCompileManager.compileReport(jrxml);
            Map<String, Object> parameters = new HashMap<>(); // must be mutable: the engine adds to it
            parameters.put("NUMBER", "2026-0002");
            JasperPrint print = JasperFillManager.fillReport(report, parameters, new JREmptyDataSource(1));
            pdf = JasperExportManager.exportReportToPdf(print);
        }
        Path unsigned = Path.of("out", "jasper-unsigned.pdf");
        Files.write(unsigned, pdf);

        SigningMaterial material = SigningMaterial.load(Path.of("billing-signer.p12"));
        PdfBoxSigning.signWithCallback(unsigned, Path.of("out", "jasper-signed.pdf"), new CmsSigner(material, null));
    }
}

The result was read back: one valid ETSI.CAdES.detached signature with byte range 0, 1393, 37395, 926. The same bytes can be given to DSS with new InMemoryDocument(bytes) instead of a FileDocument, so that nothing touches the disk. A few consequences of signing after the export:

  • Encrypt before signing if both are needed, and never encrypt after: a signed file that is rewritten is no longer the file that was signed. The encryption options of the exporter and iText’s are in PDF Exporter Configuration and Encryption & Permissions.

  • A file exported as PDF/A stays PDF/A after an incremental signature only if the signature dictionary and the optional appearance meet the conformance level; check the result with a validator, as in PDF Standards & Conformance.

  • JasperReports form fields exported with the AcroForm properties and a signature are different matters: the signature field is added at signing time, not by the exporter.

  • For a monthly sales report produced in a batch, sign once at the end of the pipeline, and keep the signing key behind the service of Remote, HSM & Deferred Signing. Returning the signed bytes from a Spring Boot controller is described in JasperReports with Spring Boot.

Reading back what was signed

Every file of this page was checked after it was written, with a small helper that is not reproduced here: for each signature dictionary it read /ByteRange, recomputed the covered bytes with PDSignature.getSignedContent, parsed /Contents with Bouncy Castle (reading only the first ASN.1 object, to ignore the zero padding), verified the signer with JcaSimpleSignerInfoVerifierBuilder, looked for the signature timestamp attribute, and, for document timestamps, recomputed the digest of the byte range with the algorithm named in the token and compared it with the message imprint. This proves that the container matches the bytes and that the timestamp matches too. It does not prove that anyone trusts the signer, which is validation and not signing; that is the subject of Signature Validation & Long-Term Validity, and of the DSS validation module that is not used on this page.

What was run, and what was not

All the Java classes on this page were compiled with Maven on Java 21 against PDFBox 3.0.8, DSS 6.5, Bouncy Castle 1.86 and JasperReports 7.0.8, and run on 2026-10-09 in a scratch project outside the repository, with the test keystores produced by the keytool script of this page.

Step Result

PDFBox callback signature

Signed, CMS verified, SubFilter ETSI.CAdES.detached

PDFBox external signature (saveIncrementalForExternalSigning)

Signed, CMS verified

PDFBox visible signature

Signed, CMS verified, rendered with PDFBox and the box was visible

PDFBox signature timestamp (B-T) against https://freetsa.org/tsr

Token present in the unsigned attributes, signature verified

PDFBox document timestamp

Token imprint matched the digest of its byte range

Second PDFBox signature on a signed file

Two valid signatures

AddValidationInformation from the 3.0.8 sources

Failed with Bouncy Castle 1.86, ran with 1.84 and wrote /DSS and /VRI (certificates, one OCSP response, one CRL)

DSS B-B, B-T, B-LT, B-LTA, standalone timestamp, visible signature

All produced; /DSS present in LT and LTA; document timestamp in LTA; imprint verified

JasperReports 7.0.8 export then PDFBox signature

One valid signature

The limits of the run must be stated plainly. The TSA was reachable and served time stamp requests; an HTTP HEAD request to freetsa.org returned HTTP 403 in the same session while the POST of a real query worked, so a curl -I is not a health check. The test PKI has no OCSP responder and no real CRL distribution point, so no real OCSP or CRL fetching was exercised for the signer; DSS received a CRL that this page generated. The B-LT and B-LTA files therefore illustrate the structure of the levels and are not valid long-term signatures: DSS logged missing revocation data for the TSA chain, and the verifier was configured to log rather than to fail. None of the files was opened in Acrobat, in the DSS demo application or in veraPDF, and nobody trusts the test CA, so Acrobat would show the signatures as untrusted. A real B-LT claim needs a real certificate chain, real revocation endpoints and a validation with a tool that checks both.

Production notes

  • Keep the private key out of the application where you can. Pkcs12SignatureToken is for tests and small deployments; DSS offers Pkcs11SignatureToken for HSMs and smart cards, and SignatureTokenConnection can be implemented to call a cloud key service, as shown in Remote, HSM & Deferred Signing and, for the cloud side, in AWS Secrets Manager & KMS, Azure Key Vault & Secrets and Google Cloud Secret Manager & KMS.

  • Read the keystore password from the environment or the secret manager of the platform, never from source, and never log it.

  • Reserve enough space. A container that does not fit fails; one that is much too large wastes size in every file. The contentSize of DSS and setPreferredSignatureSize of PDFBox are both estimates to be measured for your chain, and they must be raised when timestamps and several certificates are embedded.

  • Build the DSS service once at start-up and reuse it, and keep the expensive part, the trusted list validation, in a scheduled job and not in a request. Check the DSS documentation before sharing one service between threads; this page did not test that.

  • Treat a timestamp server as a dependency: set time-outs, retry, and fall back to the next TSA, because a signing call that waits for an unavailable TSA blocks the invoice run.

  • Choose algorithms deliberately. The example uses RSA 3072 with SHA-256; ETSI TS 119 312 V2.1.1 recommends RSA-PSS or ECDSA, and DSS selects the encryption algorithm from the key and the digest from setDigestAlgorithm.

  • Use saveIncremental, never save, for a file that is already signed.

  • Do not assume that a signature that a viewer shows as valid is long-term valid: a viewer may fetch revocation data online when it opens the file, and a file that relies on that does not carry it, so it is not B-LT.

Outdated points to avoid

  1. Code from the PDFBox 2.0 era: PDDocument.load(file) is Loader.loadPDF(file) in 3.0; PDFont constants such as PDType1Font.HELVETICA are constructed from Standard14Fonts.FontName (new PDType1Font(Standard14Fonts.FontName.HELVETICA)); the designer classes of the 2.0 visible-signature examples are not what the official 3.0 example uses, which builds a template PDF as shown above.

  2. The old PDFBox SignatureInterface examples that use adbe.pkcs7.sha1, SHA-1 digests or SHA1withRSA (finding O11 of this section’s source analysis): SHA-1 is forbidden for new signatures, and adbe.pkcs7.sha1 is deprecated. Use SHA-256 or better, and ETSI.CAdES.detached for PAdES.

  3. Using the signature algorithm of the certificate (getSigAlgName) as the algorithm of the signer without thinking: it is the issuer’s algorithm.

  4. Signing with a CMS container that lacks signing-certificate-v2 and calling the result PAdES.

  5. Believing that PDFBox signs at a PAdES level by itself: it writes the file; the level is determined by what you put in the container and the document security store.

  6. Believing that JasperReports can sign: it cannot; export first, then sign the bytes (finding O27).

  7. Using org.apache.pdfbox:pdfbox-examples as a dependency: it was not published for 3.0.8.

  8. Treating a DSS extension with LogOnStatusAlert as a successful B-LT: the alert is a demonstration setting.

  9. Using the free test TSA, or any server without a service agreement, for production signatures.

  10. Old tutorials with iText 1 or 2 signing code (PdfStamper.createSignature), found in books such as iText in Action, which was consulted and is not reproduced: it targets a different, removed API.