Home

/

Keep PII Out of Your LLM

/

Entity Catalogue and C# Validators

Entity Catalogue and C# Validators

Appendix A
Appendix
4
min read

The catalogue

EntityFormatChecksumFalse positives without validationConfidence with validation
Payment card12–19 digitsLuhnVery highVery high
ABA routing number9 digitsWeighted mod-10Very highVery high
NPI10 digitsLuhn, 80840 prefixedVery highVery high
IBAN (international customers)15–34 alphanumericmod-97LowVery high
SSN9 digitsNone, range rules onlyVery highMedium
ITIN9 digits, 9 prefixNone, range rules onlyVery highMedium
EIN9 digitsNone, prefix list onlyVery highMedium
MBI (Medicare)11 chars, mixedNone, positional rulesMediumMedium
US bank account4–17 digitsNoneVery highLow, needs routing context
Driver's licenceVaries by stateNoneVery highLow
EmailRFC 5322NoneLowHigh
US phone (NANP)10 digitsNone, NANP rulesHighMedium
ZIP / ZIP+45 or 9 digitsNoneHighLow
IPv4 / IPv6Dotted quad / hexNoneMediumHigh, often not personal data

Read the last two columns together. A validator is what separates an identifier from a number. Where no checksum exists, format and context are all you have, and confidence caps out at medium.

The four rows with a real checksum are worth disproportionate attention. They are also, conveniently, four of the highest-consequence categories: payment, banking, healthcare provider, and international banking.

Luhn, for payment cards

public static bool IsLuhnValid(ReadOnlySpan<char> digits)
{
    int sum = 0;
    bool doubling = false;

    for (int i = digits.Length - 1; i >= 0; i--)
    {
        if (!char.IsDigit(digits[i])) return false;

        int d = digits[i] - '0';
        if (doubling)
        {
            d *= 2;
            if (d > 9) d -= 9;
        }

        sum += d;
        doubling = !doubling;
    }

    return sum % 10 == 0;
}

Known-valid test values: 4111111111111111, 5500005555555559, 378282246310005.

ABA routing number, weighted mod-10

Nine digits. Weights 3, 7, 1 repeating, and the weighted sum must be a multiple of ten.

private static readonly int[] AbaWeights = [3, 7, 1, 3, 7, 1, 3, 7, 1];

public static bool IsAbaRoutingValid(ReadOnlySpan<char> routing)
{
    if (routing.Length != 9) return false;

    int sum = 0;
    for (int i = 0; i < 9; i++)
    {
        if (!char.IsDigit(routing[i])) return false;
        sum += (routing[i] - '0') * AbaWeights[i];
    }

    return sum % 10 == 0;
}

Known-valid test values: 021000021, 011000015.

A routing number identifies an institution, not a person, so on its own it is not personal data. Paired with an account number it is. Detect the routing number first, since it is the half you can verify, then treat nearby digit runs as the account.

NPI, Luhn with the CMS prefix

The National Provider Identifier issued by CMS. Ten digits, validated by Luhn after prepending 80840. Omit the prefix and every valid NPI fails, which is the single most common implementation error here.

public static bool IsNpiValid(string npi)
{
    var digits = new string(npi.Where(char.IsDigit).ToArray());
    if (digits.Length != 10) return false;

    // CMS specifies Luhn over the NPI prefixed with 80840.
    // "80" denotes health applications, "840" denotes the United States.
    return IsLuhnValid("80840" + digits);
}

Known-valid test values: 1993999998, 1234567893.

An NPI identifies a provider rather than a patient. It is still a strong quasi-identifier in a clinical note, because the treating provider narrows the patient population sharply.

IBAN, mod-97 (ISO 13616)

For customers outside the US. Move the first four characters to the end, convert letters to numbers where A is 10, and the remainder against 97 must be 1.

