Compliant Electronic Signature Manifestations in Submission PDFs
A regulatory reviewer opens a cover letter and expects to read, in plain text on the page, who signed it, when, and what they were attesting to. That visible block is the manifestation 21 CFR 11.50 requires, and it is only half the job described in Electronic Signature Manifestation under 21 CFR Part 11 — the other half, covered there, is the cryptographic signature over the record’s hash that satisfies 11.70. This guide is the concrete Python for the visible half: rendering a correct manifestation block onto a submission PDF, in a way that references the same hash the cryptographic signature covers, so the two halves can never drift apart. It sits inside the Electronic Submission Gateway & E-Signature Automation domain, downstream of the artifacts eCTD Sequence Assembly & Lifecycle Management hands off to signing.
The reason this is worth its own guide, rather than a paragraph inside the broader signing walkthrough, is that the visible manifestation is the artifact a reviewer, a sponsor’s own quality group, and an inspector will each independently look at without running any verification tooling. If the text on the page is wrong — a stale date, a meaning that does not match what actually happened, a signer name that does not match the credential that produced the cryptographic signature — the mismatch is a finding regardless of how sound the underlying cryptography is. Getting the rendering right, and getting the ordering of operations right so the rendered text can never contradict the signed hash, is a correctness problem in its own right.
Why naive approaches fail
Stamping “signed” text onto a PDF looks trivial until it meets an inspector’s questions.
- Stamping before hashing. If the manifestation text is written onto the page and then the file is hashed for signing, the signature covers a document that already contains the visible signature block — which is circular, and which breaks the moment the manifestation text needs to change (the printed name is misspelled, say) because now the hash and the signature both have to be redone in lockstep with no clear order of operations.
- Free-text meaning fields. A manifestation that lets a caller type an arbitrary meaning string (“looked at it”) satisfies no regulatory value and cannot be queried consistently across a portfolio of submissions. §11.50 names specific meanings; the field should be a constrained value, not free text.
- Local time instead of UTC. A signer in one time zone and a reviewer in another can disagree about “the date of signing” if the timestamp is rendered in local time with no offset. Rendering everything in UTC removes the ambiguity entirely.
- Overwriting the original PDF in place. Writing the stamped page back over the original file destroys the ability to prove what the pre-signature document looked like. The pipeline should always produce a new, distinctly named output artifact and leave the pre-signature input untouched.
Architecture overview
The stamping step sits between record hashing and cryptographic signing, then produces the final artifact that transport will send.
Hashing runs first, against the unmodified input PDF, so the manifestation block can safely reference that hash’s short form on the page without changing what gets signed.
Setup and configuration
Two maintained libraries cover this: pypdf for reading and merging pages, and reportlab for drawing the manifestation text onto a new overlay page.
pip install "pypdf>=4.0" "reportlab>=4.0"
Deprecated dependency: if an existing pipeline imports
PyPDF2, replace it withpypdf— same API surface, actively maintained, and the packagePyPDF2now installs is simply an old snapshot with no further security fixes.
Configuration — the output directory and the visible product name that appears in the manifestation footer — comes from the environment, never a hardcoded path:
"""Environment-driven configuration for manifestation stamping."""
from __future__ import annotations
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class StampConfig:
output_dir: str
product_name: str
@classmethod
def from_env(cls) -> "StampConfig":
def required(name: str) -> str:
value = os.environ.get(name)
if not value:
raise RuntimeError(f"Missing required environment variable: {name}")
return value
return cls(
output_dir=required("SIGNED_OUTPUT_DIR"),
product_name=required("SUBMISSION_PRODUCT_NAME"),
)
Full working implementation
The implementation hashes the input first, builds a small overlay page with reportlab, merges it onto the last page of the document with pypdf, and writes a new file whose name makes clear it is the signed output rather than the original.
"""Stamp a Part 11 signature manifestation onto a submission PDF."""
from __future__ import annotations
import hashlib
import io
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from pypdf import PdfReader, PdfWriter
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
class SignatureMeaning(str, Enum):
AUTHORSHIP = "authorship"
REVIEW = "review"
APPROVAL = "approval"
RESPONSIBILITY = "responsibility"
class ManifestationError(Exception):
"""Raised when a PDF cannot be stamped with a compliant manifestation."""
@dataclass(frozen=True)
class Manifestation:
signer_name: str
meaning: SignatureMeaning
signed_at: datetime
record_sha256: str
def hash_pdf(path: Path) -> str:
"""Hash the pre-signature bytes; this is what the eventual signature covers."""
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(65536), b""):
digest.update(chunk)
return digest.hexdigest()
def build_manifestation(signer_name: str, meaning: SignatureMeaning, record_sha256: str) -> Manifestation:
if not signer_name.strip():
raise ManifestationError("signer_name must not be empty")
return Manifestation(
signer_name=signer_name.strip(),
meaning=meaning,
signed_at=datetime.now(timezone.utc),
record_sha256=record_sha256,
)
def _overlay_page(manifestation: Manifestation) -> bytes:
"""Render the manifestation block as a standalone one-page PDF."""
buffer = io.BytesIO()
page = canvas.Canvas(buffer, pagesize=letter)
width, height = letter
lines = [
"Electronically signed per 21 CFR Part 11",
f"Signer: {manifestation.signer_name}",
f"Meaning: {manifestation.meaning.value}",
f"Signed (UTC): {manifestation.signed_at.isoformat()}",
f"Record hash (SHA-256): {manifestation.record_sha256[:16]}...",
]
text_object = page.beginText(56, 96)
text_object.setFont("Helvetica", 9)
for line in lines:
text_object.textLine(line)
page.drawText(text_object)
page.showPage()
page.save()
return buffer.getvalue()
def stamp_manifestation(source_pdf: Path, manifestation: Manifestation, output_dir: Path) -> Path:
"""Merge the manifestation overlay onto the last page and write a new signed artifact."""
reader = PdfReader(str(source_pdf))
if len(reader.pages) == 0:
raise ManifestationError(f"{source_pdf} has no pages to stamp")
overlay_reader = PdfReader(io.BytesIO(_overlay_page(manifestation)))
overlay_page = overlay_reader.pages[0]
writer = PdfWriter()
for index, page in enumerate(reader.pages):
if index == len(reader.pages) - 1:
page.merge_page(overlay_page)
writer.add_page(page)
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / f"{source_pdf.stem}.signed.pdf"
with output_path.open("wb") as handle:
writer.write(handle)
return output_path
The manifestation block deliberately prints only the first sixteen characters of the hash — enough for a human to cross-check against an audit record, not so much that the page reads as a wall of hexadecimal. The full hash lives in the signature record produced by the cryptographic signing step, not in the visible text.
Two properties of stamp_manifestation are worth naming explicitly because they are easy to lose in a refactor. First, the function never mutates source_pdf — it always reads from it and writes to a distinctly named path under output_dir, so the pre-signature input remains available to re-derive the hash from at any point later. Second, the merge happens strictly on the last page of the existing document rather than appending an entirely new page, which keeps a reviewer’s expected page count intact and avoids introducing a blank trailing page that some viewers render oddly. Both choices trade a small amount of implementation convenience for a document that behaves exactly the way a reviewer already expects a signed cover letter to behave.
Validation and edge-case handling
A handful of edge cases account for most defects found during review:
- Zero-page or encrypted input. A password-protected PDF must be decrypted with an authorized key before stamping —
pypdfraises on encrypted content, which is the correct fail-fast behavior rather than silently producing an unstamped output. - Multi-page cover letters. Placing the manifestation on the last page, as this implementation does, keeps it consistent regardless of how many pages the cover letter grows to; placing it on a fixed page number instead breaks the moment content is added.
- Re-stamping an already-signed file. Stamping should always read from the pre-signature input and never from a previously signed output — merging a second manifestation onto an already-stamped page produces overlapping text and, more importantly, means the new hash no longer matches anything meaningful. Track the pre-signature path separately and never feed a
.signed.pdfback intostamp_manifestation. - Non-ASCII signer names.
reportlab’s default Helvetica font supports the Latin-1 range; a signer name with characters outside it (some CJK or fully accented names) needs a Unicode-capable font registered explicitly, or the draw call raises rather than silently dropping characters. - Overlay text colliding with existing page content. If the last page of the source PDF already has footer text near the coordinates the overlay uses, the merged result can overlap and become unreadable. A production pipeline should reserve a fixed margin on cover-letter templates specifically for the manifestation block, rather than discovering the collision only when a reviewer complains that a line of text is illegible.
Testing and verification
Two tests cover the properties that matter most: that the manifestation text actually appears on the rendered page, and that a zero-page input is rejected rather than silently producing an empty stamp.
"""Tests for PDF signature manifestation stamping."""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
import pytest
from pypdf import PdfReader, PdfWriter
def _make_blank_pdf(path: Path, pages: int = 1) -> None:
writer = PdfWriter()
for _ in range(pages):
writer.add_blank_page(width=612, height=792)
with path.open("wb") as handle:
writer.write(handle)
def test_manifestation_text_present_on_output(tmp_path: Path) -> None:
source = tmp_path / "cover-letter.pdf"
_make_blank_pdf(source)
record_hash = hash_pdf(source)
manifestation = build_manifestation("Dr. A. Reviewer", SignatureMeaning.APPROVAL, record_hash)
output_path = stamp_manifestation(source, manifestation, tmp_path / "out")
text = PdfReader(str(output_path)).pages[-1].extract_text()
assert "Dr. A. Reviewer" in text
assert "approval" in text
def test_empty_pdf_is_rejected(tmp_path: Path) -> None:
source = tmp_path / "empty.pdf"
_make_blank_pdf(source, pages=0)
record_hash = hash_pdf(source)
manifestation = build_manifestation("Dr. A. Reviewer", SignatureMeaning.REVIEW, record_hash)
with pytest.raises(ManifestationError):
stamp_manifestation(source, manifestation, tmp_path / "out")
The first test proves the printed name and meaning both land on the page a reviewer will actually see; the second proves an empty document fails loudly at stamping time instead of producing a signed artifact with nothing to sign.