Last month a client sent me a folder of 340 employee onboarding PDFs and asked me to "black out the Aadhaar numbers before we ship this to the auditor."
Their previous approach: open each PDF in a viewer, draw a black rectangle over the number, save. It looks perfect. It is also completely useless — a black rectangle is a drawing placed on top of the page. The text is still sitting underneath it. Select-all, copy, paste into a notepad, and every Aadhaar number you thought you hid comes back out in plain text.
This is not a hypothetical failure mode. It is the single most common way PII leaks out of documents that someone genuinely believed were redacted.
Here is a 63-line script that does it properly, and validates Aadhaar numbers with the actual UIDAI checksum so you do not nuke random 12-digit invoice numbers.
Why this matters more in 2026
Under the DPDP Act, "we drew a box over it" is not a defence. If a KYC packet leaves your building with recoverable PAN and Aadhaar numbers in it, you shipped personal data. The moment that folder gets emailed to an auditor, a vendor, or a WhatsApp group, you have lost control of it.
Most teams handling this at scale — CA firms, HR departments, NBFC ops teams — are doing it manually. 340 files at two minutes each is eleven hours of work that also happens to be wrong.
The code
"""Redact PAN and Aadhaar numbers from PDFs before you share them."""
import re
import sys
from pathlib import Path
import pymupdf
PAN = re.compile(r"[A-Z]{5}[0-9]{4}[A-Z]")
AADHAAR = re.compile(r"[2-9][0-9]{3}[ -]?[0-9]{4}[ -]?[0-9]{4}")
D = [
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
[2, 3, 4, 0, 1, 7, 8, 9, 5, 6], [3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
[4, 0, 1, 2, 3, 9, 5, 6, 7, 8], [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
[6, 5, 9, 8, 7, 1, 0, 4, 3, 2], [7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
[8, 7, 6, 5, 9, 3, 2, 1, 0, 4], [9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
]
P = [
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
[5, 8, 0, 3, 7, 9, 6, 1, 4, 2], [8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
[9, 4, 5, 3, 1, 2, 6, 8, 7, 0], [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
[2, 7, 9, 3, 8, 0, 6, 4, 1, 5], [7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
]
def is_aadhaar(digits: str) -> bool:
"""Verhoeff checksum -- UIDAI's guard against typos and false positives."""
c = 0
for i, ch in enumerate(reversed(digits)):
c = D[c][P[i % 8][int(ch)]]
return c == 0
def find_secrets(text: str) -> set[str]:
hits = set(PAN.findall(text))
for m in AADHAAR.finditer(text):
raw = m.group()
if is_aadhaar(re.sub(r"[ -]", "", raw)):
hits.add(raw)
return hits
def redact(src: Path, dst: Path) -> int:
doc = pymupdf.open(src)
total = 0
for page in doc:
for secret in find_secrets(page.get_text()):
for box in page.search_for(secret):
page.add_redact_annot(box, fill=(0, 0, 0))
total += 1
page.apply_redactions()
doc.save(dst, garbage=4, deflate=True)
doc.close()
return total
if __name__ == "__main__":
folder = Path(sys.argv[1] if len(sys.argv) > 1 else ".")
out = folder / "redacted"
out.mkdir(exist_ok=True)
for pdf in folder.glob("*.pdf"):
n = redact(pdf, out / pdf.name)
print(f"{pdf.name}: redacted {n} identifier(s)")
Install and run:
pip install pymupdf
python redact_kyc.py ./kyc_packets
onboarding_form.pdf: redacted 2 identifier(s)
Originals are never touched — clean copies land in kyc_packets/redacted/.
The three lines that actually matter
page.add_redact_annot(box, fill=(0, 0, 0)) marks a rectangle for redaction. On its own it changes nothing.
page.apply_redactions() is the step everyone skips. This is what physically deletes the glyphs inside those rectangles from the content stream. Without it you have drawn a black box; with it, the characters are gone. You can verify the difference yourself:
import pymupdf
print(pymupdf.open("redacted/onboarding_form.pdf")[0].get_text())
EMPLOYEE ONBOARDING - KYC PACKET
Name: Rohan Sharma
PAN:
Aadhaar:
Order no: 4567 1234 8888
Bank IFSC: HDFC0001234
The values are not hidden. They do not exist in the file.
doc.save(dst, garbage=4) garbage-collects orphaned objects, so no stale copy of the old content stream survives in the PDF's object table.
Why the Verhoeff checksum earns its 20 lines
Look at the output above. Order no: 4567 1234 8888 matched the Aadhaar regex perfectly — 12 digits, right grouping, starts with a valid leading digit. It was left alone anyway.
Every real Aadhaar number ends in a Verhoeff check digit, a non-commutative checksum built on the dihedral group D5 that catches transposition errors decimal checksums miss. A random 12-digit number survives it only about 10% of the time — I ran 200,000 of them through this function and 9.87% got past. So this one table turns a regex that would have shredded your invoice numbers, order IDs and reference codes into one that leaves roughly nine out of ten of them alone.
That is the difference between a script you can point at 340 files unattended and one you have to babysit.
The PAN regex needs no such help — [A-Z]{5}[0-9]{4}[A-Z] is distinctive enough that false positives are rare in practice.
What this does not do
Scanned PDFs. If the document is a photo of a form, there is no text layer, get_text() returns nothing, and this script will cheerfully report zero redactions on a page full of visible Aadhaar numbers. Run OCR first (ocrmypdf in.pdf out.pdf) and then this. Always check the reported count is non-zero before you ship a batch.
Numbers split across lines. A number broken by a line wrap will not match. Rare in forms, common in dense paragraphs.
Everything else. Bank accounts, GSTINs, phone numbers, addresses — all still there. Add patterns to find_secrets() as your threat model demands. GSTIN is an easy next one: d{2}[A-Z]{5}d{4}[A-Z][A-Z0-9]Z[A-Z0-9].
Wiring it into a real workflow
The version I actually shipped runs on a watched folder: drop PDFs into inbox/, redacted copies appear in outbox/, and a line goes into an append-only CSV recording filename, timestamp and redaction count. That log turned out to be the part the compliance team cared about most — it is the evidence that every file in the outgoing batch was processed, and it is what you hand over when someone asks how you know.
Eleven hours became about forty seconds, and unlike the black rectangles, the output survives a copy-paste.
Follow me on Twitter @automate_archit for daily AI automation tips.
Top comments (0)