
ITIN Dev NotesA practical checklist for validating, labeling, and storing U.S. taxpayer IDs without locking out millions of people.
If your product asks for a U.S. tax ID (payroll, lending, banking, tax prep, marketplaces paying out sellers), there is a good chance your form quietly rejects a whole group of real customers: people who file taxes with an ITIN instead of a Social Security Number.
An ITIN (Individual Taxpayer Identification Number) is issued by the IRS to people who must file U.S. taxes but can't get an SSN. It looks exactly like an SSN, which is precisely why so many validators get it wrong.
This post is a checklist of the bugs I see most often, with code you can drop in.
ITINs are nine digits, formatted like an SSN (9XX-XX-XXXX), and always start with 9. SSNs, on the other hand, are never issued with an area number from 900 to 999. So the classic SSN rule "reject anything starting with 9" is correct for SSNs and fatal for ITINs.
The fix is to validate a taxpayer ID, not an SSN, and to classify it:
import re
TIN = re.compile(r"^(\d{3})-?(\d{2})-?(\d{4})$")
ITIN_GROUPS = [range(50, 66), range(70, 89), range(90, 93), range(94, 100)]
def classify_tin(value: str) -> str | None:
"""Return "SSN", "ITIN", or None if the number cannot be valid."""
match = TIN.match(value.strip())
if not match:
return None
area, group, serial = match.groups()
if area.startswith("9"):
return "ITIN" if any(int(group) in r for r in ITIN_GROUPS) else None
if area in ("000", "666") or group == "00" or serial == "0000":
return None
return "SSN"
The ITIN check uses the 4th and 5th digits: the IRS issues ITINs with those digits in the ranges 50 to 65, 70 to 88, 90 to 92, and 94 to 99. Keep that list in one place so it is easy to update if the IRS expands it again.
The same logic on the client, so people get instant feedback:
const ITIN_GROUPS = [[50, 65], [70, 88], [90, 92], [94, 99]];
export function classifyTin(value) {
const m = value.trim().match(/^(\d{3})-?(\d{2})-?(\d{4})$/);
if (!m) return null;
const [, area, group, serial] = m;
if (area.startsWith("9")) {
const g = Number(group);
return ITIN_GROUPS.some(([lo, hi]) => g >= lo && g <= hi) ? "ITIN" : null;
}
if (area === "000" || area === "666" || group === "00" || serial === "0000") return null;
return "SSN";
}
Always repeat the check on the server. Client-side validation is a convenience, not a control.
A handful of unit tests catches most regressions. Make sure your suite covers at least:
000, 666, a 00 group and a 0000 serial are rejected as SSNsA field labeled "Social Security Number" tells an ITIN holder to leave. Even if your backend accepts ITINs, many people will abandon the form at that label.
inputmode="numeric" and autocomplete="off", and accept the number with or without dashesMany ITIN holders have names that break naive validation: two surnames, accents, apostrophes, or a single name.
They look the same, but they are not interchangeable, and the differences matter for your product rules:
An ITIN is as sensitive as an SSN. Treat it that way:
***-**-1234)None of this is hard, and it opens your product to millions of taxpayers that a one-line regex was turning away.
If you have hit other ITIN edge cases in production, share them in the comments. I'll fold the good ones into this checklist.