Validate PDF digital signatures in C#

ValidateSignatures checks every digital signature of a PDF and returns one result each: whether the signed bytes are intact, whether the certificate chains to a trusted root, whether anything was added after signing, and a Status that sums it up.

Compiled and run: Linux ARM64 · Linux x64 · macOS ARM64 · Windows x64

Video walkthrough · 4:32 · Watch on YouTube · Short · 1:08
§STEP BY STEP

Build it step by step.

  1. 1Install the NuGet packages

    SelectPdf.Universal is the library. The native package carries the Chromium engine for one platform: add the one for the platform you run on.

    terminal
    dotnet add package SelectPdf.Universal
    dotnet add package SelectPdf.Universal.Native.win-x64
    terminal
    dotnet add package SelectPdf.Universal
    dotnet add package SelectPdf.Universal.Native.linux-x64
    terminal
    dotnet add package SelectPdf.Universal
    dotnet add package SelectPdf.Universal.Native.linux-arm64
    terminal
    dotnet add package SelectPdf.Universal
    dotnet add package SelectPdf.Universal.Native.osx-arm64
  2. 2Create a console app

    Any .NET 8 or .NET 10 project works. To follow along, create a console app, run the install commands above inside its folder, then replace Program.cs with the code below.

    terminal
    dotnet new console -n PdfSample
    cd PdfSample
  3. 3Import the namespaces

    Program.cs · lines 1–4
    using System.Security.Cryptography;
    using System.Security.Cryptography.X509Certificates;
    using SelectPdf.Universal;
    using SelectPdf.Universal.Drawing;

    The System.Security.Cryptography namespaces only make the test certificates. Validation itself needs SelectPdf.Universal.

  4. 4Make a test CA and a signer certificate

    Program.cs · lines 6–23
    // 1. Test certificates: a certificate authority (CA) and a signer certificate it issued.
    // In production the signer's certificate comes from your CA or a public one.
    DateTimeOffset now = DateTimeOffset.UtcNow;
    using RSA caKey = RSA.Create(2048);
    var caRequest = new CertificateRequest("CN=Example Test CA", caKey,
        HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
    caRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
    using X509Certificate2 ca = caRequest.CreateSelfSigned(now.AddDays(-1), now.AddYears(5));
    
    using RSA signerKey = RSA.Create(2048);
    var signerRequest = new CertificateRequest("CN=Jane Doe, O=Example Ltd", signerKey,
        HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
    using X509Certificate2 issued = signerRequest.Create(ca, now.AddDays(-1), now.AddYears(1), [1, 2, 3, 4]);
    using X509Certificate2 signer = issued.CopyWithPrivateKey(signerKey);
    File.WriteAllBytes("signer.pfx", signer.Export(X509ContentType.Pfx, "pfx-password"));
    // the CA certificate alone, without its private key: the root to trust
    using X509Certificate2 caCertificate = X509CertificateLoader.LoadCertificate(ca.RawData);
    File.WriteAllBytes("example-ca.pfx", caCertificate.Export(X509ContentType.Pfx, "ca-password"));

    To keep the sample self-contained, .NET creates a certificate authority and a certificate it issues to Jane Doe. The signer's certificate and key go to signer.pfx; the CA's certificate alone, without its private key, goes to example-ca.pfx. In production the PDFs you check are signed by others and the CA is your organization's or a public one.

  5. 5Sign a PDF to check

    Program.cs · lines 25–35
    // 2. A signed PDF to check.
    HtmlToPdf converter = new HtmlToPdf();
    PdfDocument contract = converter.ConvertHtmlString("<h1>Service agreement</h1><p>Approved by:</p>");
    contract.Save("contract.pdf");
    contract.Close();
    PdfSecurityManager signing = new PdfSecurityManager();
    signing.Load("contract.pdf");
    signing.Sign("signer.pfx", "pfx-password", "Signature1", "Approved", "London", "",
        0, new RectangleF(40, 60, 260, 70));
    signing.Save("contract-signed.pdf");
    signing.Close();

    HtmlToPdf makes a short contract and PdfSecurityManager.Sign signs it into contract-signed.pdf, as in the signing guide. In your application this is the signed PDF you received.

  6. 6Load the PDF and the roots to trust

    Program.cs · lines 37–41
    // 3. Load the signed PDF and validate every signature, trusting the CA as a root.
    PdfSecurityManager manager = new PdfSecurityManager();
    manager.Load("contract-signed.pdf");
    using PdfDigitalCertificatesCollection roots =
        PdfDigitalCertificatesStore.GetCertificates("example-ca.pfx", "ca-password");

    Load opens the signed PDF. PdfDigitalCertificatesStore.GetCertificates reads the CA certificate. Certificates passed to ValidateSignatures are trusted as roots in addition to the system's trusted store.

  7. 7Validate every signature

    Program.cs · lines 42–51
    foreach (PdfSignatureValidationResult result in manager.ValidateSignatures(roots))
    {
        Console.WriteLine($"{result.SignatureName}: {result.Status}");
        Console.WriteLine($"  signed by {result.SignerName} on {result.SigningTime}");
        Console.WriteLine($"  intact: {result.IsValid}, trusted: {result.IsTrusted}, " +
            $"covers the whole document: {result.CoversWholeDocument}");
        foreach (string message in result.Messages)
            Console.WriteLine("  " + message);
    }
    manager.Close();

    ValidateSignatures returns one PdfSignatureValidationResult per signature. IsValid says whether the signed bytes are intact, IsTrusted whether the certificate chains to a trusted root, CoversWholeDocument whether anything was added after it, and Status sums them up. Messages explains any check that failed. Here the output is Signature1: Valid, signed by Jane Doe, intact, trusted, covering the whole document.

  8. 8Run it

    terminal
    dotnet run

    The program writes contract-signed.pdf to the working folder. Without a license key, every page carries a trial watermark; set GlobalProperties.LicenseKey before the first conversion to remove it.

§COMPLETE PROGRAM

The complete Program.cs.

This is the exact file our samples pipeline compiled and ran on Linux ARM64 and Linux x64 and macOS ARM64 and Windows x64 to produce the output on this page.

Program.cs
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using SelectPdf.Universal;
using SelectPdf.Universal.Drawing;

// 1. Test certificates: a certificate authority (CA) and a signer certificate it issued.
// In production the signer's certificate comes from your CA or a public one.
DateTimeOffset now = DateTimeOffset.UtcNow;
using RSA caKey = RSA.Create(2048);
var caRequest = new CertificateRequest("CN=Example Test CA", caKey,
    HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
caRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
using X509Certificate2 ca = caRequest.CreateSelfSigned(now.AddDays(-1), now.AddYears(5));

using RSA signerKey = RSA.Create(2048);
var signerRequest = new CertificateRequest("CN=Jane Doe, O=Example Ltd", signerKey,
    HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
using X509Certificate2 issued = signerRequest.Create(ca, now.AddDays(-1), now.AddYears(1), [1, 2, 3, 4]);
using X509Certificate2 signer = issued.CopyWithPrivateKey(signerKey);
File.WriteAllBytes("signer.pfx", signer.Export(X509ContentType.Pfx, "pfx-password"));
// the CA certificate alone, without its private key: the root to trust
using X509Certificate2 caCertificate = X509CertificateLoader.LoadCertificate(ca.RawData);
File.WriteAllBytes("example-ca.pfx", caCertificate.Export(X509ContentType.Pfx, "ca-password"));

// 2. A signed PDF to check.
HtmlToPdf converter = new HtmlToPdf();
PdfDocument contract = converter.ConvertHtmlString("<h1>Service agreement</h1><p>Approved by:</p>");
contract.Save("contract.pdf");
contract.Close();
PdfSecurityManager signing = new PdfSecurityManager();
signing.Load("contract.pdf");
signing.Sign("signer.pfx", "pfx-password", "Signature1", "Approved", "London", "",
    0, new RectangleF(40, 60, 260, 70));
signing.Save("contract-signed.pdf");
signing.Close();

// 3. Load the signed PDF and validate every signature, trusting the CA as a root.
PdfSecurityManager manager = new PdfSecurityManager();
manager.Load("contract-signed.pdf");
using PdfDigitalCertificatesCollection roots =
    PdfDigitalCertificatesStore.GetCertificates("example-ca.pfx", "ca-password");
foreach (PdfSignatureValidationResult result in manager.ValidateSignatures(roots))
{
    Console.WriteLine($"{result.SignatureName}: {result.Status}");
    Console.WriteLine($"  signed by {result.SignerName} on {result.SigningTime}");
    Console.WriteLine($"  intact: {result.IsValid}, trusted: {result.IsTrusted}, " +
        $"covers the whole document: {result.CoversWholeDocument}");
    foreach (string message in result.Messages)
        Console.WriteLine("  " + message);
}
manager.Close();

Open the project on GitHub → this program and the variations below, ready to dotnet run

§OUTPUT

The PDF it produces.

First page of contract-signed.pdf, the PDF this sample produces
contract-signed.pdf, page 1

Download contract-signed.pdf ↓ the file this sample produced, unedited

§VARIATIONS

Variations you will need next.

Each variation is a separate file in the same project, compiled and run with the program above.

Detect a file that was changed after signing

Changing any byte the signature covers, here the location recorded with it, breaks the signature: Status is Invalid, IsValid is false, and the message says the document was altered or corrupted since the signature was applied.

TamperedCopy.cs
using System.Text;
using SelectPdf.Universal;

static class TamperedCopy
{
    public static void Run()
    {
        // change bytes the signature covers: the location recorded with it
        byte[] bytes = File.ReadAllBytes("contract-signed.pdf");
        byte[] from = Encoding.ASCII.GetBytes("(London)");
        byte[] to = Encoding.ASCII.GetBytes("(Paris!)");
        int at = bytes.AsSpan().IndexOf(from);
        to.CopyTo(bytes, at);
        File.WriteAllBytes("contract-tampered.pdf", bytes);

        PdfSecurityManager manager = new PdfSecurityManager();
        manager.Load("contract-tampered.pdf");
        using PdfDigitalCertificatesCollection roots =
            PdfDigitalCertificatesStore.GetCertificates("example-ca.pfx", "ca-password");
        foreach (PdfSignatureValidationResult result in manager.ValidateSignatures(roots))
        {
            Console.WriteLine($"{result.SignatureName}: {result.Status}, intact: {result.IsValid}");
            foreach (string message in result.Messages)
                Console.WriteLine("  " + message);
        }
        manager.Close();
    }
}

Two signatures, one after the other

After a second signature, the first is still intact but no longer covers the whole document: its Status is ValidWithWarnings with the message that content was added after it. The second signature, the last one, is Valid.

Countersigned.cs
using SelectPdf.Universal;
using SelectPdf.Universal.Drawing;

static class Countersigned
{
    public static void Run()
    {
        PdfSecurityManager signing = new PdfSecurityManager();
        signing.Load("contract-signed.pdf");
        signing.Sign("signer.pfx", "pfx-password", "Signature2", "Reviewed", "London", "",
            0, new RectangleF(320, 60, 260, 70));
        signing.Save("contract-countersigned.pdf");
        signing.Close();

        PdfSecurityManager manager = new PdfSecurityManager();
        manager.Load("contract-countersigned.pdf");
        using PdfDigitalCertificatesCollection roots =
            PdfDigitalCertificatesStore.GetCertificates("example-ca.pfx", "ca-password");
        foreach (PdfSignatureValidationResult result in manager.ValidateSignatures(roots))
        {
            Console.WriteLine($"{result.SignatureName}: {result.Status}, covers the whole document: {result.CoversWholeDocument}");
            foreach (string message in result.Messages)
                Console.WriteLine("  " + message);
        }
        manager.Close();
    }
}

Check whether the certificate was revoked

RevocationCheckEnabled = true asks the certificate's issuer, over OCSP or a revocation list, whether it was revoked. The test certificate names no revocation service, so RevocationStatus is Unknown and Status is Indeterminate. A certificate from a real CA names its services; an LTV-enabled signature carries the answer inside the PDF.

RevocationCheck.cs
using SelectPdf.Universal;

static class RevocationCheck
{
    public static void Run()
    {
        // ask the certificate's issuer whether it was revoked (OCSP or CRL, over the network)
        PdfSecurityManager manager = new PdfSecurityManager();
        manager.Load("contract-signed.pdf");
        manager.RevocationCheckEnabled = true;
        using PdfDigitalCertificatesCollection roots =
            PdfDigitalCertificatesStore.GetCertificates("example-ca.pfx", "ca-password");
        foreach (PdfSignatureValidationResult result in manager.ValidateSignatures(roots))
        {
            Console.WriteLine($"{result.SignatureName}: {result.Status}, revocation: {result.RevocationStatus}");
            foreach (string message in result.Messages)
                Console.WriteLine("  " + message);
        }
        manager.Close();
    }
}
§TROUBLESHOOTING

Common problems and fixes.

ValidWithWarnings: the certificate does not chain to a trusted root

Without the CA passed as a root, the same signature is ValidWithWarnings with the message "The signer's certificate does not chain to a trusted root." Pass your CA to ValidateSignatures(roots) or install it in the system's trusted store.

An empty result

ValidateSignatures returns an empty array when the document has no signatures.

The revocation check is slow or Unknown

It contacts the services the certificate names, over the network, and takes as long as they take to answer. With no network, or no service named, RevocationStatus is Unknown. Signatures made with EnableLtv carry the revocation data and are checked without going online.

The PDF is password-protected

Load it with Load(fileName, password); PdfSecurityManager also loads from a stream or a byte array.

§GOOD TO KNOW

Good to know.

  • ValidateSignatures checks every signature of a loaded document and returns a PdfSignatureValidationResult for each. Source
  • ValidateSignatures(roots) trusts the given certificates as roots, for example your organization's own certificate authority, in addition to the system's trusted store. Source
  • Status is Valid, ValidWithWarnings, Indeterminate or Invalid; Invalid means the signed content was changed, the signature is damaged or the certificate is revoked. Source
  • Each signature is checked against the bytes it was made over, so a signature followed by a later one, or by a form fill, is reported intact. Source
  • RevocationCheckEnabled asks the issuer of each signer's certificate, through OCSP or a certificate revocation list, whether it was revoked; it is off by default and needs network access. Source
?FAQ

Questions, answered.

Short answers about this guide. The SelectPdf.Universal documentation covers every class and option.

Load the PDF with PdfSecurityManager and call ValidateSignatures. Each result has IsValid (the signed bytes are intact), IsTrusted (the certificate chains to a trusted root), CoversWholeDocument and an overall Status.
Read its certificate with PdfDigitalCertificatesStore.GetCertificates and pass the collection to ValidateSignatures. Those certificates are trusted as roots in addition to the system's trusted store.
Yes. A change to the signed bytes makes the signature Invalid. Content added later, such as another signature, keeps it intact but CoversWholeDocument becomes false and the status ValidWithWarnings.
Yes. Set RevocationCheckEnabled to true before ValidateSignatures; RevocationStatus is then Good, Revoked or Unknown. The check uses OCSP or a certificate revocation list and needs network access, unless the signature is LTV-enabled.