| Entity | Format | Checksum | False positives without validation | Confidence with validation |
|---|---|---|---|---|
| Payment card | 12–19 digits | Luhn | Very high | Very high |
| ABA routing number | 9 digits | Weighted mod-10 | Very high | Very high |
| NPI | 10 digits | Luhn, 80840 prefixed | Very high | Very high |
| IBAN (international customers) | 15–34 alphanumeric | mod-97 | Low | Very high |
| SSN | 9 digits | None, range rules only | Very high | Medium |
| ITIN | 9 digits, 9 prefix | None, range rules only | Very high | Medium |
| EIN | 9 digits | None, prefix list only | Very high | Medium |
| MBI (Medicare) | 11 chars, mixed | None, positional rules | Medium | Medium |
| US bank account | 4–17 digits | None | Very high | Low, needs routing context |
| Driver's licence | Varies by state | None | Very high | Low |
| RFC 5322 | None | Low | High | |
| US phone (NANP) | 10 digits | None, NANP rules | High | Medium |
| ZIP / ZIP+4 | 5 or 9 digits | None | High | Low |
| IPv4 / IPv6 | Dotted quad / hex | None | Medium | High, 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.
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.
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.
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.
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.
No checksum. Exclusions are all you have: area 000, 666, and 900–999 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.
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.
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.
Download the full PDF for free?
Free download — no account required