---
title: "Sign a PDF with an external signature, HSM or remote signer in C#"
url: https://recursiveai.org/onepdf/cookbook/sign-pdf-external-signature/
description: "Sign a PDF in C# with an external signature from an HSM or a remote signing service: send the digest, put the CMS it returns into the PDF. Runnable code."
version: "1.17.0"
---

# Sign a PDF with an external signature, HSM or remote signer in C#

When the key lives in an HSM or a remote signing service that returns a whole CMS (PKCS#7) signature, sign in two steps. `AddSignaturePlaceholder` writes the signature field with an empty hole and gives you the digest to send; `PdfSignatureHole.Fill` writes the CMS that comes back into the hole. The second step needs only the file and the hole’s two offsets, so it can run later, in another process or on another machine.

```shell
dotnet new console -o SignExternal && cd SignExternal
dotnet add package RecursiveAI.OnePdf --version 1.17.0
curl -LO https://www.irs.gov/pub/irs-pdf/fw9.pdf
```

Put this in `Program.cs`. `SignDigest` stands in for your signer, with a self-signed test certificate, so that it runs anywhere:

```csharp
using System.Formats.Asn1;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using OnePdf;

if (args is ["prepare"])
{
    // Step 1: add the signature field, with an empty hole where the CMS goes.
    using PdfDocument doc = PdfDocument.Open("fw9.pdf",
        new PdfOpenOptions { AllowEditing = true });
    PdfSignaturePlaceholder p = doc.AddSignaturePlaceholder("fw9-signed.pdf",
        new PdfSigningOptions
        {
            FieldName = "Signature1",
            SignerName = "Ada Lovelace",
            Reason = "I certify the information above",
            PageIndex = 0,
            Rectangle = new PdfRectangle(120, 192, 380, 216),
        });
    // The two offsets are all that step 2 needs.
    File.WriteAllText("hole.txt", $"{p.HoleStart} {p.HoleEnd}");
    Console.WriteLine($"Hole for the CMS: bytes {p.HoleStart} to {p.HoleEnd}");
    Console.WriteLine($"Digest: {Convert.ToHexString(p.Digest)}");
}
else if (args is ["complete"])
{
    // Step 2, later or in another process: hash the file around the hole ...
    long[] hole = File.ReadAllText("hole.txt").Split(' ').Select(long.Parse).ToArray();
    byte[] digest;
    using (FileStream file = File.OpenRead("fw9-signed.pdf"))
        digest = PdfSignatureHole.ComputeDigest(file, hole[0], hole[1],
            HashAlgorithmName.SHA256);
    Console.WriteLine($"Digest: {Convert.ToHexString(digest)}");

    // ... send the digest to the signer and get a detached CMS back ...
    using X509Certificate2 signer = TestCertificate("CN=Ada Lovelace");
    byte[] cms = SignDigest(digest, signer);
    Console.WriteLine($"CMS from the signer: {cms.Length} bytes");

    // ... and write it into the hole. Nothing else in the file moves.
    PdfSignatureHole.Fill("fw9-signed.pdf", hole[0], hole[1], cms);

    // Verify. Trust is explicit: here the test certificate is its own root.
    using PdfDocument signed = PdfDocument.Open("fw9-signed.pdf");
    var options = new PdfSignatureVerifyOptions { TrustAnchors = [signer] };
    foreach (PdfSignatureVerification v in signed.VerifySignatures(options).Signatures)
        Console.WriteLine($"{v.Signature.FieldName}: valid={v.IsValid}, "
                        + $"chain={v.Chain}, coverage={v.Coverage}");

    // The bytes the signature covers: the whole file minus the hole.
    using Stream data = signed.GetSignatures()[0].OpenSignedData()!;
    bool same = SHA256.HashData(data).SequenceEqual(digest);
    Console.WriteLine($"Signed bytes: {data.Length}, hash matches the digest: {same}");
}

// Stand-in for your HSM or signing service: digest in, detached CMS out.
// It signs the CAdES signed attributes (content type, message digest and
// signing certificate) with the certificate's RSA key.
static byte[] SignDigest(byte[] digest, X509Certificate2 cert)
{
    const string Data = "1.2.840.113549.1.7.1", Sha256 = "2.16.840.1.101.3.4.2.1";
    var a = new AsnWriter(AsnEncodingRules.DER);
    using (a.PushSetOf())
    {
        Attribute(a, "1.2.840.113549.1.9.3", w => w.WriteObjectIdentifier(Data));
        Attribute(a, "1.2.840.113549.1.9.4", w => w.WriteOctetString(digest));
        Attribute(a, "1.2.840.113549.1.9.16.2.47", w => // signing certificate v2
        {
            using (w.PushSequence()) using (w.PushSequence()) using (w.PushSequence())
                w.WriteOctetString(SHA256.HashData(cert.RawData));
        });
    }
    byte[] attrs = a.Encode();
    using RSA key = cert.GetRSAPrivateKey()!;
    byte[] value = key.SignData(attrs, HashAlgorithmName.SHA256,
        RSASignaturePadding.Pkcs1);
    attrs[0] = 0xA0; // signed as a SET, stored as [0]

    var w = new AsnWriter(AsnEncodingRules.DER);
    var tag0 = new Asn1Tag(TagClass.ContextSpecific, 0);
    using (w.PushSequence())
    {
        w.WriteObjectIdentifier("1.2.840.113549.1.7.2"); // signed data
        using (w.PushSequence(tag0))
        using (w.PushSequence())
        {
            w.WriteInteger(1);
            using (w.PushSetOf()) Algorithm(w, Sha256);
            using (w.PushSequence()) w.WriteObjectIdentifier(Data); // detached
            using (w.PushSetOf(tag0)) w.WriteEncodedValue(cert.RawData);
            using (w.PushSetOf())
            using (w.PushSequence()) // signer info
            {
                w.WriteInteger(1);
                using (w.PushSequence())
                {
                    w.WriteEncodedValue(cert.IssuerName.RawData);
                    w.WriteInteger(cert.SerialNumberBytes.Span);
                }
                Algorithm(w, Sha256);
                w.WriteEncodedValue(attrs);
                Algorithm(w, "1.2.840.113549.1.1.1", withNull: true); // RSA
                w.WriteOctetString(value);
            }
        }
    }
    return w.Encode();
}

static void Attribute(AsnWriter w, string oid, Action<AsnWriter> value)
{
    using (w.PushSequence())
    {
        w.WriteObjectIdentifier(oid);
        using (w.PushSetOf()) value(w);
    }
}

static void Algorithm(AsnWriter w, string oid, bool withNull = false)
{
    using (w.PushSequence())
    {
        w.WriteObjectIdentifier(oid);
        if (withNull) w.WriteNull();
    }
}

// A self-signed certificate for trying this out. Viewers won't trust it.
static X509Certificate2 TestCertificate(string subject)
{
    using RSA key = RSA.Create(3072);
    var request = new CertificateRequest(subject, key,
        HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
    request.CertificateExtensions.Add(new X509KeyUsageExtension(
        X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.NonRepudiation, true));
    var now = DateTimeOffset.UtcNow;
    return request.CreateSelfSigned(now.AddDays(-1), now.AddYears(1));
}
```

Step 1 adds a visible signature field on the W-9’s “Sign Here” line and saves the offsets:

```shell
dotnet run -- prepare
```

```text
Hole for the CMS: bytes 141765 to 174535
Digest: B9162A3739554115510C6E1727DD5F4D082B316AD523B8B91BE2C36CDD5E9C93
```

Step 2 hashes the file around the hole, gets the CMS, fills it in and verifies the result:

```shell
dotnet run -- complete
```

```text
Digest: B9162A3739554115510C6E1727DD5F4D082B316AD523B8B91BE2C36CDD5E9C93
CMS from the signer: 1625 bytes
Signature1: valid=True, chain=Trusted, coverage=WholeDocument
Signed bytes: 142885, hash matches the digest: True
```

Only the 32-byte digest goes to the signer, and only the CMS comes back. With a real service, replace `SignDigest` with its client call: send `digest`, receive the detached CMS. The stand-in writes the CMS with `System.Formats.Asn1` because .NET’s `SignedCms` always hashes the content itself and can’t sign a digest it was handed. A service that builds the CMS does the same on its side.

`fw9-signed.pdf` carries a PAdES signature (`ETSI.CAdES.detached`) over the whole file except the hole, and page 1 shows a box on the signature line with “Digitally signed by Ada Lovelace”, the date and the reason. `OpenSignedData()` reads those signed bytes back from the file.

## Check it with another tool

Poppler’s `pdfsig` reads the same file independently:

```shell
pdfsig fw9-signed.pdf
```

```text
Digital Signature Info of: fw9-signed.pdf
Signature #1:
  - Signature Field Name: Signature1
  - Signer Certificate Common Name: Ada Lovelace
  - Signer full Distinguished Name: CN=Ada Lovelace
  - Signing Time: Oct 31 2026 23:09:59
  - Signing Hash Algorithm: SHA-256
  - Signature Type: ETSI.CAdES.detached
  - Signed Ranges: [0 - 141765], [174535 - 175655]
  - Total document signed
  - Signature Validation: Signature is Valid.
  - Certificate Validation: Certificate issuer is unknown.
```

The issuer is unknown only because the test certificate is self-signed.

## Which way to sign

- **The signer returns a raw signature** over a hash, as most HSMs, smart cards and cloud key services do: `PdfSigningCredential.FromExternalSigner` with `doc.Sign` is one call, and OnePDF builds the CMS. See [Sign a PDF in C#](https://recursiveai.org/onepdf/cookbook/sign-and-verify/).
- **The signer returns a whole CMS**, the key is one `Sign` doesn’t take (Ed25519, Ed448 or post-quantum), or signing happens later or elsewhere: use the placeholder, as on this page.

## Options

- **One process.** Skip `hole.txt`: send `p.Digest`, then call `p.Fill("fw9-signed.pdf", cms)`.
- **Hash.** `Hash = HashAlgorithmName.SHA384` in the options sets the algorithm of `p.Digest`; pass the same one to `ComputeDigest`.
- **Sub-filter.** The default is `ETSI.CAdES.detached` (PAdES). Set `SubFilter = "adbe.pkcs7.detached"` for a signer that returns plain PKCS#7.
- **Room for the CMS.** The hole holds 16,384 bytes of CMS by default. Pass `contentsReserve:` to `AddSignaturePlaceholder` for a long certificate chain or an embedded time-stamp (64 bytes to 4 MiB).
- **Invisible, certifying, field lock.** The same options as `Sign`: leave out `Rectangle` and `PageIndex` for an invisible signature, set `Certify` or `FieldLock` as needed. On a document certified with `PdfCertificationLevel.NoChanges`, a placeholder for a second signature is refused with `PdfSigningException` (`CertificationForbidsChanges`) before anything is written; set `AllowBreakingCertification = true` to sign anyway, knowing viewers will report the certification as broken.

## Limits

- **Not signed until filled.** Between the steps the file has an empty signature; don’t hand it out.
- **One fill per hole.** `Fill` checks the hole first. Wrong offsets or a hole already filled throw `PdfSigningException` (`BadHole`), and a CMS too large for the hole throws `ContentsTooSmall` with the size needed. Either way nothing is written. For a bigger hole, run step 1 again on the original file.
- **The CMS is yours.** OnePDF doesn’t add a time-stamp or choose the padding here; your signer does.
- **Verification keys.** OnePDF’s own verification checks RSA and ECDSA signatures. An Ed25519, Ed448 or post-quantum signature comes back as `NotChecked`, so check it with another tool.
- **No long-term validation data** (PAdES B-LT and B-LTA) is written.

The hole’s exact layout and every check `Fill` makes are in [Signatures](https://recursiveai.org/onepdf/docs/signatures/#two-phase-and-external-cms-signing).
