PDF Security Blog

Detect a Tampered PDF in Python Without the Original

HTPBE Team··12 min read
Detect a Tampered PDF in Python Without the Original

This article is a snapshot — content was accurate as of September 2026 (code examples tested against the API as of July 2026). The product evolves actively; specific counts, examples, and detection rules may have changed since publication — see the changelog for the current state.

A PDF lands in your intake queue — a bank statement, an invoice, a signed offer letter — and you have to decide whether to trust it. There is no “original” sitting in a database to diff against. The applicant emailed you one file. The vendor uploaded one file. That single PDF is all the evidence you have.

The usual advice is blunt: without the original, you cannot tell. That is half true. A PDF carries evidence of its own history inside the file itself, so you do not always need the original to spot that something was changed after the document was created. This tutorial reads those internal signals in Python with pypdf and pikepdf, explains what each one means, and is honest about where do-it-yourself heuristics break down.

One scope note before we start. This is tamper detection — finding evidence that a file was modified after it was first created. That is a different problem from confirming an identity or fact-checking the numbers on the page. We inspect the document’s structure; we do not ask a bank whether the balance is real.

This post is the by-hand, no-original angle. If you would rather skip straight to a hosted integration — submit a URL, get a verdict, route on it — the companion post PDF Tamper Detection in Python: Integrate in Under 50 Lines walks through the API instead.

Can you check if a PDF was edited without the original?

The common objection is that a single file has no baseline, so any change is invisible. That holds for one specific question: did the visible text change from some earlier draft? Without the earlier draft, you genuinely cannot diff pixel to pixel.

But that is not the only question worth asking. A more useful one is: does this file show evidence of having been written to more than once after it was created? That question does not need a baseline. The answer is recorded inside the PDF, because of how the format saves changes. A PDF is not a flat picture of a page — it is a structured container with several layers of bookkeeping, and editing tools leave fingerprints in that bookkeeping.

Three layers carry most of the signal:

  1. Metadata — who made the file, with what software, and when.
  2. Structure — the cross-reference machinery that records how many times the file was saved.
  3. Digital signatures — cryptographic seals, and whether anything happened after they were applied.

We read all three with mainstream libraries: pypdf for the friendly high-level metadata, and pikepdf (a binding over the battle-tested QPDF engine) when we need the lower-level structure.

pip install pypdf pikepdf

Layer 1: Metadata — Creator, Producer, and the two dates

Every PDF can carry an Info dictionary and an XMP metadata packet. Both describe the document’s provenance, and two pairs of fields are especially telling.

Creator vs Producer. The creator is the application a human used to author the document — Word, InDesign, a payroll system. The producer is the library or engine that actually wrote the PDF bytes — a PDF library, a print-to-PDF driver, a conversion tool. On a clean, institutionally generated document these two tell a coherent story. When a file has passed through an editor, the producer often changes to name that editor while the creator still claims something else.

CreationDate vs ModDate. The creation date is when the document was first made; the modification date is when it was last saved. On a file generated in one shot, these are effectively the same instant. When they diverge — or worse, when the modification date is earlier than the creation date — you are looking at a file that was touched after it was born.

Here is how to read all four with pypdf:

from pypdf import PdfReader

reader = PdfReader("statement.pdf")
info = reader.metadata or {}

print("Creator: ", info.get("/Creator"))
print("Producer:", info.get("/Producer"))
print("Created: ", info.get("/CreationDate"))
print("Modified:", info.get("/ModDate"))

PDF dates look like D:20240213120000+00'00'. A small helper makes them comparable:

from datetime import datetime, timezone
import re


def parse_pdf_date(raw):
    if not raw:
        return None
    m = re.match(r"D:(\d{4})(\d{2})(\d{2})(\d{2})?(\d{2})?(\d{2})?", str(raw))
    if not m:
        return None
    y, mo, d, hh, mm, ss = (int(g or 0) for g in m.groups())
    return datetime(y, mo, d, hh, mm, ss, tzinfo=timezone.utc)


created = parse_pdf_date(info.get("/CreationDate"))
modified = parse_pdf_date(info.get("/ModDate"))

if created and modified:
    if modified < created:
        print("Modification date precedes creation date.")
    elif modified > created:
        print("Document was saved after it was created.")

A modification timestamp that precedes the creation timestamp is one of the cleaner signals you will find — there is no honest workflow in which a file is saved before it exists.