public static bool IsIbanValid(string iban)
{
    var s = new string(iban.Where(char.IsLetterOrDigit).ToArray()).ToUpperInvariant();
    if (s.Length is < 15 or > 34) return false;
    if (!char.IsLetter(s[0]) || !char.IsLetter(s[1])) return false;

    var rearranged = s[4..] + s[..4];

    int remainder = 0;
    foreach (char c in rearranged)
    {
        int value = char.IsDigit(c) ? c - '0' : c - 'A' + 10;
        remainder = value > 9
            ? (remainder * 100 + value) % 97
            : (remainder * 10 + value) % 97;
    }

    return remainder == 1;
}

Known-valid test values: GB82WEST12345698765432, DE89370400440532013000.

The first two characters give you the country, which tells you which rules apply to the account holder. Keep it.

SSN, range rules only

No checksum. Exclusions are all you have: area 000, 666, and 900999 are never issued; group 00 and serial 0000 are never issued.

public static bool IsSsnPlausible(ReadOnlySpan<char> ssn)
{
    if (ssn.Length != 9) return false;
    foreach (var c in ssn) if (!char.IsDigit(c)) return false;

    int area = int.Parse(ssn[..3]);
    int group = int.Parse(ssn[3..5]);
    int serial = int.Parse(ssn[5..]);

    if (area is 0 or 666 || area >= 900) return false;
    return group != 0 && serial != 0;
}

Call it IsPlausible, not IsValid. Nine digits with no checksum means high false positives on any numeric data. Require context: a nearby label, or the xxx-xx-xxxx separator format, which is far more reliable than the bare digits.

ITIN, a special case of the SSN shape

Individual Taxpayer Identification Numbers share the nine-digit shape and always begin with 9, with the group digits falling in defined ranges.

public static bool IsItinPlausible(ReadOnlySpan<char> itin)
{
    if (itin.Length != 9) return false;
    foreach (var c in itin) if (!char.IsDigit(c)) return false;

    if (itin[0] != '9') return false;
    int group = int.Parse(itin[3..5]);
    return group is (>= 50 and <= 65) or (>= 70 and <= 88)
                 or (>= 90 and <= 92) or (>= 94 and <= 99);
}

Known-valid test value: 912501234. Because the leading 9 excludes it from valid SSN areas, checking ITIN before SSN avoids double-counting the same span.

EIN, prefix list only

Nine digits, written XX-XXXXXXX. No checksum. Validity is a list of issued campus prefixes, which changes, so treat this as a shape check and lean on the separator and the label.

private static readonly Regex EinPattern =
    new(@"\b(\d{2})-(\d{7})\b", RegexOptions.Compiled);

public static bool LooksLikeEin(string candidate) =>
    EinPattern.IsMatch(candidate);

An EIN identifies a business, so it is personal data only for sole proprietors, where it is frequently the owner's SSN. Detect it, then decide by context.

the-leak-you-cant-see
blast-radius
what-counts-as-pii
the-five-doors
the-accuracy-reckoning
the-hybrid-that-does-not-work
deterministic-detection
npi-in-c
the-three-way-choice
calling-the-analyzer-from-c
measuring-your-own-demo-gap
choosing-the-operating-point
the-ladder-of-safeguards
pseudonymisation
the-round-trip
restoring-safely
when-masking-breaks-the-task
plausibility-hazard
the-architecture-that-holds
the-reference-architecture
dont-send-it-at-all
structure-beats-prose
the-gateway
failure-is-a-policy-decision
the-sidecar-you-can-trust
egress-deny-it-at-the-network
rag-and-agents
de-identify-before-you-embed
dual-model-separation
the-boring-controls
evidence-and-the-first-thirty-days
week-two-the-chokepoint-and-the-fast-layer
entity-catalogue-and-c-validators
mbi-positional-rules
tooling-at-a-glance
azure-ai-language-pii-in-detail
container-trust-checklist
sources
azure-ai-language
provider-retention

Download the full PDF for free?

Free download — no account required

Get the PDF
Get the PDF
Related Chapters
Free Download
Get the full PDF
All pages, including all code examples, diagrams, and the appendix reference card.
No spam. Unsubscribe at any time.
Your email won't be shared.
Oops! There's a problem with your request. We're working on fixing it. Please try again later.