Digitally signing PDFs in Node.js: how it works and where it breaks

ByteRange, CMS, PAdES levels B-T and B-LTA, RFC 3161 timestamps, where the private key should live, and why any edit after signing kills the signature. Verification code included.

Node.jsPDFSecurity

A digital signature on a PDF is not a picture of a signature and not a password on the file. It is cryptographic proof of two things: the document has not changed since it was signed, and whoever signed it holds a private key tied to an identified certificate. Everything else, the blue banner Acrobat draws included, is presentation.

Anyone issuing documents to businesses runs into this quickly. An electronic document sent to a customer needs a signature to carry weight, and the recipient needs a way to verify it. The engineering side is detailed and full of traps, most of them quiet: the file looks fine, a PDF reader opens it, and only Acrobat or pdfsig tells you the signature is broken.

What actually gets signed

Inside the PDF sits a signature dictionary. Two fields in it matter.

/Contents is a fixed-length hex string that will hold the signature. Before signing it is a hole full of zeros, known as the placeholder.

/ByteRange is an array of four numbers, [a b c d], meaning "from byte a for b bytes, and from byte c for d bytes". Those two spans cover the entire file except the /Contents hole. That explains everything odd here: you cannot sign a file that contains its own signature, so you sign everything but the hole.

What goes into the hole is a detached CMS structure (PKCS#7): the hash of the two spans, signed with the private key, along with the signer certificate and the chain. The document bytes themselves are not embedded, which is what detached means.

Two practical consequences follow. First, the placeholder length is fixed before you know the real signature size, so reserving 8KB and producing a larger CMS fails the signing step. Second, any byte that changes in the file after signing, metadata edits and file "optimization" included, breaks it.

PAdES and the levels

The relevant standard for PDF is ETSI's PAdES. Rather than reading the conformance tables, know the four levels, because they decide how long the signature stays verifiable.

Level Adds What it buys
B-B The basic signature Integrity and origin, here and now
B-T A timestamp from a TSA Proof the signature predates a point in time
B-LT Certificates and revocation data embedded Verification when OCSP is down or the certificate expired
B-LTA Archive timestamps Repeated extension before an algorithm weakens

B-B is enough to say "this document has not changed". It is not enough to answer, five years from now, whether the certificate was valid at the moment of signing. Accounting documents are kept for seven years, so B-T is the sane minimum and B-LT is what you actually want.

The minimum code

In Node, the @signpdf packages are the short path. They separate preparing the placeholder from producing the signature, which is the right split.

import { readFileSync } from 'node:fs';
import signpdf from '@signpdf/signpdf';
import { P12Signer } from '@signpdf/signer-p12';
import { plainAddPlaceholder } from '@signpdf/placeholder-plain';

const withPlaceholder = plainAddPlaceholder({
  pdfBuffer: readFileSync('invoice.pdf'),
  reason: 'Invoice issued',
  contactInfo: '[email protected]',
  name: 'Example Ltd',
  location: 'Tel Aviv',
  signatureLength: 16384,
});

const signer = new P12Signer(readFileSync('cert.p12'), { passphrase: process.env.P12_PASS });
const signed = await signpdf.sign(withPlaceholder, signer);

Watch signatureLength. The default is comfortable until you add a timestamp and a long certificate chain, at which point the CMS grows and signing fails on some runs and not others. Leave headroom, and measure the real size against the production certificate rather than a short test one.

If the document is built with pdf-lib, there is a dedicated placeholder that works through the document object instead of patching raw bytes, and that is the cleaner option when you are already constructing the file in code.

pdf-lib itself does not sign. It carries no CMS cryptography and does not pretend otherwise. It is the tool for producing the file, not for signing it.

Timestamps

Without a timestamp, all you have is the clock on the machine that signed. That is worthless: it can lie, and the certificate will expire at some point, leaving no way to prove the signature happened while it was still valid.

An RFC 3161 timestamp fixes that. You send a hash of the signature to a TSA, get back a signed token saying "this hash existed at this time", and embed it as an unsigned attribute inside the CMS.

Many public TSAs require registration or throttle requests. In a service issuing thousands of documents a day that matters: queue, retry with backoff, and a clear policy for a TSA outage. The choice is between failing the issue and producing a B-B signature to be upgraded later. Both are defensible, and the decision belongs in design rather than in an incident.

Where the private key lives

A .p12 file on disk with the passphrase in an environment variable is what every example shows, and also where keys leak from. Three options, in increasing seriousness.

The local file suits development and tests. In production it means anyone who reaches the container, a backup, or a crash log reaches the key.

A managed KMS, AWS KMS or Cloud KMS, holds an asymmetric key that never leaves. You send a hash and get a signature back. The cost: @signpdf expects a Signer that returns a complete CMS, so you build the CMS yourself with PKI.js and delegate only the raw signing operation to KMS. That is a few dozen lines, not more.

An HSM over PKCS#11 is what you need when the certificate is an organizational one and the CA's policy requires hardware storage. It is also the most expensive to operate.

In every case: keep the .p12 out of the Docker image, keep the passphrase out of error output, and rotate the certificate before it expires rather than after.

A second signature, and why editing kills one

PDF supports incremental updates: bytes are appended to the end of the file instead of rewriting it. That mechanism is what allows a second signature, a customer countersigning a document you already signed. The first signature still covers the old bytes, and the second covers everything.

It follows that any tool which rewrites the file rather than appending breaks the first one. That includes things that do not look like edits: a round trip through pdf-lib with a plain save(), compression, merging, adding metadata, and sometimes an "optimizer" someone dropped into the pipeline to shrink files.

The only order that works is to produce the final file completely, then sign. If the earlier piece here on PDF generation applies to you, this is the join: Chromium produces the document, and signing follows immediately, with nothing in between.

To forbid later changes, use DocMDP, which sets a permission level: no changes at all, form filling allowed, or comments allowed too. For an accounting document the sane default is the first.

Verifying

Real verification is five steps, and each fails differently.

Read /ByteRange and confirm it genuinely covers the whole file except the hole. A signature that covers only half the document is a known trick, and it looks valid to any tool that skips this check.

Hash the two spans and compare against what the CMS signed.

Verify the signature against the public key in the certificate.

Build the certificate chain up to a trust anchor you recognise, and check revocation through OCSP or a CRL. A compromised, revoked certificate still produces a signature that passes the first three steps.

Check the timestamp, and confirm the certificate was valid at that moment.

For a quick command-line check, pdfsig from poppler does most of this:

pdfsig -nocert signed.pdf

In code, PKI.js parses the CMS and gives you all five steps. A public verification page should present the result in human language: who signed, when, and whether the uploaded file is identical to the signed one. If it is not, the answer should be "no" with no qualifiers. A page that shows a green tick because it found a signature dictionary is worse than nothing.

What it does not solve

A signature proves the document has not changed and that the signer held the key. It says nothing about whether the amount is right, whether the transaction happened, or whether the signer was authorised to sign. Those are questions of permissions and process, not cryptography, and a system that treats the signature as an answer to them has only moved the problem somewhere harder to see.