How-to
Automating Reporting WorkflowsDeep dive

Sign a PDF Report Digitally with Python

Add a cryptographic signature to a PDF exported from an Excel report with pyHanko, so readers can prove who issued it and that nobody changed it afterwards: load a certificate from a PKCS#12 file, sign with a reason and location, add a visible signature box, validate the result in Python and with pdfsig, and see exactly what a one-byte change does to it.

A PDF of a monthly report is easy to change. Anyone who receives it can edit a figure with a PDF editor and pass it on, and the copy looks exactly like the original. For most internal reports that does not matter, but for reports that leave the organisation — statements to clients, figures for a regulator, board packs that are archived as the record of a decision — readers sometimes need to know two things: that the document really came from the finance team, and that nobody has altered it since. A digital signature answers both. It is a cryptographic seal over the bytes of the file, made with a private key that only the signer holds, which PDF readers check automatically and which breaks if a single byte of the signed content changes. This page signs a PDF exported from an Excel report with the pyHanko library, adds a visible signature box, and validates the result, including a tampered copy. It belongs to Exporting Excel Reports to PDF.

Bash
soffice --headless --convert-to pdf monthly_report.xlsx > /dev/null 2>&1 && ls -la monthly_report.pdf | awk '{print $5, $9}'

Prerequisites

Bash
pip install pyhanko cryptography

The PDF to sign can come from any export route — LibreOffice, Excel itself or a PDF library. The signature needs a certificate and its private key, normally delivered as a password-protected PKCS#12 file (.p12 or .pfx). For production that certificate comes from a certificate authority or your organisation's internal one; for this example a self-signed certificate is created in step 1 so everything can run locally.

Step 1 — A certificate to sign with (test only)

Python
from datetime import datetime, timedelta, timezone
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.serialization import pkcs12

key = rsa.generate_private_key(public_exponent=65537, key_size=3072)
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Finance Reporting"),
                  x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Example Ltd")])
now = datetime.now(timezone.utc)
cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key())
        .serial_number(x509.random_serial_number())
        .not_valid_before(now - timedelta(minutes=5)).not_valid_after(now + timedelta(days=365))
        .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
        .sign(key, hashes.SHA256()))
with open("finance_signer.p12", "wb") as f:
    f.write(pkcs12.serialize_key_and_certificates(b"finance", key, cert, None,
                                                  serialization.BestAvailableEncryption(b"change-me")))
with open("finance_signer.pem", "wb") as f:
    f.write(cert.public_bytes(serialization.Encoding.PEM))
print(cert.subject.rfc4514_string(), "valid until", f"{cert.not_valid_after_utc:%Y-%m-%d}")

A self-signed certificate is enough to see how signing works, but readers' software will not trust it: Acrobat will show the signature as made by an unknown identity. For reports that go to outside readers, use a certificate from a provider on the Adobe Approved Trust List, which Acrobat trusts out of the box; inside an organisation, a certificate from the company's own certificate authority works when that authority is installed on readers' machines. Either way, the .p12 file holds the private key — treat it like a password, store it in the same secret store as other credentials, and never commit it to version control.

Step 2 — Sign the PDF

Python
from pyhanko.sign import signers
from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter

signer = signers.SimpleSigner.load_pkcs12("finance_signer.p12", passphrase=b"change-me")
meta = signers.PdfSignatureMetadata(field_name="FinanceSignature", reason="Issued by Finance — September 2026 figures",
                                    location="London", name="Finance Reporting")
with open("monthly_report.pdf", "rb") as src, open("monthly_report_signed.pdf", "wb") as out:
    signers.sign_pdf(IncrementalPdfFileWriter(src), meta, signer=signer, output=out)
print("signed")

pyHanko signs by appending to the PDF rather than rewriting it: the original bytes stay exactly as they were, and an incremental update adds a signature field and the signature value. The signature covers every byte of the file except the space reserved for itself, so any later change to the content is detectable. The reason and location are shown by PDF readers in the signature panel and are a good place to say what the signature means — "issued by", "approved by", "figures as at" — because a signature on its own proves who signed and that the file is unchanged, not what the signer intended.

What signing adds to the file The original PDF bytes are left untouched. An incremental update appends a signature field, and a hash of all the file's bytes except the signature itself is signed with the private key. Readers recompute the hash and check it with the certificate's public key. Original PDF bytes unchanged Incremental update signature field added Hash of the bytes all but the signature Signed with private key from the .p12 file Reader checks public key + hash

Step 3 — Add a visible signature box

Python
from pyhanko import stamp
from pyhanko.sign import fields

with open("monthly_report.pdf", "rb") as src, open("monthly_report_signed_visible.pdf", "wb") as out:
    w = IncrementalPdfFileWriter(src)
    fields.append_signature_field(w, sig_field_spec=fields.SigFieldSpec("FinanceSignature", on_page=0, box=(360, 40, 560, 95)))
    pdf_signer = signers.PdfSigner(meta, signer=signer,
                                   stamp_style=stamp.TextStampStyle(stamp_text="Signed by %(signer)s\n%(ts)s"))
    pdf_signer.sign_pdf(w, output=out)
print("signed with a visible box")

