If you build document workflows, electronic signature tools, or legal intake pipelines, you have probably run into this high-stakes customer complaint: a user signs a PDF agreement, a downstream process or web viewer fills in a remaining approval timestamp or adds an annotation, and saves the file. When the client opens the result in Adobe Acrobat, a bright warning banner appears:
"At least one signature has problems: The document has been altered or corrupted since it was signed."
The user assumes the software corrupted their file. In reality, the document's cryptographic integrity was intentionally tripped. Unlike simple web forms or office files, PDF digital signatures do not sign an abstract data payload alone. They sign exact byte ranges of the physical file on disk.
Understanding this behavior requires looking at how the PDF standard structures byte ranges, why common save operations break them, and how incremental updates preserve cryptographic integrity.
1. How /ByteRange works in ISO 32000-1
In ISO 32000-1 (§12.8), a digital signature is represented by a signature dictionary (/Sig) embedded inside a signature field widget annotation.
Unlike formats that sign a detached hash of raw text, a PDF digital signature computes a cryptographic digest (typically SHA-256) over the binary file itself. But there is a circular dependency: how can a PDF contain its own cryptographic signature without altering the hash of the file that contains it?
PDF resolves this via the /ByteRange array:
/ByteRange [ 0 84000 96000 24000 ]
/Contents <3082046b06092a864886f70d010702a082045c...>
The /ByteRange array consists of four numbers representing two continuous byte intervals:
-
0: The starting byte of the first range (the very beginning of the PDF). -
84000: The length in bytes of the first range. -
96000: The starting byte of the second range. -
24000: The length in bytes of the second range (extending to the end of the file).
The gap between byte 84000 and byte 96000 is the exact space allocated for the /Contents hexadecimal string, which stores the PKCS#7 / CMS cryptographic signature container.
When a viewer validates the signature:
- It reads the
/ByteRangeoffsets. - It hashes bytes
[0 to 84000]concatenated with[96000 to 120000]. - It extracts the public key from the embedded certificate in
/Contentsand verifies the signed digest against the recomputed hash.
If a single byte within those two ranges changes, even by 1 bit, the hash fails immediately.
2. Why standard file saves invalidate signatures
When a developer opens a signed PDF using a typical PDF library or in-browser editor and calls save() or saveToBytes(), the library usually performs a full serialization rewrite:
- Unused objects are garbage collected.
- PDF object IDs may be renumbered sequentially.
- Cross-reference tables (
xref) or cross-reference streams are reconstructed. - Document streams may be recompressed with FlateDecode.
Even if the visual appearance of the document looks identical, every physical byte offset in the file has moved. Because the original bytes covered by the initial /ByteRange no longer exist at their designated offsets, signature validators report that the document has been tampered with or corrupted.
3. The compliant solution: incremental updates
The PDF specification anticipated this challenge through Incremental Updates (ISO 32000-1 §7.5.6).
Rather than rewriting the entire file from byte 0, an incremental update appends new changes directly to the very end of the existing file:
-
Preserve Original Bytes: The bytes from
0to the original%%EOFmarker are left 100% untouched. -
Append Modified Objects: Any changed objects (such as an updated field value
/V, a new annotation, or a second signature widget) are assigned the same object number and appended after the old%%EOF. -
Append New Cross-Reference Table: An incremental
xrefsection is appended, pointing to the new byte offsets of the modified objects. -
Append New Trailer: A new trailer dictionary is appended, containing a
/Prevpointer holding the exact byte offset of the previous cross-reference table.
When a conforming viewer loads an incrementally updated PDF, it reads backwards from the final %%EOF, follows the /Prev chain to assemble the latest document state, but still evaluates the original signature against the untouched byte ranges of the first revision.
+------------------------------------------+
| Original Document Revision (Bytes 0..N) | <-- First /ByteRange covers this
| Includes Signature 1 (/Sig) |
| %%EOF (Revision 1) |
+------------------------------------------+
| Incremental Update Section |
| - Updated Form Field or Signature 2 |
| - New xref table | <-- /Prev points to Rev 1 xref
| - New trailer & %%EOF (Revision 2) |
+------------------------------------------+
4. Modification permissions: DocMDP and FieldMDP
Preserving byte integrity through incremental saving is necessary, but it is not always sufficient. A document author can enforce semantic restrictions via DocMDP (Modification Detection and Permissions) or FieldMDP:
- P = 1 (No Changes Allowed): Any subsequent incremental change, including adding a form field value or an annotation, invalidates the certification signature. Only DSS (Document Security Store) timestamping for LTV (Long-Term Validation) is permitted.
- P = 2 (Form Filling and Signing Allowed): Incremental updates that fill existing form fields or add subsequent approval signatures remain valid. Adding page graphics or arbitrary annotations invalidates the signature.
- P = 3 (Form Filling, Annotations, and Signing Allowed): Incremental updates may add review comments, fill fields, and add signatures.
5. Detecting signatures before mutation
In automated document processing pipelines, you can prevent accidental signature invalidation by checking whether an incoming PDF contains digital signatures before choosing between a standard save and an append-only workflow:
import { PDFDocument, PDFName, PDFDict, PDFArray } from 'pdf-lib';
import fs from 'fs/promises';
async function checkDigitalSignatureStatus(pdfPath) {
const fileBytes = await fs.readFile(pdfPath);
const pdfDoc = await PDFDocument.load(fileBytes, { ignoreEncryption: true });
const catalog = pdfDoc.catalog;
const acroFormRef = catalog.get(PDFName.of('AcroForm'));
if (!acroFormRef) {
return { signed: false, signatures: [] };
}
const acroForm = pdfDoc.context.lookup(acroFormRef, PDFDict);
const fieldsRef = acroForm.get(PDFName.of('Fields'));
if (!fieldsRef) {
return { signed: false, signatures: [] };
}
const fields = pdfDoc.context.lookup(fieldsRef, PDFArray);
const signatures = [];
for (let i = 0; i < fields.size(); i++) {
const field = pdfDoc.context.lookup(fields.get(i), PDFDict);
const fieldType = field.get(PDFName.of('FT'));
// /Sig indicates a digital signature field
if (fieldType?.toString() === '/Sig') {
const sigValue = field.get(PDFName.of('V'));
if (sigValue) {
signatures.push({
fieldName: field.get(PDFName.of('T'))?.toString() || 'Unnamed',
signed: true
});
}
}
}
return {
signed: signatures.length > 0,
signatureCount: signatures.length,
signatures
};
}
6. Architecture takeaways for PDF pipelines
- Order Matters: Fill all automated template variables, generate barcode stamps, and flatten static annotations before applying cryptographic signatures.
- Refuse Naive Saves on Signed Inputs: If an ingestion worker must modify an already-signed file, verify that your PDF library supports true incremental appending. If only full-rewrite saves are available, reject the operation or explicitly notify the user that existing signatures will be invalidated.
-
Audit Revision History: When reviewing signed documents, use readers or APIs capable of traversing the
/Prevtrailer chain to inspect each historical revision exactly as it looked at the time of each signature.
Top comments (0)