Do not forget the XMP packet, which sometimes carries history the Info dictionary does not:

xmp = reader.xmp_metadata
if xmp:
    print("XMP CreateDate:", xmp.xmp_createDate)
    print("XMP ModifyDate:", xmp.xmp_modifyDate)

When the Info dictionary dates and the XMP dates disagree, that is itself worth a closer look — two layers that contradict each other about when the document was made suggest one of them was rewritten.

One important warning about metadata on its own: it is the easiest layer to forge. A one-line script can overwrite the producer field or backdate a timestamp without touching a single visible pixel. Metadata is a lead, not a verdict — which is exactly why the structural layer below matters more.

Layer 2: Structure — xref tables and incremental updates

This is where the “you need the original” objection falls apart.

A PDF locates its internal objects through a cross-reference table — the xref. When you save a PDF, a writer can append changes to the end of the file rather than rewriting it from scratch. This is called an incremental update, and it adds a new xref section pointing at the appended objects. The original bytes stay where they were; the edits are bolted on after them.

The consequence is forensically useful: each save generation leaves its own xref layer. A file that was generated once and never touched typically has a single xref section. A file that was opened, edited, and re-saved several times accumulates a chain of them. Counting those layers tells you roughly how many times the document was written to — no original required, because the history is in the file.

pikepdf gives you a clean handle on this. Counting startxref markers in the raw bytes is a simple first approximation of how many save generations the file has:

import pikepdf

with open("statement.pdf", "rb") as f:
    raw = f.read()

# Each save generation appends a startxref pointer.
save_generations = raw.count(b"startxref")
print("Approx. save generations:", save_generations)

# pikepdf reads the document version and structure.
pdf = pikepdf.open("statement.pdf")
print("PDF version:", pdf.pdf_version)

Now the caveat, and it is a theme we will keep hammering: more than one save generation is not proof of fraud. A bank’s own system might linearize a file (a legitimate optimization that adds structure), or a document-management pipeline might re-stamp every file it ingests. Incremental updates are how PDFs are supposed to grow. The signal is “this file was written to after creation,” not “this file was edited maliciously.” What that fact means depends entirely on what kind of document you expected and what software an honest issuer would have used.

Layer 3: Digital signatures and “modified after signing”

If a PDF is digitally signed, the signature covers a specific byte range of the file. Anything appended after that signed range was added after the signing — and the cryptographic guarantee no longer covers the whole document.

You can detect the presence of signatures structurally. Signature fields live in the AcroForm dictionary with a /Sig field type:

import pikepdf

pdf = pikepdf.open("contract.pdf")
root = pdf.Root

sig_fields = 0
if "/AcroForm" in root and "/Fields" in root.AcroForm:
    for field in root.AcroForm.Fields:
        if field.get("/FT") == pikepdf.Name("/Sig") and "/V" in field:
            sig_fields += 1

print("Signature fields present:", sig_fields)

Two findings here carry real weight:

  • Modifications after signing. If the signed byte range stops short of the end of the file, content was added after the seal was applied. The signature no longer covers the whole document.
  • Signature removal. A document that was signed and then had its signature stripped — leaving behind the scaffolding of a signature workflow without the seal — is a strong tampering signal.

Detecting these reliably means parsing the signature’s byte range, walking the structure that follows it, and reconstructing what the document looked like at signing time. That is considerably more code than reading a metadata field — which is a good segue into the limits of the DIY approach.

Where do-it-yourself runs out of road

Everything above is real and useful. But ship it as your fraud check and you will drown in false positives. Here is why.

Legitimate tools touch the same fields fraudsters do. A perfectly honest invoice might be generated, then optimized by a server-side tool that adds an xref layer and rewrites the producer. If your rule is “producer changed, therefore tampered,” you will reject a large slice of genuine traffic. The hard part is not reading the fields — it is knowing which combinations of creator, producer, structure, and dates are normal for a given kind of document and which are anomalous.

You need a corpus of known tools. Telling a bank’s statement engine apart from a consumer PDF editor apart from an online merge tool requires a maintained database of software fingerprints and how each one behaves. That database is the actual product; the byte-reading is the easy ten percent. Building it from scratch, and keeping it current as tools ship new versions, is a full-time job on its own.

