Redacting SSNs and PII from FOIA PDFs with Microsoft Presidio
Within PII Redaction Pipelines, detection is the stage that decides what ever gets a chance to be withheld — and Microsoft Presidio is the engine that turns a page of text into a set of scored PII spans. This guide builds the detection layer specifically for FOIA PDFs: a custom recognizer for Social Security numbers and agency case numbers, confidence thresholds tuned so a missed identifier never slips through, a mapping from character offsets to page coordinates so a real redaction box can be drawn, and an audit line for every span so the withholding is traceable.
Scenario & Compliance Stakes
A records officer preparing an investigative file for release cannot eyeball three thousand pages for every Social Security number, phone number, and third-party name. Presidio automates the find step, but the stakes make the tuning unforgiving. A single SSN that the detector misses and a reviewer overlooks becomes public the moment the release copy leaves the agency, and disclosure of a Social Security number is precisely the personal-privacy harm that 5 U.S.C. § 552(b)(6) exists to prevent. The detector therefore runs with a recall bias: it is far cheaper to surface a false positive a reviewer discards than to miss a true positive that breaches privacy.
The output of this stage is not a redacted file — it is a set of scored candidate spans with character offsets, handed to the burn-in step that physically removes the content. Keeping detection and destruction separate is deliberate: detection is reversible and re-tunable, and it must never be the thing that decides, on its own, that a nine-digit number is or is not an SSN. That judgment belongs to a confidence threshold and, past the threshold, to a human. The spans this stage emits feed the exemption mapping in PII Redaction Pipelines and, ultimately, the irreversible flattening in Flattening PDF Layers for Irreversible Redaction.
Prerequisites
- Python 3.11+ with
presidio_analyzerand its spaCy language model for named-entity recognition, pluspymupdf(imported asfitz) to read the PDF text layer and resolve span coordinates. - A verified text layer. Presidio analyzes text, not pixels. Born-digital PDFs expose selectable text directly; image-only scans have none and must first pass through OCR Processing Pipelines, because the detector cannot recognize an SSN it cannot read.
- A structured audit sink — JSON to stdout in development, forwarded to an append-only store in production — so every candidate span is logged with its type, score, and a hash of the matched text rather than the raw value.
- Least-privilege execution, scoped so the detector reads only the record under review and writes only candidate spans and audit lines.
Implementation
The detection layer has three deterministic stages: register recognizers (built-in plus a custom government-identifier pattern), analyze each page’s text into scored spans, and resolve those spans to page coordinates the burn-in step can redact. Keeping the recognizer definitions separate from the analysis loop lets you tune SSN patterns and confidence floors without touching the coordinate-mapping code.
1. Register a custom recognizer for government identifiers
Presidio ships recognizers for common entities, but agency case numbers and the exact SSN formatting your records use need a custom PatternRecognizer. Patterns carry a base confidence score, and Presidio combines that with context words nearby (“SSN”, “Social Security”) to raise or lower the final score. Writing the SSN pattern to tolerate spaces and hyphens is what keeps 123 45 6789 from evading a hyphen-only rule.
import hashlib
import json
import logging
from datetime import datetime, timezone
from presidio_analyzer import AnalyzerEngine, Pattern, PatternRecognizer
# Structured JSON audit logging: every candidate span is recorded by a hash of
# its text, never the raw value — the audit trail must not itself leak the PII
# it exists to protect (5 U.S.C. § 552(b)(6): personal-privacy exemption).
logging.basicConfig(
format='{"ts":"%(asctime)s","level":"%(levelname)s","msg":"%(message)s"}',
level=logging.INFO,
)
logger = logging.getLogger("presidio_redaction")
def build_analyzer() -> AnalyzerEngine:
"""Register custom SSN and case-number recognizers alongside the defaults."""
# SSN: three digits, two digits, four digits, separated by space or hyphen.
ssn_pattern = Pattern(name="us_ssn_flexible",
regex=r"\b\d{3}[ -]?\d{2}[ -]?\d{4}\b",
score=0.6)
ssn_recognizer = PatternRecognizer(
supported_entity="US_SSN",
patterns=[ssn_pattern],
# Context words lift the score when the number really is an SSN,
# which is how we separate an SSN from a random 9-digit string.
context=["ssn", "social security", "soc sec", "ssn:"],
)
# Agency case numbers, e.g. 2024-CR-01892 — map to (b)(7)(C) downstream.
case_pattern = Pattern(name="agency_case_no",
regex=r"\b\d{4}-[A-Z]{2}-\d{4,6}\b",
score=0.7)
case_recognizer = PatternRecognizer(
supported_entity="CASE_NUMBER",
patterns=[case_pattern],
context=["case", "docket", "matter no"],
)
analyzer = AnalyzerEngine()
analyzer.registry.add_recognizer(ssn_recognizer)
analyzer.registry.add_recognizer(case_recognizer)
return analyzer
2. Analyze each page and apply a confidence threshold
Run the analyzer against each page’s text, then split the results on a confidence floor. Spans at or above the threshold become redaction candidates; spans below it are not discarded — they route to manual review, because a low-confidence SSN match is exactly the case a human should adjudicate, not the machine. The threshold is deliberately conservative: for SSNs, where a miss is a breach, err toward review.
CONFIDENCE_THRESHOLD = 0.5 # at/above -> redact; below -> human review
TARGET_ENTITIES = ["US_SSN", "CASE_NUMBER", "PHONE_NUMBER", "PERSON"]
def analyze_page(analyzer: AnalyzerEngine, page_text: str, page_no: int):
"""Return (to_redact, to_review) span lists for one page."""
try:
results = analyzer.analyze(
text=page_text, language="en", entities=TARGET_ENTITIES,
)
except Exception as exc:
# Fail closed: an analysis error must halt this page, never emit an
# empty span set that would let the page pass through unredacted.
logger.error("analyze_failed page=%s err=%s", page_no, type(exc).__name__)
raise
to_redact, to_review = [], []
for r in results:
span_text = page_text[r.start:r.end]
span_hash = hashlib.sha256(span_text.encode("utf-8")).hexdigest()[:16]
record = {"entity": r.entity_type, "start": r.start, "end": r.end,
"score": round(r.score, 3), "span_hash": span_hash,
"page": page_no}
if r.score >= CONFIDENCE_THRESHOLD:
to_redact.append(record)
else:
to_review.append(record)
logger.info("candidate %s", json.dumps(record))
return to_redact, to_review
3. Map spans to page coordinates for redaction
A character offset means nothing to a PDF renderer; the burn-in step needs a rectangle. pymupdf resolves the matched text to one or more bounding boxes on the page, and attaching those coordinates to each candidate is what lets the next stage draw a real redaction rather than re-searching the raw string and risking a partial-word match elsewhere.
import fitz # pymupdf
def resolve_span_coordinates(pdf_path: str, spans: list) -> list:
"""Attach page-coordinate bounding boxes to each accepted span."""
doc = fitz.open(pdf_path)
located = []
for span in spans:
page = doc[span["page"]]
span_text = None # in production, carry the text via a secure handle
# Search by the recognizer's own extraction, not a re-derived string,
# to avoid matching an innocuous substring elsewhere on the page.
rects = page.search_for(span.get("match_text", "")) if span_text is None else []
for rect in rects:
located.append({
**span,
"bbox": [rect.x0, rect.y0, rect.x1, rect.y1],
})
if not rects:
# A span the detector found but the renderer cannot locate must be
# escalated, never silently dropped — that is a potential leak.
logger.warning("span_unlocated page=%s hash=%s",
span["page"], span["span_hash"])
doc.close()
return located
Expected Output & Verification
A clean run emits one greppable audit line per candidate and a short located-span list ready for burn-in:
{"ts":"2026-07-12 10:22:04","level":"INFO","msg":"candidate {\"entity\": \"US_SSN\", \"score\": 0.85, \"span_hash\": \"9f2c1a7b4e80c3d1\", \"page\": 3}"}
{"ts":"2026-07-12 10:22:04","level":"INFO","msg":"candidate {\"entity\": \"CASE_NUMBER\", \"score\": 0.7, \"span_hash\": \"11ab77e4009c22fd\", \"page\": 3}"}
Assert the contract in a unit test so a recognizer change that breaks SSN detection fails CI rather than a live disclosure:
def test_flexible_ssn_variants_are_detected():
analyzer = build_analyzer()
for text in ("SSN: 123-45-6789", "ssn 123 45 6789", "123456789 social security"):
redact, review = analyze_page(analyzer, text, page_no=0)
# Every formatting variant must surface as a candidate somewhere.
assert any(s["entity"] == "US_SSN" for s in redact + review)
Beyond unit fixtures, run a labeled corpus of real (test) records through detection in dry-run mode and diff the candidate counts against a reviewed baseline; a sudden drop in SSN candidates is the signal that a pattern or context list regressed.
Common Pitfalls
- False positives on 9-digit non-SSNs. Permit numbers, tracking codes, and phone-plus-extension strings all match a bare nine-digit pattern, and treating every one as an SSN over-redacts responsive content. Diagnosis: high SSN candidate counts on records with no personnel context. Fix: keep the base pattern score modest (0.6) and lean on Presidio’s context words so a number near “social security” scores above the threshold while an isolated nine-digit code stays below it and routes to review.
- Missing OCR text layer. Presidio sees nothing on an image-only scan, so it emits zero candidates and the page sails through unredacted — the most dangerous silent failure in the pipeline. Diagnosis: pages with zero candidates that visibly contain identifiers. Fix: gate detection on a verified text layer, route scans through OCR Processing Pipelines first, and treat a zero-candidate page whose OCR confidence is low as a manual-review case, never as “clean.”
- A threshold tuned for precision instead of recall. Setting the confidence floor high to suppress false positives also suppresses borderline true positives, and a suppressed SSN is a breach. Fix: keep the threshold conservative and absorb false positives in the review queue rather than raising the floor — the reviewer discards over-inclusion cheaply, but no one can recover a missed identifier after release.
FAQ
Why hash the matched text in the audit log instead of storing it?
Because the audit trail must not become a second copy of the PII it exists to protect. Logging a truncated SHA-256 of each span lets you prove two runs found the same identifier, deduplicate candidates, and trace a redaction back to a specific detection event — all without writing a Social Security number into a log that may have broader access than the source record. Under the personal-privacy exemptions, the metadata about a withholding must itself avoid leaking the protected value.
How do we avoid redacting every nine-digit number as an SSN?
By separating pattern strength from context. The SSN pattern carries only a modest base score, so a bare nine-digit string — a permit or tracking number — lands below the confidence threshold and routes to review rather than automatic redaction. Presidio raises the score when context words like “social security” appear nearby, so a genuine SSN clears the threshold. This keeps recall high for real identifiers while sending ambiguous numbers to a human instead of over-redacting responsive content.
What happens to spans below the confidence threshold?
They are never discarded. A sub-threshold span routes to a manual review queue where a records officer adjudicates it, because a low-confidence match is precisely the judgment call a machine should not make alone. Discarding low-confidence candidates would convert an uncertain detection into a silent non-redaction, which is exactly the failure mode that leaks PII — so the pipeline fails toward human review, not toward release.
Related
- PII Redaction Pipelines — the parent capability this detection layer feeds.
- Flattening PDF Layers for Irreversible Redaction — the burn-in step that consumes these coordinate-mapped spans.
- Classifying FOIA Privacy Exemptions (b)(6) and (b)(7)© in Python — assigns the authority each redacted span cites.
- OCR Processing Pipelines — produces the verified text layer detection depends on.
- Audit Logging Architecture — the append-only sink these span records are written to.
← Back to all public records automation topics