Digital signatures
A digital signature seals a PDF as it was at the moment of signing. The engine reads every signature in a document, tells you what changed after it, and signs a field for you. It never sees your private key: it hands you the fingerprint of the file (a digest), your key signs it, and the engine writes the result into the PDF.
import { sign, webCryptoSigner } from '@embedpdf/core-signature';
const { signatures } = await doc.signatures.list();
const field = signatures.find((signature) => !signature.signed)!.field;
await sign(doc, {
field,
key: webCryptoSigner({ privateKey, certificateChain }),
signer: { name: 'Ada Lovelace', reason: 'Approved' },
});Signing needs doc.sign, and doc.forms.read to find the field with list(); see
Permissions.
The cryptography (building a signature from a key, checking one against its certificate) lives in its own package, which works with both engines:
npm install @embedpdf/core-signature@nextSign a field#
sign() signs a field in one call. It gets the digest from the engine, has your key sign it,
checks the result and writes it into the document. When anything fails, it cancels, and the
document is as it was.
import { sign, webCryptoSigner } from '@embedpdf/core-signature';
const result = await sign(doc, {
field,
key: webCryptoSigner({ privateKey, certificateChain }),
signer: { reason: 'Approved', location: 'Amsterdam' },
});
result.signature; // the new signature, as list() returns itkey is what signs the digest, one of:
| Key | Use it when |
|---|---|
webCryptoSigner({ privateKey, certificateChain }) | You hold a WebCrypto key and its certificates (DER, the signer’s first). |
remoteSigner({ sign }) | A signing service or HSM turns a digest into a finished signature (CMS). |
createTestSigner() | Tests and demos: a throwaway key with a self-signed certificate. |
createTestTimestampAuthority() | Tests and demos: a throwaway timestamp authority. |
After signing, the document is the signed file: list() shows the new signature, and
doc.download() returns the signed PDF. When the signature your key returns doesn’t match what
the engine prepared, sign() fails with SignatureRefused and the message says why.
Options#
sign() and prepare() take these options (sign() also takes the key):
| Option | What it is | Default |
|---|---|---|
field | The signature field to sign. It must not be signed yet. | |
kind | 'signature', or 'timestamp' for a document timestamp. | 'signature' |
signer | { name, reason, location, contactInfo, signedAt }, written into the signature. | none; signedAt now |
appearance | { pdf }: a one-page PDF, drawn into the field’s box. | none |
certify | { permission: 1 | 2 | 3 }: certify the document. | not certified |
lock | Fields this signature locks. | none |
digest | 'sha256', 'sha384' or 'sha512'. | 'sha256', or the key’s hash with sign() |
subFilter | 'ETSI.CAdES.detached' or 'adbe.pkcs7.detached'. | 'ETSI.CAdES.detached' |
contentsSize | Bytes reserved for the signature, from 256 to 4 MiB. | 8192 |
The field needs a widget to carry an appearance; without one, the call fails with
SignatureRefused. A PDF with more than one page fails with InvalidArg, as for a
stamp; take the page you want out first with
doc.pages.extract().
Sign in two steps#
When the key is somewhere else (a signing service, a hardware key, another device), sign in two
steps. prepare() reserves room for the signature and returns the digest to sign; complete()
writes it in:
const prepared = await doc.signatures.prepare({
field,
signer: { name: 'Ada Lovelace', reason: 'Approved' },
});
// prepared.digest is the fingerprint to sign, with prepared.algorithm ('sha256').
const cms = await mySigningService.sign(prepared.digest, prepared.algorithm);
const result = await doc.signatures.complete({
signingId: prepared.signingId,
cms,
expectedVersion: prepared.expectedVersion,
});cms is a detached CMS signature (PKCS #7 or CAdES) over the digest: what signing services
return. To build one from a raw key yourself, use buildDetachedCms() from
@embedpdf/core-signature.
Between the two steps:
- The document is locked. Every other change fails with
SigningPendinguntil you complete or cancel; reading still works. - To give up, call
doc.signatures.cancel(prepared.signingId). It returnsstatus: 'cancelled', and acomplete()for that signing fails withNotFound. - Pass
expectedVersionback as it is. It ties the signature to the version it was made for; a different one fails withSigningVersionMismatch, and you prepare again.
complete() checks that cms is one signature that fits the space prepare() reserved
(contentsSize); if not, it fails with SignatureRefused. It doesn’t check the cryptography:
sign() does that before it calls complete(), and validateSignatures() does it afterwards.
Calling complete() again with the same cms returns the same result, with
status: 'already-completed'; a different cms fails with SignatureRefused.
A signature can’t be undone, and completing one ends undo for every change made before it; see Changes & undo.
A prepared signature waits until you complete or cancel it: prepared.expiresAt is null.
Certify a document#
The first signature in a document can certify it: the signer says which changes may follow.
await sign(doc, { field, key, certify: { permission: 2 } });permission | What may change afterwards |
|---|---|
1 | Nothing, except adding document timestamps and validation data. |
2 | Filling in the form and signing. |
3 | Filling in the form, signing and adding or changing comments. |
From the moment it completes, the engine refuses what the certification forbids, with
ProtectedDocument: every level refuses page changes, new or changed fields, attachments and
redaction; 1 and 2 also refuse annotations; 1 also refuses filling in. Certifying after a
signature already exists fails with SignatureRefused.
Lock fields#
A signature can lock fields, so nobody changes what the signer saw:
await sign(doc, {
field,
key,
lock: { action: 'include', fields: ['invoice.total', 'invoice.date'] },
});action is 'include' (lock the fields named), 'exclude' (lock all others) or 'all'. A name
covers the fields in its group: 'invoice' locks invoice.total too. Once signed, writing a
locked field fails with ProtectedDocument, and an import skips it.
A signature field in a group (groupId, see Signers and groups)
locks the rest of its group when you name no lock: the group’s other fields as they are when you
sign, but not its signature fields, which their signers still sign. Name a lock to choose
yourself.
Signature fields and their look#
A signature field is a form field of family 'signature'. Create one like any field (see
Creating form fields):
const { field } = await doc.forms.create({
family: 'signature',
name: 'approver',
widgets: [{ page: page.ref, rect: { x: 300, y: 682, width: 200, height: 60 } }],
});To draw a mark into a field without signing it, for example a handwritten signature in a viewer
that has no key, use setSignatureAppearance(). The field stays unsigned:
await doc.forms.setSignatureAppearance(field.ref, { pdf: artworkPdf });Once a field is signed, its look is part of what the signature seals, so
setSignatureAppearance() fails with ProtectedDocument. To sign with artwork, pass appearance to
sign() or prepare().
Read the signatures#
list() returns every signature field, signed or not, and what the signatures allow:
const { signatures, protection } = await doc.signatures.list();
for (const signature of signatures) {
console.log(signature.fieldName, signature.signed, signature.signer.name);
}| Field | What it is |
|---|---|
field | The signature field’s ref. Pass it to sign() and prepare(). |
fieldName | The field’s full name. |
signed | Whether the field holds a signature. |
signer | What the signer wrote: name, reason, location, contactInfo and signedAt. |
kind | 'signature', or 'timestamp' for a document timestamp. |
docMdp | The certification level the signature declares (1, 2 or 3), or null. |
widget | The field’s widget, with its page and ref, or null when the field has none. Where it shows is its row in doc.forms.list(). |
The signer fields are claims, the same ones you pass when you sign: anyone can write any name.
Who really signed is in the signature’s certificate, which
validateSignatures() checks. signedAt is the signer’s own clock.
protection says what the signatures allow from now on; see
Protection.
The signatures describe the file as it was opened, or as it was last signed. Changes you haven’t saved aren’t part of any signature until you sign.
Check the signatures#
validateSignatures() answers, for each signature: are the signed bytes intact, does the
signature match its certificate, do you trust that certificate, and what changed since.
import { validateSignatures } from '@embedpdf/core-signature';
const results = await validateSignatures(doc, {
trust: { anchors: async () => [rootCertificate] },
});
for (const result of results) {
console.log(result.signature.fieldName, result.summary);
}anchors returns the certificates you trust (DER bytes), for example your company’s root.
| Field | What it answers | Values |
|---|---|---|
integrity | Are the signed bytes unchanged? | 'valid', 'invalid', 'indeterminate' |
cryptography | Does the signature match its own certificate? | 'valid', 'invalid', 'unsupported' |
trust | Does the certificate lead to one you trust? | 'trusted', 'untrusted', 'unknown' |
modifications.verdict | What changed after signing, and is it allowed? | See What changed |
summary | All of the above together. | 'valid', 'valid-untrusted', 'invalid', 'indeterminate' |
summary is 'invalid' only on proof: bytes that changed, a signature that doesn’t match, or a
change the signature forbids. When something can’t be established, it’s 'indeterminate'.
Without trust, a good signature is 'valid-untrusted' at best.
By default the check looks at the saved file. Pass until: 'working-copy' to include the changes
you haven’t saved yet: the answer is what the file will say once you save it. A viewer uses this
to warn before a change breaks a signature.
What changed since a signature#
analyze() compares the document with the version a signature sealed, and says whether what
changed is allowed:
const analysis = await doc.signatures.analyze({
since: { signatureIndex: 0 },
until: 'working-copy',
});
console.log(analysis.verdict);| Verdict | Meaning |
|---|---|
'unchanged' | Nothing changed since the signature. |
'permitted' | Something changed, and the signature allows it, such as a field filled in or another signature. |
'forbidden' | A change the signature doesn’t allow, such as a rotated page. |
'indeterminate' | The engine can’t tell: part of the file can’t be read, or its structure is broken. |
With 'forbidden', the signature itself still checks out, but PDF apps such as Acrobat report
that the document was changed after signing. For what since and until take, see
Analyzing in detail.
Events#
| Event | Fired by |
|---|---|
signatures.prepared | prepare(), with the signingId and the field. |
signatures.completed | complete(), with what it returned. |
signatures.cancelled | cancel(). |
document.versioned | complete(): the document is now the signed file. Read revisions, digests and verdicts again. |
sign() fires the same events. See Events.
Permissions#
Reading signatures (list(), analyze(), getContents(), getDigest()) needs
doc.forms.read; downloadRevision() needs doc.download. Signing (prepare(), complete(),
cancel()) needs doc.sign, or fields:sign for the field’s group, and certifying also
doc.sign.certify. See Permissions.
A call the document’s own signatures forbid fails with ProtectedDocument, whatever the user’s
permissions. A field that can’t be signed (already signed, read-only, or locked by an earlier
signature) fails with SignatureRefused; the message says why.
Good to know#
Protection: what signatures allow#
protection from list() answers two different questions:
| Field | Question | Values |
|---|---|---|
enforced | What does the engine refuse? | A level, or null when no signer declared a limit. |
judged | What keeps the signatures valid? | A level, or null when nothing is signed. |
certification | The certifying signature and its permission. | null when the document isn’t certified. |
fieldLocks | The fields signatures locked. | A list, empty when no field is locked. |
A level is 'lta' (only document timestamps and validation data), 'fill' (filling in and
signing) or 'annotate' (also comments).
enforced is what the engine refuses. Only a certification or a field lock sets it; the calls it
refuses fail with ProtectedDocument. A plain approval signature sets nothing, so every change
stays possible.
judged is what keeps signatures valid in Acrobat. For an approval signature it’s 'annotate':
filling in, signing and commenting keep it valid, while a rotated page, for example, makes
Acrobat report that the document was changed.
Use allows() to hide what the session may not do; a document’s signatures can still refuse a
change, with ProtectedDocument:
if (!doc.security.allows('doc.pages.assemble')) hidePageTools();One save is always refused on a signed document: a rewrite (download({ mode: 'rewrite' }))
would remove every signature, so it fails with ProtectedDocument. The default incremental save
keeps them; see Saving.
Analyzing in detail#
since takes the signature’s position in list() ({ signatureIndex }), or a revision (see
Revisions and byte ranges). until is 'persisted' (the saved file,
the default), 'working-copy' (with your unsaved changes) or { revisionIndex }.
analysis.current.primary is the change that decided the verdict, with the rule that allowed or
forbade it. Show it next to a 'forbidden' verdict.
Revisions and byte ranges#
A PDF that keeps its signatures grows by appending: each save adds a revision at the end of
the file, and the older bytes stay as they were. A signature covers every byte of the revision it
was made in, except the hole it sits in (the signature dictionary’s /ByteRange and /Contents).
list() also returns revisions, oldest first, each with its index, its end (the byte length
of the file at that revision) and the signatureIndex that seals it. Each signature has its
revisionIndex and a coverage:
'whole-revision': it covers exactly one revision. That’s the normal case.'partial': its byte ranges are valid but seal no revision. It can’t be checked against later changes, soanalyze()from it fails withInvalidArg.'malformed': its byte ranges or its signature bytes can’t be used.
chainValid: false means the file’s cross-reference chain is broken or was rebuilt, so no fact
about its bytes can be trusted.
The raw bytes#
For your own verifier: getContents(field) returns the signature (DER, without padding),
getDigest(field, algorithm) hashes the bytes the signature covers ('sha1' works too, for old
signatures), and downloadRevision(index) returns the file exactly as it was at a revision.
How changes are judged#
- A signature is judged on the document as it is now against the revision it sealed. A change that was undone later doesn’t count, and an object written again with the same value isn’t a change.
- A certification also checks every revision in between, so a forbidden change stays forbidden even when a later revision hides it.
- Missing evidence is never a pass: a value the engine can’t read makes the verdict
'indeterminate'. - The rules follow what Acrobat reports.
exploratoryLevelasks what a level would allow; the answer (mode: 'exploratory') isn’t a verdict.detail: 'full'lists every changed object (large; for tools).
Unsaved changes when you sign#
Your unsaved changes become part of the signed version, so the signature seals them too. You don’t need to save before you sign.
Document timestamps#
kind: 'timestamp' makes a document timestamp instead of a signature. With sign(), the key is
your timestamp authority: a remoteSigner whose sign sends the digest to the authority and
returns its timestamp token (subFilter is 'ETSI.RFC3161'). A key you hold can’t make one, so
sign() fails with InvalidArg. With prepare() and complete(), the cms is the token over
prepared.digest.
const result = await sign(doc, { field, kind: 'timestamp', key: myTimestampAuthority });
result.signature.kind; // 'timestamp'Turning protection off#
For tools that must produce or test invalid files, create the engine with
localEngine({ signedDocumentPolicy: 'permit' }): the engine then refuses nothing a signature
forbids. judged and every verdict stay the same.
Your feedback goes directly to the documentation team.