# Romanian CNP Validator in TypeScript, Zod and React

> Build a Romanian CNP validator in TypeScript: the weighted check digit, real birth dates, the new county code 70, a zod rule, a React field and safe storage.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 9, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 13 min
- Canonical: https://wingo-ui.com/blog/romanian-cnp-validator-javascript

## TL;DR

A Romanian CNP validator checks five things: 13 digits, a first digit from 1 to 9, a birth date that exists and is not in the future, a county code (01 to 48, 51, 52, or 70, which the new civil status system has used since May 2024) and the check digit. The check digit is the sum of the first 12 digits multiplied by the matching digits of 279146358279, mod 11, with a remainder of 10 written as 1. Store a valid CNP encrypted, with a keyed hash for lookups, because a plain SHA-256 of one can be brute forced in about half an hour on one CPU core.

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](https://wingo-ui.com/components/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](https://ro.wikipedia.org/wiki/Cod_numeric_personal_(Rom%C3%A2nia)) documents each one. Here they are on `1960523123457`, the sample code from the demo further down:

| Digits | Part | Meaning | In the sample |
|---|---|---|---|
| 1 | S | Sex and century of birth | 1: male, born 1900 to 1999 |
| 2 to 3 | AA | Year of birth | 96 |
| 4 to 5 | LL | Month of birth | 05 |
| 6 to 7 | ZZ | Day of birth | 23 |
| 8 to 9 | JJ | County code | 12: Cluj |
| 10 to 12 | NNN | Serial number, 001 to 999 | 345 |
| 13 | C | Check digit | 7 |

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](https://www.bugetul.ro/mai-schimba-procesul-prin-care-sunt-generate-cnp-urile-ce-se-intampla-cu-codurile-deja-existente/) 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:

| Position | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Digit | 1 | 9 | 6 | 0 | 5 | 2 | 3 | 1 | 2 | 3 | 4 | 5 |
| Key | 2 | 7 | 9 | 1 | 4 | 6 | 3 | 5 | 8 | 2 | 7 | 9 |
| Product | 2 | 63 | 54 | 0 | 20 | 12 | 9 | 5 | 16 | 6 | 28 | 45 |

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:

```ts
// lib/cnp.ts
export type CnpProblem = "format" | "sex" | "date" | "county" | "checksum";

export type CnpInfo = {
  cnp: string;
  sex: "male" | "female" | null;
  // "1996-05-23"; null for 7 to 9, whose century is not encoded
  birthDate: string | null;
  // "12"; "70" for codes issued by the civil status system since May 2024
  county: string;
  countyName: string | null;
  serial: string;
};

export type CnpCheck = { problem: CnpProblem; info: null } | { problem: null; info: CnpInfo };

const WEIGHTS = [2, 7, 9, 1, 4, 6, 3, 5, 8, 2, 7, 9];

// 01 to 39 in alphabetical order, then Bucharest and its sectors (7 and 8 no longer exist)
const COUNTIES: Record<string, string> = Object.fromEntries(
  [
    "Alba", "Arad", "Argeș", "Bacău", "Bihor", "Bistrița-Năsăud", "Botoșani", "Brașov", "Brăila", "Buzău",
    "Caraș-Severin", "Cluj", "Constanța", "Covasna", "Dâmbovița", "Dolj", "Galați", "Gorj", "Harghita", "Hunedoara",
    "Ialomița", "Iași", "Ilfov", "Maramureș", "Mehedinți", "Mureș", "Neamț", "Olt", "Prahova", "Satu Mare",
    "Sălaj", "Sibiu", "Suceava", "Teleorman", "Timiș", "Tulcea", "Vaslui", "Vâlcea", "Vrancea", "București",
    "București, Sector 1", "București, Sector 2", "București, Sector 3", "București, Sector 4",
    "București, Sector 5", "București, Sector 6", "București, Sector 7", "București, Sector 8",
  ].map((name, i) => [String(i + 1).padStart(2, "0"), name]),
);
COUNTIES["51"] = "Călărași";
COUNTIES["52"] = "Giurgiu";

const CENTURY: Record<string, number> = { "1": 1900, "2": 1900, "3": 1800, "4": 1800, "5": 2000, "6": 2000 };

export function cnpCheckDigit(first12: string): number {
  let sum = 0;
  for (let i = 0; i < 12; i++) sum += Number(first12[i]) * WEIGHTS[i];
  // a remainder of 10 does not fit in one digit, so it becomes 1
  return sum % 11 === 10 ? 1 : sum % 11;
}

// "1996-05-23", or null when that day does not exist
function isoDate(year: number, month: number, day: number): string | null {
  const date = new Date(Date.UTC(year, month - 1, day));
  if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return null;
  return date.toISOString().slice(0, 10);
}

// the calendar day in Romania, where the codes are issued
export const todayInRomania = () => new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Bucharest" }).format(new Date());

export function checkCnp(input: string, today = todayInRomania()): CnpCheck {
  const cnp = input.replace(/[\s.-]/g, "");
  const fail = (problem: CnpProblem): CnpCheck => ({ problem, info: null });
  if (!/^\d{13}$/.test(cnp)) return fail("format");

  const s = cnp[0];
  if (s === "0") return fail("sex");

  const [yy, mm, dd] = [cnp.slice(1, 3), cnp.slice(3, 5), cnp.slice(5, 7)].map(Number);
  let birthDate: string | null = null;
  if (CENTURY[s]) {
    birthDate = isoDate(CENTURY[s] + yy, mm, dd);
    if (!birthDate || birthDate > today) return fail("date");
  } else {
    // 7 to 9: the day has to exist in one of the two centuries it could be from
    const candidates = [2000 + yy, 1900 + yy].map((year) => isoDate(year, mm, dd));
    if (!candidates.some((date) => date && date <= today)) return fail("date");
  }

  const county = cnp.slice(7, 9);
  if (county !== "70" && !COUNTIES[county]) return fail("county");

  if (cnpCheckDigit(cnp) !== Number(cnp[12])) return fail("checksum");

  return {
    problem: null,
    info: {
      cnp,
      sex: s === "9" ? null : Number(s) % 2 === 1 ? "male" : "female",
      birthDate,
      county,
      countyName: COUNTIES[county] ?? null,
      serial: cnp.slice(9, 12),
    },
  };
}

export const isValidCnp = (input: string) => checkCnp(input).problem === null;
```

```ts
checkCnp("1960 523 123 457");
// { problem: null, info: { cnp: "1960523123457", sex: "male", birthDate: "1996-05-23", county: "12", countyName: "Cluj", serial: "345" } }
checkCnp("1960523123458"); // { problem: "checksum", info: null }
checkCnp("1000229123456"); // { problem: "date", info: null }, 1900 was not a leap year
checkCnp("1960523491239"); // { problem: "county", info: null }, there is no county 49
isValidCnp("5240601701230"); // true, county 70
```

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.** `5300101123456` decodes 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: null` instead 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.

```ts
// lib/cnp.ts (continued)
// "Male, born May 23, 1996, Cluj"
export function describeCnp(info: CnpInfo, locale = "en-US"): string {
  const sex = info.sex === "male" ? "Male" : info.sex === "female" ? "Female" : "Foreign national";
  const born = info.birthDate
    ? `born ${new Intl.DateTimeFormat(locale, { dateStyle: "long", timeZone: "UTC" }).format(new Date(info.birthDate))}`
    : "resident";
  return [sex, born, info.countyName].filter(Boolean).join(", ");
}

// full years on a given day: ageOn("2008-10-10", "2026-10-09") is 17
export function ageOn(birthDate: string, today = todayInRomania()): number {
  const [by, bm, bd] = birthDate.split("-").map(Number);
  const [ty, tm, td] = today.split("-").map(Number);
  return ty - by - (tm < bm || (tm === bm && td < bd) ? 1 : 0);
}
```

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):

```ts
// lib/cnp-schema.ts
import { z } from "zod";
import { ageOn, checkCnp, type CnpProblem } from "@/lib/cnp";

const MESSAGES: Record<CnpProblem, string> = {
  format: "A CNP has 13 digits",
  sex: "A CNP starts with a digit from 1 to 9",
  date: "Digits 2 to 7 are not a real birth date",
  county: "Digits 8 and 9 are not a county code",
  checksum: "The last digit does not match. Check for a typo",
};

export const cnp = z
  .string()
  .trim()
  .min(1, { error: "Enter your CNP", abort: true })
  .check((ctx) => {
    const { problem } = checkCnp(ctx.value);
    if (problem) ctx.issues.push({ code: "custom", message: MESSAGES[problem], input: ctx.value });
  })
  .transform((value) => value.replace(/[\s.-]/g, ""));

// for contracts that need an adult; 7 to 9 have no birth date, so ask for one separately
export const adultCnp = cnp.refine(
  (value) => {
    const { info } = checkCnp(value);
    return !info?.birthDate || ageOn(info.birthDate) >= 18;
  },
  { error: "You need to be 18 or older" },
);
```

"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](https://wingo-ui.com/components/form-validation) 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.

> Live demo (Masked Input): Type 1960523123457: a check appears once the 13th digit is in. Change the last digit and it turns into an alert; press Save to see the error message. Try it at [Masked Input](https://wingo-ui.com/components/masked-input) and install it with `npx wingo-ui@latest add masked-input`.

Wire 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`:

```tsx
"use client";

import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { MaskedInput } from "@/components/ui/masked-input";
import { checkCnp, describeCnp, isValidCnp } from "@/lib/cnp";
import { cnp } from "@/lib/cnp-schema";

const schema = z.object({ cnp });
type Values = z.output<typeof schema>;

export function IdentityStep({ onSave }: { onSave: (values: Values) => Promise<void> }) {
  const form = useForm<z.input<typeof schema>, unknown, Values>({
    resolver: zodResolver(schema),
    // the first error waits for blur, then the field re-checks on every keystroke
    mode: "onTouched",
    defaultValues: { cnp: "" },
  });

  return (
    <form noValidate onSubmit={form.handleSubmit(onSave)} className="flex flex-col gap-4">
      <Controller
        control={form.control}
        name="cnp"
        render={({ field, fieldState }) => {
          const { info } = checkCnp(field.value);
          return (
            <MaskedInput
              preset="cnp"
              // the same check as the zod rule, so the icon and the error never disagree
              validate={isValidCnp}
              label="CNP (personal numeric code)"
              placeholder="13 digits"
              showMask="focus"
              placeholderChar="·"
              ref={field.ref}
              value={field.value}
              onValueChange={(raw) => field.onChange(raw)}
              onBlur={field.onBlur}
              // read back, so a valid code that is not theirs stands out
              description={info ? describeCnp(info) : "The 13 digits on your ID card."}
              error={fieldState.error?.message}
              enterKeyHint="done"
            />
          );
        }}
      />
      <Button type="submit" loading={form.formState.isSubmitting}>
        Continue
      </Button>
    </form>
  );
}
```

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](https://contabilul.manager.ro/a/29150/e-factura-b2c-noutati-in-2025-sanctiuni-si-studii-de-caz.html). Accept those 13 zeros before you call `checkCnp` if the field feeds an invoice.

> Live demo (Invoicing): The check under the invoice reads 0000000000000 as a buyer who gave no CNP, the placeholder e-Factura accepts on B2C invoices. Try it at [Invoicing](https://wingo-ui.com/components/invoicing) and install it with `npx wingo-ui@latest add invoicing`.

When you do collect it, [Romanian Law 190/2018](https://legislatie.just.ro/Public/DetaliiDocument/203151) 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:

```ts
// lib/cnp-vault.ts
import "server-only";
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from "node:crypto";

// two 32 byte keys, base64, from your secret manager: openssl rand -base64 32
// read on use, not at import, so next build works on a machine without the secrets
function key(name: "CNP_ENCRYPTION_KEY" | "CNP_LOOKUP_KEY"): Buffer {
  const value = Buffer.from(process.env[name] ?? "", "base64");
  if (value.length !== 32) throw new Error(`${name} must be 32 bytes, base64`);
  return value;
}

// same CNP, same value: for unique indexes and "is this person already a customer"
export function cnpLookupHash(cnp: string): string {
  return createHmac("sha256", key("CNP_LOOKUP_KEY")).update(cnp).digest("base64url");
}

// iv + auth tag + ciphertext in one string; a new iv every time, so equal CNPs never look equal
export function encryptCnp(cnp: string): string {
  const iv = randomBytes(12);
  const cipher = createCipheriv("aes-256-gcm", key("CNP_ENCRYPTION_KEY"), iv);
  const data = Buffer.concat([cipher.update(cnp, "utf8"), cipher.final()]);
  return Buffer.concat([iv, cipher.getAuthTag(), data]).toString("base64");
}

export function decryptCnp(stored: string): string {
  const raw = Buffer.from(stored, "base64");
  const decipher = createDecipheriv("aes-256-gcm", key("CNP_ENCRYPTION_KEY"), raw.subarray(0, 12));
  decipher.setAuthTag(raw.subarray(12, 28));
  return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString("utf8");
}

// what support screens and receipts show: "•••••••••3457"
export const maskCnp = (cnp: string) => "•".repeat(9) + cnp.slice(-4);
```

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 `cnp` in 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 calls `decryptCnp`.
- **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](https://www.npmjs.com/package/romanian-personal-identity-code-validator), 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](https://github.com/arthurdejong/python-stdnum/blob/master/stdnum/ro/cnp.py) 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](https://wingo-ui.com/components/input) with `inputMode="numeric"` cover a contract or payroll form. Masked Input, the field from the demo, is part of [Wingo UI Pro](https://wingo-ui.com/pricing), and so is the [invoicing lib](https://wingo-ui.com/components/invoicing), 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](https://wingo-ui.com/blog/romanian-cui-validation-javascript) for companies and [IBAN validation in JavaScript](https://wingo-ui.com/blog/iban-validation-javascript) for bank accounts. For the rest of the form anatomy, read [React form components: accessible fields, zod and mobile](https://wingo-ui.com/blog/react-form-components-guide), or browse more [component guides](https://wingo-ui.com/blog/category/components).

## Components in this post

- [Masked Input](https://wingo-ui.com/components/masked-input) (Pro): A text field that formats itself as you type or paste: card number, expiry, CVC, IBAN, CNP, postal code or a custom mask. Install: `npx wingo-ui@latest add masked-input`
- [Invoicing](https://wingo-ui.com/components/invoicing) (Pro): Invoice math and the ids an invoice carries: VAT per rate the way e-Factura checks it, Romanian VAT rates, CUI, IBAN with its bank, CNP. Install: `npx wingo-ui@latest add invoicing`
- [Form validation](https://wingo-ui.com/components/form-validation) (Pro): Zod rules with one set of messages (English and Romanian), react-hook-form glue and a pretend request, for the forms of blocks. Install: `npx wingo-ui@latest add form-validation`
- [Input](https://wingo-ui.com/components/input) (Free): The single-line text field: icons and addons inside the box, a clear button, a loading spinner, a counter and floating labels. Install: `npx wingo-ui@latest add input`

## 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.

---

Source: https://wingo-ui.com/blog/romanian-cnp-validator-javascript
