Aadhaar masking looks like a solved problem when you describe it: find twelve digits, black out the first eight. Most implementations get a clean UIDAI PDF right on the first try, and everyone moves on.

Then real documents arrive — folded printouts, photographs taken at an angle under a tube light, scans where one card sits sideways — and the failure modes turn out to be specific, repeatable, and in one case genuinely dangerous. This is a write-up of the ones that matter, why they happen, and what fixes them.

Failure one: stopping at the first number you find

This is the dangerous one, because it fails silently and produces a document that looks masked.

UIDAI's standard e-Aadhaar printout puts both sides of the card on one page. When someone photographs or scans that printout, the two cards frequently end up at different orientations — the front upright, the back rotated ninety degrees.

A detector typically handles rotation by trying several orientations in turn: zero degrees, then ninety, then two-seventy, then one-eighty. The natural optimisation is to stop as soon as a number is found, because OCR is expensive and why keep scanning once you have your answer.

On a two-sided page that optimisation is a data leak. The upright card matches at zero degrees, the scan stops, and the rotated card — still carrying the same twelve digits in the largest print on the page — is never examined. The output is returned, reported as successfully masked, and handed to the user.

The user sees a success screen, downloads the file, and sends it to a landlord or a bank believing the number is hidden. It is not.

The fix is to scan every orientation regardless of what earlier passes found. It costs more compute. There is no way around that, and it is the correct trade.

The part that makes it harder

Scanning all four orientations is necessary but, on some documents, still not sufficient.

On one test document — a scanned printout with a fold running through the back card — the first two digit groups on that card were unreadable at every orientation, at multiple resolutions, and under several page segmentation modes. Cropping the card out and rotating it perfectly upright did not help. Only the trailing four digits came back reliably. The rest of the digits were physically destroyed by the crease before OCR ever saw them.

So a detector that depends on reading those digits will always miss them. But it does not have to depend on reading them, because of a property of the document: the same Aadhaar number appears on both sides.

Once any side of the document yields a complete, validated number, you know the trailing group. You can then find every other place that trailing group appears and extrapolate the mask leftward across the two groups that must precede it. Aadhaar is always printed in a 4-4-4 layout, so the geometry is predictable — on the test card, the two preceding groups plus their inter-group gaps measured about 2.55 times the width of the trailing group.

That covers the unreadable card without ever reading it.

Failure two: treating any twelve digits as an Aadhaar number

The opposite error. Less dangerous, more visible, and more annoying to the people who have to use the output.

An e-Aadhaar page is dense with digits that are not the Aadhaar number:

A regular expression looking for twelve consecutive digits finds several candidates here. The VID is the worst offender: a sixteen-digit run contains five overlapping twelve-digit windows, and a naive matcher will mask one of them.

Worse, tokens can be merged. OCR returns words with bounding boxes, and a detector that joins adjacent tokens to handle numbers split across a line break will happily join the digits either side of a date of birth. In one observed case, tokens around 07/06/1967 combined into a twelve-digit string that a matcher accepted, placing a black box squarely over the date of birth.

The checksum solves this

Aadhaar numbers carry a Verhoeff check digit. Verhoeff is an older checksum scheme — it predates the more common Luhn and Damm algorithms in this role — and it catches all single-digit errors and all adjacent transpositions.

For detection purposes what matters is the false-positive rate: an arbitrary twelve-digit string passes Verhoeff roughly one time in ten. Applied to the candidates above, the results are decisive:

CandidateSourceVerhoeff
A genuine Aadhaar numberThe cardValid
First twelve digits of a VIDLine below the numberRejected
VID window at offset fourSame lineRejected
Digits merged across a date of birthToken concatenationRejected

Add one more rule — UIDAI does not issue numbers beginning with 0 or 1 — and the remaining noise, such as repeated-digit sequences that happen to satisfy the checksum, largely disappears.

The implementation is small. Two lookup tables and a loop:

_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 verhoeff_valid(number: str) -> bool:
    c = 0
    for i, digit in enumerate(reversed(number)):
        c = _D[c][_P[i % 8][int(digit)]]
    return c == 0

def is_aadhaar(number: str) -> bool:
    # UIDAI does not issue numbers beginning 0 or 1
    return bool(re.fullmatch(r"[2-9]\d{11}", number)) and verhoeff_valid(number)

If you are evaluating a masking tool, this gives you a two-second test. Run a document through it and look at whether the VID line and the date of birth survived. If they are blacked out, the tool is pattern-matching without validating, which tells you something about what else it might be getting wrong.

Failure three: assuming OCR reads left to right

A subtle one that produces a confusing symptom: the number is clearly legible in the image, OCR reads all three groups correctly, and detection still fails.

The cause is skew. A card photographed by hand is never perfectly square to the sensor. On one test image, the three digit groups of an Aadhaar number had vertical positions of 370, 365 and 360 pixels — decreasing, because the card tilts slightly upward to the right.

A detector that sorts OCR tokens by vertical position and then reads consecutive array elements will assemble those groups in the wrong order. The digits come out reversed, the checksum fails, and a perfectly readable number is discarded.

