Sign a PDF Report Digitally with Python
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.
soffice --headless --convert-to pdf monthly_report.xlsx > /dev/null 2>&1 && ls -la monthly_report.pdf | awk '{print $5, $9}'
Prerequisites
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)
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
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.
Step 3 — Add a visible signature box
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
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"))
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.
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
.p12and 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.
Related
- Up one level: Exporting Excel Reports to PDF — PDF output from Excel.
- Convert Excel to PDF on Linux with LibreOffice — exporting before signing.
- Password Protect an Excel File with Python — protecting the workbook itself.
- Add Headers, Footers and Page Numbers to an Excel PDF — finish the PDF before signing.
More on pdf export
- How-toFill PDF Forms from Excel Rows with Python
- How-toGenerate One PDF per Excel Row
- How-toAdd Bookmarks and a Cover Page to an Excel PDF Export
- How-toAdd Headers, Footers and Page Numbers to an Excel PDF
- How-toCheck a PDF Export for Cut-Off Columns
- How-toConvert an Excel File to PDF with Python