Some forgeries have no modification to detect at all. This is the most important limitation. If a fraudster never edits a real document and instead generates a fake one from scratch — building a convincing bank statement in code or a design tool with whatever numbers they like — there is no post-creation modification to find. The file may be structurally pristine. It was simply born fake. Structural tamper detection cannot catch this class of document, and any honest tool will tell you so rather than pretend otherwise. The correct response to “I cannot structurally check this” is to escalate to the issuing organisation, not to wave it through.

That last point is why a careful tool reports three outcomes, not two: intact, modified, and inconclusive. The third bucket is not a failure. It means the document was produced by consumer software, an online editor, or a scanner — so structural integrity simply does not apply, because anyone can create such a file from scratch. Reporting inconclusive is the honest answer, and it tells your workflow exactly when a human or an external check needs to step in.

Or: call a forensic API and skip the false-positive tax

If you would rather not build and maintain a tool-fingerprint corpus yourself, hand the file to a hosted forensics service. HTPBE? exposes a public REST API that runs the structural, metadata, and signature analysis described above — plus the known-tool corpus that keeps false positives down — and returns a single verdict with named markers. As of this writing it runs 61 forensic checks across those layers.

It is a two-call flow. First, submit a publicly reachable URL to the PDF:

import os
import requests

API_KEY = os.environ["HTPBE_API_KEY"]
HEADERS = {"Authorization": f"Bearer {API_KEY}"}

# Step 1 — submit the PDF for analysis
resp = requests.post(
    "https://api.htpbe.tech/v1/analyze",
    headers={**HEADERS, "Content-Type": "application/json"},
    json={"url": "https://example.com/documents/statement.pdf"},
)
resp.raise_for_status()
check_id = resp.json()["id"]

Then fetch the full result by its id:

# Step 2 — retrieve the verdict and markers
result = requests.get(
    f"https://api.htpbe.tech/v1/result/{check_id}",
    headers=HEADERS,
).json()

status = result["status"]                    # "intact" | "modified" | "inconclusive"
confidence = result["modification_confidence"]  # "certain" | "high" | "none"
markers = result["modification_markers"]

print(f"Verdict: {status} (confidence: {confidence})")
for m in markers:
    print(" -", m)

A modified document comes back like this:

{
  "status": "modified",
  "modification_confidence": "certain",
  "creator": "Adobe Acrobat Pro DC",
  "producer": "Adobe PDF Library 15.0",
  "xref_count": 2,
  "has_incremental_updates": true,
  "signature_removed": true,
  "modification_markers": ["HTPBE_SIGNATURE_REMOVED", "HTPBE_DATES_DISAGREE"]
}

The modification_markers are stable, machine-readable ids — branch your intake logic on them, not on prose. Notice the design choice that mirrors the DIY discussion above: a file created in consumer software or scanned from paper comes back inconclusive, with a status_reason telling you why it cannot be structurally checked, so your pipeline routes it to a human or to the issuer rather than silently trusting or rejecting it.

Branch your code on the verdict like this:

if status == "modified":
    reject(reason=markers)                          # post-creation edits detected
elif status == "inconclusive":
    escalate(reason=result.get("status_reason"))    # consumer software — check with the issuer
else:  # "intact"
    accept()

You can read how each marker maps to an outcome on the how-it-works page, see plans and limits on the API page, or drop a single file into the free in-browser checker for a one-off look without writing any code. If you decide the hosted route fits your pipeline, the under-50-lines integration tutorial covers verdict routing, batch processing, and async polling in production-ready Python.

Reading the signals vs. producing a verdict

A PDF is a witness to its own history. Even with no original to compare against, you can read its metadata layer (creator vs producer, creation vs modification date), its structural layer (xref tables and incremental save generations), and its signature layer (signed byte ranges and removed seals) to find evidence that the file was changed after it was created. pypdf and pikepdf expose all of those fields in a handful of lines, and for one-off investigations that is often enough.

What those libraries will not give you is the judgment: knowing which tool combinations are normal, keeping false positives in check, and recognising the documents that are unverifiable by structure alone. Reading the signals is the easy part; producing a verdict your intake pipeline can safely act on is the hard part — and the gap between the two is exactly where a maintained known-tool corpus earns its keep. Be clear-eyed about which one your use case needs before you ship.

Share This Article

Found this article helpful? Share it with others to spread knowledge about PDF security and fraud detection.

https://htpbe.tech/blog/detect-tampered-pdf-python-without-original

Secure your workflow

Create your account — check PDFs on the web or with an API key, both ready on signup.
From $15/mo. No sales call. Cancel any time.