---
title: "Digital signatures"
description: "Read what is signed and what it seals, judge what changed since, sign a field in two phases, and validate — locally or through the cloud engine's immutable versions."
source: "https://www.embedpdf.com/docs/engine/core-concepts/signatures"
---

# Digital signatures

A digital signature is a fact about **bytes**: a `/ByteRange` and a `/Contents` that seal a byte
prefix of the file. The engine reads those facts, judges what changed after them, builds and seals a
signing candidate, and installs the sealed bytes as the document's new version. It never holds a key:
the cryptography (building and verifying the CMS, judging trust) lives in
`@embedpdf/core-signature`, which you bring a signer to.

Three ideas carry everything below:

- **A revision is a byte prefix** defined by the cross-reference chain. A signature covers the
  revision it was written in (`coverage: 'whole-revision'`) or it covers something else, and the
  engine tells you which.
- **Signing seals.** A completed signature does not edit the document; it produces a *new version*
  of it. Locally the session moves to the sealed bytes; on the cloud the document's head moves to a
  new immutable version and every layer sits over it.
- **A signature raises two questions.** What a signer *declared* (a certification's DocMDP level, a
  field lock) is subtracted from what the caller may do, like encryption permission bits. What a
  validator will *conclude* about later changes is a policy, and the engine judges the way the
  recipient's validator does: after a plain approval signature, form fill-in, further signatures and
  commenting keep it valid (Acrobat's own wording under such a signature); a page or field
  structure change does not. The rules were checked case by case against Acrobat on a synthetic
  corpus of 91 signed documents (`signature-compat` in the engine's test fixtures); the few places
  where the engine is deliberately stricter are recorded there.

Everything here is on `doc.signatures`. It is `undefined` on an engine that does not implement
signatures, so check once:

```ts
if (!doc.signatures) throw new Error('this engine cannot sign');
```

## Reading

`list()` returns the revisions, every signature field with its signed state, and the protection in
force:

```ts
const snapshot = await doc.signatures.list();

snapshot.chainValid; // false = the xref chain is broken; every byte fact is indeterminate
snapshot.revisions; // oldest first: { index, end, xrefOffset, signatureIndex }
for (const sig of snapshot.signatures) {
  sig.fieldName; // 'sig'
  sig.signed; // has a /V
  sig.coverage; // 'whole-revision' | 'partial' | 'malformed' | null
  sig.revisionIndex; // which revision it seals (whole-revision only)
  sig.signer; // { name, reason, location, contactInfo, claimedTime } — claims, unverified
  sig.docMdp; // 1 | 2 | 3 when it certifies
  sig.catalogCertification; // /Root /Perms /DocMDP names it
  sig.fieldMdp;
  sig.lock; // FieldMDP transform / field /Lock
}
snapshot.protection; // { enforced, judged, certification, fieldLocks, policyVersion }
```

The bytes a verifier needs are read straight from the loaded file:

```ts
const cms = await doc.signatures.contents({ kind: 'fqn', name: 'sig' }); // DER /Contents, padding stripped
const digest = await doc.signatures.digest({ kind: 'fqn', name: 'sig' }, 'sha256'); // hash of the /ByteRange
const revision = await doc.signatures.revisionBytes(0); // the exact bytes revision 0 signed
```

> Every read describes the bytes the document was *loaded* from. Unsaved edits are not part
> of any revision until `prepare` snapshots them.

## What changed since a signature

`analyze()` judges the document as it is now against the revision a signature sealed — its *net
state*, the way Acrobat judges an approval signature: a page changed and byte-restored later is
unchanged, and each signature has its own reference point. A certification is also replayed
revision by revision, so an intermediate violation invalidates it even when the final state no
longer shows it. The result names which objects changed, what each change is, and the rule that
permits it — or the reference no rule explains.

```ts
const analysis = await doc.signatures.analyze({ since: { signatureIndex: 0 } });

analysis.current.verdict; // 'unchanged' | 'permitted' | 'forbidden' | 'indeterminate'
analysis.current.primary; // the finding that decided it
analysis.current.findings; // per object: the change, the rule, or the unexplained reference
analysis.restrictions; // the certification level and this signature's own locks it was judged under
analysis.later.revisionCount; // revisions appended after the sealed one
analysis.later.undoneObjectNumbers; // objects changed in between that hold the sealed value again
analysis.mode; // 'authoritative', or 'exploratory' when you passed a level
```

Three options shape the question:

- `until`: `'persisted'` (default) judges the saved revisions; `'working-copy'` snapshots the
  session's unsaved edits as one more revision first, which is how a viewer previews "would this
  fill be allowed"; `{ revisionIndex }` stops at a revision.
- `exploratoryLevel`: evaluate as if a modification level (`'lta' | 'fill' | 'annotate'`) were in
  force. The result is tagged exploratory and never becomes a verdict.
- `detail`: `'summary'` (default) carries the verdict and its findings; `'full'` also carries
  every pairwise step between the two revisions (`analysis.steps`), with each step's changes.

A verdict is one of four words. `unchanged`: nothing about the document differs from what the
signature sealed — an object written again with the same value (Acrobat: "not modified") does
not count as a change. `permitted`: something changed, and every change is one the policy allows
(a fill, a further signature, a comment). `forbidden`: a proven violation. `indeterminate`: the
evidence to decide is missing — a truncated value, an unreadable object, a broken chain, or a
document Acrobat itself cannot verify later changes to (a first cross-reference section with
holes, a reachable object that is nothing but a reference). Missing evidence never becomes a
pass, and a violation is only proven on evidence.

## Signing: two phases

Signing is `prepare` then `complete`, with the CMS built in between by whoever holds the key.

```ts
import { buildDetachedCms, createTestSigner, profileFor } from '@embedpdf/core-signature';

const prepared = await doc.signatures.prepare({
  field: { kind: 'fqn', name: 'sig' },
  certify: { permission: 2 }, // a certification: fill and sign remain allowed
  attribution: { name: 'Bob Singor', reason: 'Approved' },
  appearance: { pdf: artworkPdf, pageIndex: 0 }, // a PDF page drawn into the widget
});

// prepared.digest is the hash over the sealed candidate's /ByteRange — sign THAT.
const signer = await createTestSigner(); // a throwaway self-signed key, for tests and demos
const cms = await buildDetachedCms({
  digest: prepared.digest,
  hash: prepared.algorithm,
  profile: profileFor(prepared.subFilter),
  signer,
});

const result = await doc.signatures.complete({
  signingId: prepared.signingId,
  cms,
  expectedVersion: prepared.expectedVersion,
});
result.version; // { sha256, byteLength } — the version the sealed bytes became
result.protection; // what the document's signatures forbid from now on
```

What happens underneath: `prepare` snapshots the document as it is (unsaved edits become their own
revision), writes the signature value with a reserved `/Contents`, seals the `/ByteRange`, and parks
the candidate. The live document is read-only until `complete` or `abort` (`SigningPending`).
`complete` verifies the CMS is one DER object that fits, writes it in, reads the sealed bytes back
through the signature model, and only then installs them. `expectedVersion` must be what `prepare`
returned (`SigningVersionMismatch` otherwise). A replay with the same CMS answers
`already-completed`; a different CMS is refused.

`sign()` does the three steps in one call, and aborts the candidate if anything fails:

```ts
import { sign, webCryptoSigner } from '@embedpdf/core-signature';

await sign(doc, {
  field: { kind: 'fqn', name: 'sig' },
  signer: webCryptoSigner({ privateKey, certificateChain }), // or a `CmsSigner` over your signing service
  attribution: { reason: 'Approved' },
});
```

A signer is either a `RawSigner` (you hold the key; the package builds the CMS) or a `CmsSigner`
(your service or HSM returns the CMS for the digest). Nothing but the digest ever leaves.

> Signing rides `doc.sign`; a certification additionally needs `doc.sign.certify`.
> A locked field, a certification that forbids the edit, or a required seed-value
> entry the engine does not implement all answer `SignatureRefused` with the reason
> in the message.

## Validating

`validateSignatures` combines the engine's byte facts, the CMS cryptography, trust, and the revision
analysis into one verdict per signature:

```ts
import { validateSignatures } from '@embedpdf/core-signature';

const verdicts = await validateSignatures(doc, {
  trust: { anchors: async () => [rootCertificateDer] }, // omit → 'valid-untrusted' at best
  until: 'working-copy', // judge unsaved edits too: what the file a save produces will say
});
for (const v of verdicts) {
  v.integrity; // 'valid' | 'invalid' | 'indeterminate' — the bytes vs. the signed digest
  v.cryptography; // the CMS verified with its own certificate
  v.trust; // chain to your anchors
  v.modifications; // { verdict, basis, laterRevisions?, undone? } — what changed after it, and whether the loaded bytes or the working copy were judged
  v.summary; // 'valid' | 'valid-untrusted' | 'invalid' | 'indeterminate'
}
```

## Protection: enforced vs. judged

`snapshot.protection` answers two different questions:

- **`enforced`** is what a signer declared: a certification's `/P`, a signed field's `/Lock` `/P`. The
  engine refuses what it forbids — a certified document loses page assembly and field authoring, a
  `P=2` certification refuses annotations, a `P=1` one refuses form fill, a FieldMDP-locked field
  refuses writes. The refusals surface as `ProtectedDocument`. A plain approval signature declares
  nothing: `enforced` is `null`, and nothing but a rewrite (which would erase the signature rather
  than invalidate it) is refused.
- **`judged`** is what a validator holds later changes to: the declared level, or the approval
  baseline `annotate` when only approval signatures exist. It drives `analyze()` and every verdict.
  So a field filled or a comment added after an approval signature is *allowed* and *judged
  permitted* — Acrobat reads such a signature as "Form Fill-in, Signing and Commenting are
  allowed" — while a text field added or deleted, a page edited, or a signed field's structure
  changed is *judged forbidden*: the signature still verifies cryptographically, but a validator
  says the document changed in a way it does not permit. A signer who wants to forbid comments
  certifies with `P=2`.

Read the policy back to grey out what a user cannot do before they try, and to warn about what they
can do but should not. An engine constructed with `signedDocumentPolicy: 'permit'` turns enforcement
off for tools whose job is to produce or test invalid files; judgement never depends on it.

## Signature fields and appearances

Signature fields are ordinary form fields of family `signature`. They can be authored:

```ts
await doc.forms.createField({
  family: 'signature',
  name: 'sig',
  widget: { pageObjectNumber, rect: { left: 50, bottom: 50, right: 250, top: 120 } },
});
```

A signature's appearance is a page of a PDF drawn into the widget: pass it to `prepare` as
`appearance`. To draw a mark into an unsigned field *without* signing — the visual "sign" of a viewer
that has no signer — use `doc.forms.setSignatureAppearance(ref, { pdf })`; the field stays unsigned and
the call is refused once the field is signed, because that appearance is sealed with the signature.

## On the cloud

Over `@cloudpdf/engine` the same calls work, and two things are worth knowing:

- A completed signature publishes a new **immutable version** of the document. `list()` and
  `analyze()` of the working copy read the layer; signed bytes (`contents`, `digest`,
  `revisionBytes`, history) are served per version and cached forever. Refresh your manifest after a
  completion: the base sha moved.
- A layer that fell behind the head (someone else published) cannot sign until it is rebased
  (`StaleBase`). A signing waits at most fifteen minutes for its CMS (`SigningExpired`).

## Events

| Event                 | Fired by                                                                               |
| :-------------------- | :------------------------------------------------------------------------------------- |
| `signature.prepared`  | `prepare`                                                                              |
| `signature.completed` | `complete`, with the full result                                                       |
| `signature.aborted`   | `abort`                                                                                |
| `document.versioned`  | a completed signature, here or (on the cloud) elsewhere: re-read every byte-level fact |

## Next

- [Forms](https://www.embedpdf.com/docs/engine/core-concepts/forms) — Signature fields are form fields: reading, authoring, widgets.
- [Security & access](https://www.embedpdf.com/docs/engine/core-concepts/security-and-access) — How scopes and a signed document's own protection gate every call.