An invisible signature is checked by the reader and listed in its signature panel, but nothing appears on the page. A visible one also draws a box — here at the bottom right of the first page, with the coordinates in points from the lower-left corner — containing the signer's name and the signing time. For printed copies this is the only sign that the document was signed, though of course a printed signature box proves nothing on its own; the cryptographic check only works on the file. Place the box where the report has empty space, typically the foot of the cover page or the last page.

Step 4 — Validate the signature, and break it

Python
from pyhanko.keys import load_cert_from_pemder
from pyhanko.pdf_utils.reader import PdfFileReader
from pyhanko.sign.validation import validate_pdf_signature
from pyhanko_certvalidator import ValidationContext

trust = ValidationContext(trust_roots=[load_cert_from_pemder("finance_signer.pem")])

def check(path):
    with open(path, "rb") as f:
        sig = PdfFileReader(f).embedded_signatures[0]
        status = validate_pdf_signature(sig, trust)
        return f"{path}: intact={status.intact} valid={status.valid} -> {status.summary()}"

print(check("monthly_report_signed.pdf"))

from pathlib import Path

data = bytearray(Path("monthly_report_signed.pdf").read_bytes())
i = data.find(b"/Producer")
data[i + 1] = ord("p")                             # change one byte inside the signed range
Path("monthly_report_tampered.pdf").write_bytes(data)
print(check("monthly_report_tampered.pdf"))
Bash
pdfsig monthly_report_signed.pdf | grep -E "Common Name|Signature Validation"
pdfsig monthly_report_tampered.pdf | grep "Signature Validation"

Validation answers three separate questions. Intact: do the signed bytes still match the hash in the signature? Valid: was that hash signed by the key belonging to the certificate? Trusted: does the certificate lead back to a root the checker trusts? The signed file passes all three here, because the test certificate was given to the validator as a trust root. Changing a single byte — one letter in the document's metadata — makes the hash no longer match: pyHanko reports the signature as not intact and the overall result as INVALID, and Poppler's pdfsig tool reports a digest mismatch. Note that valid stays true for the tampered copy. That is not a contradiction: the signature is still a genuine signature over the hash that was recorded at signing time, it is just that the file no longer matches that hash. This is why the summary, not any single flag, is what a job should check. Running the same check without the trust root reports the signature as intact but untrusted, which is exactly what a reader who does not know the self-signed certificate will see.

Validation results The signed file is intact, valid and trusted. After a one-byte change, the hash no longer matches, so the signature is reported as not intact and the overall result is invalid. Checked without the trust root, the unchanged file is intact but untrusted. A B C D E 1 File Intact Valid Trusted Summary 2 Signed yes yes yes INTACT:TRUSTED,UNTOUCHED 3 One byte changed no yes — INVALID 4 Signed, no trust root yes yes no INTACT:UNTRUSTED,UNTOUCHED Three questions a validator asks First whether the signed bytes still match the recorded hash, then whether the hash was signed by the key of the certificate, then whether the certificate chains to a trusted root. Only a yes to all three gives a fully valid signature. Intact? bytes still match the hash Valid? hash signed by the certificate's key Trusted? certificate chains to a known root

Signing in a scheduled job

In an automated report job, signing is the last step after the PDF is exported and checked, and before it is sent or archived. Load the .p12 passphrase from the environment or a secret store rather than the script, and validate each signed file straight after signing, with the real trust roots, so a broken certificate chain or an expired certificate fails the job instead of producing files that readers' software flags. Certificates expire — commonly after one to three years — so add a check that warns a month before not_valid_after. For files that must remain verifiable for many years, such as archived statements, pyHanko can also add a trusted timestamp from a timestamping authority and long-term validation data, which keep the signature checkable after the certificate has expired; that is worth combining with PDF/A, described in Export an Excel Report to PDF/A for Archiving.

Common pitfalls

  • Editing the PDF after signing. Add page numbers, stamps and metadata first, then sign.
  • A self-signed certificate for outside readers. It shows as untrusted; use a CA certificate.
  • The private key in the repository. Keep the .p12 and its passphrase in a secret store.
  • Assuming a printed box proves anything. Only the file can be verified.
  • Expired certificates. Monitor expiry and add timestamps for long-term records.

Conclusion

To sign a PDF report in Python, load the signer's certificate and key from a PKCS#12 file with pyHanko, sign with a reason and location that say what the signature means, optionally add a visible box, and validate the result straight away. Any later change to the file breaks the signature, which pyHanko, pdfsig and Acrobat all report.

Frequently asked questions

How do I digitally sign a PDF in Python? Use pyHanko: load a signer with SimpleSigner.load_pkcs12, then call signers.sign_pdf with an IncrementalPdfFileWriter over the original file.

Why does Acrobat say the signer's identity is unknown? The certificate is not trusted on that machine — usually because it is self-signed or from an internal CA that is not installed; use a certificate from a trusted provider.

Can a signed PDF be changed? Any change to the signed bytes makes the signature invalid; later incremental updates such as further signatures are allowed and are reported separately.

How do I verify a PDF signature from the command line? Run pdfsig file.pdf from Poppler, which shows the signer and whether the signature and certificate are valid.

More on pdf export

More on pdf export →