The fix is to stop relying on array order. For each candidate group, search explicitly for the nearest token to its right that sits on the same printed line:

def right_of(token, tokens, length):
    # Nearest token of `length` digits to the right, on the same printed line.
    best = None
    for candidate in tokens:
        if candidate is token or len(candidate.digits) != length:
            continue
        if candidate.x0 < token.x0:          # must actually be to the right
            continue
        if not same_line(token, candidate):  # vertical centres within ~0.6 line heights
            continue
        if best is None or candidate.x0 < best.x0:
            best = candidate
    return best

Defining "same line" as vertical centres within roughly 0.6 of the larger token's height tolerates normal skew while still refusing to join tokens from different rows.

Failure four: the page segmentation mode you asked for is ignored

This one is specific to browser-based masking with tesseract.js, and it cost real debugging time.

Tesseract's page segmentation mode materially changes recognition on card images. On one glare-heavy photograph, a PAN number read as BNFPAO089 under one mode — the letter O substituted for a zero, and the final character dropped entirely — while reading perfectly under two other modes.

The trap is that passing the mode as an option to the convenience function does not apply it. The recognition result reports the default mode regardless of what was requested. Code that appears to try mode 6 and then fall back to mode 11 is in fact running the identical pass twice: double the time, no additional recall, and the modes that would have worked never actually run.

The mode only takes effect when set on a worker directly:

const worker = await Tesseract.createWorker("eng");
await worker.setParameters({ tessedit_pageseg_mode: "11" });
const { data } = await worker.recognize(canvas);

Creating one worker and reusing it across passes is worth doing anyway. The convenience function spins up and tears down a worker per call, which on a multi-pass detector means repeatedly paying for the WebAssembly and language-model load.

Failure five: character-level OCR repair that makes things worse

Glare causes character substitution, and it is tempting to repair it. For PAN numbers, whose format is fixed at five letters, four digits and a letter, positional coercion works well: a zero in a letter position becomes O, a letter B in a digit position becomes 8.

Insertions are where it goes wrong. On a glare-affected card, OCR returned BNFPAO0890P — eleven characters, with a phantom O inserted. The obvious repair is to slide a ten-character window across the string and keep whatever validates. That produces two structurally valid PAN numbers, BNFPA0089O and NFPAO0890P, and both are wrong. Whichever the code happens to keep is a plausible-looking, incorrect number stamped onto the customer's document.

Deleting the phantom character instead recovers the real number with no substitutions at all. The general principle: score candidate repairs by how much surgery they required, and always prefer the least-tortured reading. An exact match beats a single deletion, which beats a window shift.

And gate repair on confidence. Tesseract reports a confidence of zero when it is essentially guessing, and repairing a zero-confidence token manufactures identity numbers out of noise.

What this adds up to

The uncomfortable summary is that masking correctly is meaningfully slower than masking naively. Every safeguard above costs compute: scanning all orientations rather than stopping early, running multiple segmentation modes, validating checksums.

That trade is not close, though. The cost of over-masking is a document someone has to re-collect. The cost of under-masking is a live Aadhaar number sitting in a file that says it is redacted, discovered later by an auditor or by whoever the document was forwarded to.

If you take one thing from this: look at the output images. Not the status code, not the masked-region count — the actual pixels, on a document that is folded, angled and two-sided. That single check finds every failure described here.

You can try this against your own difficult documents using the browser-based Aadhaar masking tool, which processes files entirely on your machine with no upload. For integrating masking into a KYC or document pipeline, the API documentation covers the endpoints and error handling, and the integration guide walks through the parts that cause incidents in production.

Frequently asked questions

Why does Aadhaar masking miss the second card on a two-sided printout?

Because many detectors stop scanning as soon as they find one Aadhaar number. On a UIDAI printout carrying both sides of the card, the upright card matches first and the scan ends before the rotated card is examined. The rotated card keeps the number in full view while the service still reports success.

What is the Verhoeff checksum and why does Aadhaar use it?

Verhoeff is a decimal check-digit algorithm that catches all single-digit errors and all adjacent transpositions. For masking it is valuable because an arbitrary twelve-digit string passes it only about one time in ten, which rules out the twelve-digit windows inside a sixteen-digit VID and digit sequences accidentally formed from dates.

Why do some tools mask the date of birth or the Virtual ID?

They match any twelve consecutive digits without validating the checksum. A sixteen-digit VID contains five overlapping twelve-digit windows, and joining OCR tokens either side of a date of birth can produce a twelve-digit string too. Verhoeff validation rejects both.

Can masking recover digits that are physically unreadable?

Not by reading them. But because the same Aadhaar number appears on both sides of the card, a number recovered from a readable side can be used to locate its trailing group elsewhere and extrapolate the mask leftward over the two groups that precede it, using the fixed 4-4-4 print layout.

Why is correct masking slower than naive masking?

Because the safeguards cost compute: scanning every orientation instead of stopping at the first hit, running multiple page segmentation modes, and validating checksums. The trade is worth making, since an under-masked document is a live identity number in a file labelled as redacted.