---
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 and further signatures
  keep it valid; anything else — an annotation included — does not.

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 every revision after a signature (or after any revision) against the restrictions
in force: which objects moved, 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.verdict; // 'unchanged' | 'permitted' | 'forbidden' | 'indeterminate'
analysis.mode; // 'authoritative', or 'exploratory' when you passed a level
for (const step of analysis.steps) {
  step.older;
  step.newer; // the revision pair
  step.verdict;
  step.findings; // per object: the change, the rule, or the unexplained reference
}
```

Two 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.

A truncated value is never permitted and a broken chain is `indeterminate`: the analysis fails
closed. An object rewritten in a later revision *without a change in value* is judged a
modification at every level: Acrobat rejected such a rewrite of a page and a signed widget after
an approval signature. The engine's own writer never produces one — a save writes only what
changed, and an annotation added and removed again writes nothing — so the rule only ever fires on
files other producers wrote.

## 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 } — 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 `fill` when only approval signatures exist. It drives `analyze()` and every verdict. So an
  annotation added after an approval signature is *allowed* and then *judged forbidden* — the
  signature still verifies cryptographically, but a validator says the document changed in a way
  it does not permit, which is exactly what Acrobat says. A signer who wants comments to keep a
  signature valid certifies with `P=3`.

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.
