You flatten a signed PDF to lock the fields before archiving it, and two things go wrong at once: the signature graphic is missing from the page, and any viewer that checks the signature now reports it as invalid.
What is actually happening
A PDF signature is not a picture. It is a form field whose field type is /Sig, reachable from the document catalog through /AcroForm, and its visible mark comes from the appearance dictionary on that field's widget annotation (PDF Association TechNote 0006, sections 1.2 and 2.2). Flattening means merging field appearances into the page content stream and then dropping the annotations and the form dictionary. When /AcroForm goes, the /Sig field goes with it, and so does the mark.
The second failure runs deeper. The signature dictionary carries a /ByteRange entry: an array of integer pairs describing exactly which spans of the file the digest was computed over. Validation recomputes the digest over those spans and compares it against the stored value, and a difference means the document changed after it was signed. Flattening rewrites the whole file, so those offsets no longer describe anything real. The documented way to change a signed PDF is an incremental update, appended to the end of the file, which leaves the originally signed bytes where they were. A full rewrite is not an incremental update.
The fix
Flatten first, sign second. If you already hold the signed file, keep it as the authoritative copy and produce any flat version as a separate, clearly non-authoritative render, never overwriting the original.
To stop the mistake reaching production, check before you flatten:
from pypdf import PdfReader
def signature_fields(path):
"""List signature fields and whether each one still covers the whole file."""
reader = PdfReader(path)
size = len(open(path, "rb").read())
acroform = reader.root_object.get("/AcroForm")
fields = acroform.get_object().get("/Fields", []) if acroform else []
out = []
for ref in fields:
field = ref.get_object()
if field.get("/FT") != "/Sig":
continue
value = field.get("/V")
br = value.get_object().get("/ByteRange") if value else None
out.append({
"name": str(field.get("/T")),
"signed": value is not None,
"byte_range": list(br) if br else None,
"covers_whole_file": bool(br) and br[0] == 0 and br[2] + br[3] == size,
})
return outOn a file that carries a signature field this returns one row per signature. On the flattened output it returns an empty list, which is the entire problem in one line.
One caveat: covers_whole_file coming back false is not proof of tampering. A document that was signed, updated, and signed again holds several signatures, and by design the earlier ones cover only the revision that existed when they were applied, so they will legitimately report less than the full file. Even a properly appended incremental update can make some viewers flag a signature, depending on what changed. Treat this as a guard rail against destructive rewrites, not as signature validation. Real validation means recomputing the digest and checking the certificate chain.
Back to All Questions