Romanian CNP Validator in TypeScript, Zod and React
Your checkout, payroll or rental contract form asks for a Romanian personal numeric code (CNP), and a 13-digit regex accepts every typo. A Romanian CNP validator has to check more than the length: a digit for sex and century, a birth date that exists, a county code and a weighted check digit. This tutorial writes that check in TypeScript, decodes the parts, turns it into a zod rule and a React field that validates as you type, and ends with what to do before you store a single CNP. The field is Masked Input from Wingo UI, the library we build, so we are not neutral. The validator is plain TypeScript you can paste anywhere.
What is the cod numeric personal format?
Thirteen digits in seven parts, written S AA LL ZZ JJ NNN C. The Romanian Wikipedia article on the CNP (opens in a new tab) documents each one. Here they are on 1960523123457, the sample code from the demo further down:
From 1 to 8, the first digit is odd for men and even for women. 1 and 2 mean born in the 1900s, 3 and 4 in the 1800s, 5 and 6 in the 2000s. 7 and 8 are foreign residents, and for them the century is not encoded. English Wikipedia also lists 9 for foreign citizens; the Romanian article stops at 8, so treat 9 as rare and accept it.
The county codes are 01 to 39 for the counties in alphabetical order (Alba to Vrancea), 40 for Bucharest, 41 to 46 for its six sectors, 47 and 48 for the former sectors 7 and 8, which only appear on old codes, and 51 and 52 for Călărași and Giurgiu. There is no 49 or 50.
And since May 2024 there is 70. Codes generated by Romania's new civil status IT system carry 70 in place of the county for every registration, wherever the person was born. The Ministry of Internal Affairs said in May 2024 (opens in a new tab) that this started with births registered from May 8 in Ilfov county and seven cities, plus the birth records of people who had just become citizens, and that existing codes do not change. The Romanian Wikipedia article now lists 70 as well. Two consequences for your code: a range check of 01 to 52 rejects every code the new system issues, and you can no longer read a county from a new code.
How does the CNP checksum algorithm work?
The CNP check digit is a weighted sum mod 11. Multiply each of the first 12 digits by the digit in the same position of the key 279146358279, add the products and divide by 11. The remainder is the check digit, except that a remainder of 10 becomes 1. Here is the sample worked by hand:
The products add up to 260, and 260 mod 11 is 7, the last digit of 1960523123457.
The fold of 10 into 1 is where the check gets weaker. Remainders 1 and 10 both produce a 1, so about 2 codes in 11 end in 1, and a typo that moves the sum from one to the other passes. 1960523120031 is valid; change its twelfth digit and 1960523120021 passes too.
We measured it with the checkCnp function below. Over 20,000 random valid codes, we tried every single-digit change and every swap of two neighboring digits. The checksum alone missed 1.7% of the changes, every one of them through the fold, and 1.9% of the swaps. Checking the date and the county as well brought both down to about 1.2%. Of a million random 13-digit strings, 10% passed the checksum and 0.14% passed every check. So about 1 typo in 80 passes every check, which is why the form below also reads the code back to the person.
How do I validate a CNP in JavaScript or TypeScript?
Run the checks in order and say which one failed, so the form can explain the problem instead of printing "invalid". Return the decoded parts from the same pass, so nothing parses the code twice. The code is TypeScript with no dependency; drop the types and it runs as plain JavaScript:
Four details that CNP validation in JavaScript often gets wrong:
- The century comes from the first digit.
new Date(24, 5, 1)is June 1, 1924, because JavaScript maps two-digit years to the 1900s. Build the full year first, and check that the day survived the round trip, or February 29, 1900 slips through. - A birth date cannot be in the future.
5300101123456decodes to January 1, 2030. Compare against today in Romania, not in UTC or in the server's zone. - The county list has gaps and a new member. 49 and 50 do not exist, and 70 does.
- Codes starting with 7 to 9 have no century. We accept the day if it exists in either century and return
birthDate: nullinstead of guessing, because a wrong guess turns an 11-year-old resident into a 111-year-old.
How do I read the birth date, age and county from a CNP?
Take them from info, and turn them into a sentence the person can compare with their ID card. Reading a valid code back catches the mistake no checksum can: someone typing a valid code that belongs to their spouse.
A code with county 70 reads "Male, born June 1, 2024", without a county, which is the truth.
How do I write a zod rule for a CNP?
Wrap checkCnp in a check that maps each problem to its own message, then strip the separators so the database gets the 13 digits. The schema uses zod 4 (.check() and the error param):
"Digits 2 to 7 are not a real birth date" tells the person where to look, which "Invalid CNP" never does. If you use the form-validation lib from Wingo UI Pro, it has rules for CUI and IBAN but none for CNP, so this rule sits next to them: with const v = createValidators(), write z.object({ email: v.email(), iban: v.iban(), cnp }).
How do I build a CNP field that checks as you type?
Use a masked field with a numeric keypad, and show the verdict the moment the 13th digit lands. The cnp preset of Masked Input sets inputMode="numeric" and autoComplete="off", keeps the value to 13 digits, drops spaces, dots and dashes from a paste, and pops in a check or an alert icon once the code is complete. The red ring and aria-invalid wait until the person leaves the field. The preset's own check accepts any county from 01 to 52, so it lets 49 and 50 through and rejects 70. Pass validate={isValidCnp} and the field agrees with the zod rule on every code.
$ npx wingo-ui@latest add masked-inputWire it with React Hook Form's Controller (npm install react-hook-form @hookform/resolvers zod). Use Controller, not register: the input element holds the masked text, and the raw digits come through onValueChange:
The field uses 16px text on phones, so iOS does not zoom into it, and grows to 44px on touch screens. Its error replaces the description, sets aria-invalid and links the message with aria-describedby. For onSave, pass a Server Action that stores the code with the vault in the next section; the CNP then travels in a POST body, never in a URL.
Should I collect and store CNPs at all?
Only when a law or a contract needs one. A CNP gives away a birth date and a sex, and on codes issued before 2024 a county, so a leak of it is worse than a leak of an email address. If you only need an age, ask for the birth date. If you send B2C invoices through e-Factura, you do not need it either: since OUG 138/2024 a business may use 0000000000000 when a private buyer gives no tax code, and it is not required to collect CNPs (opens in a new tab). Accept those 13 zeros before you call checkCnp if the field feeds an invoice.
$ npx wingo-ui@latest add invoicingWhen you do collect it, Romanian Law 190/2018 (opens in a new tab) applies on top of GDPR. Its article 2 lists the CNP as a national identification number, and article 4 sets the rules for processing one. Processing it needs a GDPR legal basis, and when that basis is legitimate interest the law requires four safeguards: technical and organizational measures for data minimization and security, a data protection officer, storage periods with deadlines for deletion, and regular training for the people who handle it. This is not legal advice; your DPO decides what applies to you.
What engineering can do is keep the code out of every place it does not need to be. Start with the storage, because a hash is not anonymization. The space of valid codes is small. Everyone born in the 1900s fits in about 3.6 billion candidates: 36,524 days, 2 sex digits, 50 county codes and 999 serials, with the check digit computed. On one core of our dev server, Node's createHash hashed about 2 million codes a second, so walking the whole century takes about half an hour. Knowing the birth date leaves 99,900 candidates, about 50 milliseconds of hashing.
So store two things: the code encrypted, and a keyed hash for lookups and unique indexes. The key never lives in the database:
The masked form keeps the serial and the check digit and hides the sex, the birth date and the county. The rest is a checklist:
- Never in a URL. Send the code in a POST body. Query strings end up in access logs, browser history and analytics.
- Never in logs or error reports. Redact
cnpin your logger and in your error tracker's event filter, and check that session replay masks the input. - Never back to the browser. After the save, the client gets
maskCnp, and only a server route that needs the full code callsdecryptCnp. - A deletion date. Decide when each stored code is deleted, and run the job that deletes it.
Should I use a CNP validator package instead?
Only after you read its county table. Code 70 is recent, and a package that misses it rejects real codes. As of October 2026, the most downloaded CNP package on npm, romanian-personal-identity-code-validator (opens in a new tab), is at 1.1.4 (published May 19, 2024, MIT, 667 downloads from October 1 to 7, 2026). It decodes the birth date, sex, birthplace and age, depends on Moment.js, and its county table has no 70, so it rejects every code the new system issues. It does list 80, as a code obtained abroad. python-stdnum's ro.cnp module (opens in a new tab) accepts 70, and also 80 to 83, labeled "Unknown". We found no official source for 80 to 83, so the code above leaves them out. If a real customer's code fails on one of them, add it.
lib/cnp.ts is about 100 lines with no dependency, and you can read every rule in it. When the format changes again, you edit one table instead of waiting for a maintainer.
Should I copy this code or install it?
Copy it when you need one field: lib/cnp.ts, the zod rule and the free Input with inputMode="numeric" cover a contract or payroll form. Masked Input, the field from the demo, is part of Wingo UI Pro, and so is the invoicing lib, whose parseCnp and isValidCnp decode the sex, birth date and county, accept 70 and treat 0000000000000 as a buyer without a CNP. They skip the future-date check, so keep checkCnp for the form. Install the field with npx wingo-ui@latest add masked-input.
The other Romanian ids have their own guides: Romanian CUI validation for companies and IBAN validation in JavaScript for bank accounts. For the rest of the form anatomy, read React form components: accessible fields, zod and mobile, or browse more component guides.
FAQ
How do I validate a Romanian CNP?
Check that it has 13 digits, that the first digit is 1 to 9, that digits 2 to 7 form a real birth date that is not in the future, and that digits 8 and 9 are a county code (01 to 48, 51, 52 or 70). Then multiply the first 12 digits by 2, 7, 9, 1, 4, 6, 3, 5, 8, 2, 7, 9, add the products and take the sum mod 11, turning 10 into 1; the result must equal the last digit.
What does county code 70 mean in a CNP?
Since May 2024, the codes generated by Romania's new civil status IT system carry 70 in place of the county, whatever the place of birth. Codes issued before keep their county, so a validator that only accepts 01 to 52 rejects every code the new system issues.
Why does the CNP check digit use 1 when the remainder is 10?
The check digit has room for one digit, so a remainder of 10 is written as 1. The cost is that remainders 1 and 10 look the same, so a typo that moves the sum between them still passes; in our test that was 1.7% of single-digit typos.
Can I get someone's age from their CNP?
For codes starting with 1 to 6, yes: the first digit gives the century and digits 2 to 7 the year, month and day. Codes for foreign residents and foreign citizens start with 7, 8 or 9 and do not encode the century, so ask for the birth date when the age matters.
Is it legal to store a CNP in my database?
Romanian Law 190/2018 lists the CNP as a national identification number: you need a GDPR legal basis, and if that basis is legitimate interest you also need data minimization and security measures, a data protection officer, retention periods and staff training. Collect it only when a law or a contract needs it, and ask your DPO before you do.
Do I need the customer's CNP for an e-Factura B2C invoice?
No. Since OUG 138/2024, a business may use 0000000000000, thirteen zeros, when a private buyer gives no tax identification code, and it is not required to collect the CNP.
- CNP
- Romania
- Form Validation
- Zod
- TypeScript
- Privacy