# CNP prüfen: rumänische Personennummer in TypeScript und Zod

> CNP prüfen in TypeScript: die gewichtete Prüfziffer, echte Geburtsdaten, der neue Kreiscode 70, eine zod-Regel, ein React-Feld und sicheres Speichern.

- Autor: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Veröffentlicht: 9. Okt. 2026
- Kategorie: [Komponenten-Guides](https://wingo-ui.com/de/blog/category/components)
- Lesezeit: 13 Min.
- Kanonische URL: https://wingo-ui.com/de/blog/romanian-cnp-validator-javascript
- Englische Fassung: https://wingo-ui.com/blog/romanian-cnp-validator-javascript

## Kurz gesagt

Ein rumänischer CNP-Validator prüft fünf Dinge: 13 Ziffern, eine erste Ziffer von 1 bis 9, ein Geburtsdatum, das existiert und nicht in der Zukunft liegt, einen Kreiscode (01 bis 48, 51, 52 oder 70, den das neue Personenstandssystem seit Mai 2024 nutzt) und die Prüfziffer. Die Prüfziffer ist die Summe der ersten 12 Ziffern, jeweils multipliziert mit der passenden Ziffer aus 279146358279, mod 11, wobei ein Rest von 10 als 1 geschrieben wird. Speichere einen gültigen CNP verschlüsselt und mit einem Keyed Hash für Abfragen, denn ein einfacher SHA-256 davon lässt sich auf einem CPU-Kern in etwa einer halben Stunde per Brute Force knacken.

Dein Formular für Checkout, Lohnabrechnung oder Mietvertrag fragt nach dem CNP (cod numeric personal, die rumänische Personennummer), und ein Regex für 13 Ziffern akzeptiert jeden Tippfehler. Wer einen CNP prüfen will, muss mehr testen als die Länge: eine Ziffer für Geschlecht und Jahrhundert, ein Geburtsdatum, das existiert, einen Kreiscode und eine gewichtete Prüfziffer. In diesem Tutorial schreiben wir diesen Check in TypeScript, zerlegen den Code in seine Teile, machen daraus eine zod-Regel und ein React-Feld, das beim Tippen prüft, und klären am Ende, was du tun musst, bevor du auch nur einen CNP speicherst. Das Feld ist [Masked Input](https://wingo-ui.com/components/masked-input) aus Wingo UI, der Bibliothek, die wir bauen. Wir sind also nicht neutral. Der Validator ist reines TypeScript, das du überall einfügen kannst.

## Wie ist der cod numeric personal aufgebaut?

Dreizehn Ziffern in sieben Teilen, geschrieben als S AA LL ZZ JJ NNN C. Der [rumänische Wikipedia-Artikel zum CNP](https://ro.wikipedia.org/wiki/Cod_numeric_personal_(Rom%C3%A2nia)) beschreibt jeden davon. Hier sind sie am Beispiel `1960523123457`, dem Beispielcode aus der Demo weiter unten:

| Ziffern | Teil | Bedeutung | Im Beispiel |
|---|---|---|---|
| 1 | S | Geschlecht und Geburtsjahrhundert | 1: männlich, geboren 1900 bis 1999 |
| 2 bis 3 | AA | Geburtsjahr | 96 |
| 4 bis 5 | LL | Geburtsmonat | 05 |
| 6 bis 7 | ZZ | Geburtstag | 23 |
| 8 bis 9 | JJ | Kreiscode | 12: Cluj |
| 10 bis 12 | NNN | Laufende Nummer, 001 bis 999 | 345 |
| 13 | C | Prüfziffer | 7 |

Von 1 bis 8 ist die erste Ziffer bei Männern ungerade und bei Frauen gerade. 1 und 2 bedeuten: geboren in den 1900ern, 3 und 4 in den 1800ern, 5 und 6 in den 2000ern. 7 und 8 stehen für ausländische Einwohner, und bei ihnen ist das Jahrhundert nicht kodiert. Die englische Wikipedia führt außerdem 9 für ausländische Staatsangehörige; der rumänische Artikel hört bei 8 auf. Behandle 9 also als selten und akzeptier sie.

Die Kreiscodes sind 01 bis 39 für die Kreise in alphabetischer Reihenfolge (Alba bis Vrancea), 40 für Bukarest, 41 bis 46 für seine sechs Sektoren, 47 und 48 für die früheren Sektoren 7 und 8, die nur in alten Codes vorkommen, und 51 und 52 für Călărași und Giurgiu. Eine 49 oder 50 gibt es nicht.

Und seit Mai 2024 gibt es die 70. Codes, die Rumäniens neues IT-System für den Personenstand erzeugt, tragen bei jeder Registrierung 70 anstelle des Kreises, egal wo die Person geboren ist. Das [Innenministerium erklärte im Mai 2024](https://www.bugetul.ro/mai-schimba-procesul-prin-care-sunt-generate-cnp-urile-ce-se-intampla-cu-codurile-deja-existente/), dass das mit Geburten begann, die ab dem 8. Mai im Kreis Ilfov und in sieben Städten registriert wurden, dazu mit den Geburtsurkunden frisch eingebürgerter Personen, und dass bestehende Codes sich nicht ändern. Auch der rumänische Wikipedia-Artikel führt die 70 inzwischen. Zwei Folgen für deinen Code: Eine Bereichsprüfung von 01 bis 52 lehnt jeden Code des neuen Systems ab, und aus einem neuen Code kannst du keinen Kreis mehr ablesen.

## Wie funktioniert die CNP-Prüfziffer?

Die CNP-Prüfziffer ist eine gewichtete Summe mod 11. Multipliziere jede der ersten 12 Ziffern mit der Ziffer an derselben Stelle des Schlüssels `279146358279`, addiere die Produkte und teile durch 11. Der Rest ist die Prüfziffer, nur dass ein Rest von 10 zu 1 wird. Hier ist das Beispiel von Hand gerechnet:

| Stelle | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Ziffer | 1 | 9 | 6 | 0 | 5 | 2 | 3 | 1 | 2 | 3 | 4 | 5 |
| Schlüssel | 2 | 7 | 9 | 1 | 4 | 6 | 3 | 5 | 8 | 2 | 7 | 9 |
| Produkt | 2 | 63 | 54 | 0 | 20 | 12 | 9 | 5 | 16 | 6 | 28 | 45 |

Die Produkte ergeben zusammen 260, und 260 mod 11 ist 7, die letzte Ziffer von `1960523123457`.

Genau dort, wo aus 10 eine 1 wird, wird der Check schwächer. Die Reste 1 und 10 ergeben beide eine 1, also enden etwa 2 von 11 Codes auf 1, und ein Tippfehler, der die Summe vom einen zum anderen verschiebt, kommt durch. `1960523120031` ist gültig; änderst du die zwölfte Ziffer, besteht `1960523120021` ebenfalls.

Wir haben das mit der Funktion `checkCnp` weiter unten gemessen. Bei 20.000 zufälligen gültigen Codes haben wir jede Änderung einer einzelnen Ziffer und jede Vertauschung zweier benachbarter Ziffern ausprobiert. Die Prüfsumme allein übersah 1,7 % der Änderungen, alle wegen dieser doppelten 1, und 1,9 % der Vertauschungen. Mit dem zusätzlichen Check von Datum und Kreis sanken beide auf etwa 1,2 %. Von einer Million zufälliger 13-stelliger Strings bestanden 10 % die Prüfsumme und 0,14 % alle Checks. Etwa 1 von 80 Tippfehlern besteht also jeden Check, und deshalb zeigt das Formular unten der Person auch an, was in ihrem Code steht.

## Wie validiere ich einen CNP in JavaScript oder TypeScript?

Führ die Checks der Reihe nach aus und sag, welcher fehlgeschlagen ist. So kann das Formular das Problem erklären, statt nur „ungültig“ anzuzeigen. Gib die zerlegten Teile aus demselben Durchlauf zurück, damit nichts den Code zweimal parst. Der Code ist TypeScript ohne Abhängigkeit; lass die Typen weg, und er läuft als reines 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 bei 7 bis 9, deren Jahrhundert nicht kodiert ist
  birthDate: string | null;
  // "12"; "70" bei Codes, die das Personenstandssystem seit Mai 2024 ausgibt
  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 bis 39 alphabetisch, dann Bukarest und seine Sektoren (7 und 8 gibt es nicht mehr)
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];
  // ein Rest von 10 passt nicht in eine Ziffer, also wird er zu 1
  return sum % 11 === 10 ? 1 : sum % 11;
}

// "1996-05-23", oder null, wenn es den Tag nicht gibt
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);
}

// der Kalendertag in Rumänien, wo die Codes ausgegeben werden
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 bis 9: der Tag muss in einem der beiden möglichen Jahrhunderte existieren
    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 war kein Schaltjahr
checkCnp("1960523491239"); // { problem: "county", info: null }, einen Kreis 49 gibt es nicht
isValidCnp("5240601701230"); // true, Kreis 70
```

Vier Details, die oft schiefgehen, wenn du einen CNP in JavaScript validieren willst:

- **Das Jahrhundert kommt aus der ersten Ziffer.** `new Date(24, 5, 1)` ist der 1. Juni 1924, weil JavaScript zweistellige Jahre auf die 1900er abbildet. Bau zuerst das volle Jahr und prüf, ob der Tag die Umwandlung hin und zurück übersteht, sonst rutscht der 29. Februar 1900 durch.
- **Ein Geburtsdatum kann nicht in der Zukunft liegen.** `5300101123456` ergibt den 1. Januar 2030. Vergleich mit dem heutigen Tag in Rumänien, nicht in UTC oder in der Zeitzone des Servers.
- **Die Kreisliste hat Lücken und ein neues Mitglied.** 49 und 50 gibt es nicht, die 70 schon.
- **Codes, die mit 7 bis 9 beginnen, haben kein Jahrhundert.** Wir akzeptieren den Tag, wenn er in einem der beiden Jahrhunderte existiert, und geben `birthDate: null` zurück, statt zu raten. Rätst du falsch, wird aus einer 11-jährigen Einwohnerin eine 111-jährige.

## Wie lese ich Geburtsdatum, Alter und Kreis aus einem CNP?

Hol sie dir aus `info` und mach daraus einen Satz, den die Person mit ihrem Ausweis vergleichen kann. Zeigst du einen gültigen Code so in Klartext an, fängst du den Fehler ab, den keine Prüfsumme findet: Jemand tippt einen gültigen Code ein, der dem Ehepartner gehört.

```ts
// lib/cnp.ts (Fortsetzung)
// "Männlich, geboren am 23. Mai 1996, Cluj"
export function describeCnp(info: CnpInfo, locale = "de-DE"): string {
  const sex = info.sex === "male" ? "Männlich" : info.sex === "female" ? "Weiblich" : "Ausländische Person";
  const born = info.birthDate
    ? `geboren am ${new Intl.DateTimeFormat(locale, { dateStyle: "long", timeZone: "UTC" }).format(new Date(info.birthDate))}`
    : "in Rumänien ansässig";
  return [sex, born, info.countyName].filter(Boolean).join(", ");
}

// volle Jahre an einem bestimmten Tag: ageOn("2008-10-10", "2026-10-09") ist 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);
}
```

Ein Code mit Kreis 70 liest sich als „Männlich, geboren am 1. Juni 2024“, ohne Kreis, und das stimmt auch.

## Wie schreibe ich eine zod-Regel für einen CNP?

Pack `checkCnp` in einen Check, der jedes Problem auf eine eigene Meldung abbildet, und entferne danach die Trennzeichen, damit die Datenbank die 13 Ziffern bekommt. Das Schema nutzt zod 4 (`.check()` und den Parameter `error`):

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

const MESSAGES: Record<CnpProblem, string> = {
  format: "Ein CNP hat 13 Ziffern",
  sex: "Ein CNP beginnt mit einer Ziffer von 1 bis 9",
  date: "Die Ziffern 2 bis 7 sind kein echtes Geburtsdatum",
  county: "Die Ziffern 8 und 9 sind kein Kreiscode",
  checksum: "Die letzte Ziffer passt nicht. Prüf, ob du dich vertippt hast",
};

export const cnp = z
  .string()
  .trim()
  .min(1, { error: "Gib deinen CNP ein", 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, ""));

// für Verträge, die eine volljährige Person brauchen; 7 bis 9 haben kein Geburtsdatum, also frag separat danach
export const adultCnp = cnp.refine(
  (value) => {
    const { info } = checkCnp(value);
    return !info?.birthDate || ageOn(info.birthDate) >= 18;
  },
  { error: "Du musst mindestens 18 Jahre alt sein" },
);
```

„Die Ziffern 2 bis 7 sind kein echtes Geburtsdatum“ sagt der Person, wo sie suchen muss. „Ungültiger CNP“ tut das nie. Wenn du die [form-validation-Lib](https://wingo-ui.com/components/form-validation) aus Wingo UI Pro nutzt: Sie hat Regeln für CUI und IBAN, aber keine für den CNP, also steht diese Regel daneben. Mit `const v = createValidators()` schreibst du `z.object({ email: v.email(), iban: v.iban(), cnp })`.

## Wie baue ich ein CNP-Feld, das beim Tippen prüft?

Nimm ein maskiertes Feld mit Zifferntastatur und zeig das Ergebnis, sobald die 13. Ziffer drin ist. Das Preset `cnp` von Masked Input setzt `inputMode="numeric"` und `autoComplete="off"`, begrenzt den Wert auf 13 Ziffern, entfernt Leerzeichen, Punkte und Bindestriche beim Einfügen und blendet ein Häkchen oder ein Warnsymbol ein, sobald der Code vollständig ist. Der rote Rahmen und `aria-invalid` warten, bis die Person das Feld verlässt. Der eigene Check des Presets akzeptiert jeden Kreis von 01 bis 52, lässt also 49 und 50 durch und lehnt 70 ab. Übergib `validate={isValidCnp}`, dann stimmt das Feld bei jedem Code mit der zod-Regel überein.

> Live-Demo (Masked Input): Tipp 1960523123457 ein: Ein Häkchen erscheint, sobald die 13. Ziffer drin ist. Änderst du die letzte Ziffer, wird daraus ein Warnsymbol; drück auf Save, um die Fehlermeldung zu sehen. Probier sie unter [Masked Input](https://wingo-ui.com/components/masked-input) aus und installier sie mit `npx wingo-ui@latest add masked-input`.

Bind es mit dem `Controller` von React Hook Form an (`npm install react-hook-form @hookform/resolvers zod`). Nimm `Controller`, nicht `register`: Das Input-Element hält den maskierten Text, und die rohen Ziffern kommen über `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),
    // der erste Fehler wartet auf blur, danach prüft das Feld bei jedem Tastendruck neu
    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"
              // derselbe Check wie in der zod-Regel, also widersprechen sich Symbol und Fehler nie
              validate={isValidCnp}
              label="CNP (persönliche Identifikationsnummer)"
              placeholder="13 Ziffern"
              showMask="focus"
              placeholderChar="·"
              ref={field.ref}
              value={field.value}
              onValueChange={(raw) => field.onChange(raw)}
              onBlur={field.onBlur}
              // in Klartext angezeigt, damit ein gültiger Code auffällt, der nicht der eigene ist
              description={info ? describeCnp(info) : "Die 13 Ziffern auf deinem Ausweis."}
              error={fieldState.error?.message}
              enterKeyHint="done"
            />
          );
        }}
      />
      <Button type="submit" loading={form.formState.isSubmitting}>
        Weiter
      </Button>
    </form>
  );
}
```

Das Feld nutzt auf Smartphones 16px große Schrift, damit iOS nicht hineinzoomt, und wird auf Touchscreens 44px hoch. Sein `error` ersetzt die Beschreibung, setzt `aria-invalid` und verknüpft die Meldung über `aria-describedby`. Als `onSave` übergibst du eine Server Action, die den Code mit dem Vault aus dem nächsten Abschnitt speichert; der CNP steckt dann im Body eines POST-Requests, nie in einer URL.

## Solltest du CNPs überhaupt erheben und speichern?

Nur wenn ein Gesetz oder ein Vertrag einen verlangt. Ein CNP verrät ein Geburtsdatum und ein Geschlecht, bei Codes von vor 2024 auch einen Kreis. Wird er geleakt, wiegt das also schwerer als bei einer E-Mail-Adresse. Wenn du nur ein Alter brauchst, frag nach dem Geburtsdatum. Auch für B2C-Rechnungen über e-Factura brauchst du ihn nicht: Seit OUG 138/2024 darf ein Unternehmen `0000000000000` verwenden, wenn ein privater Käufer keine Steuernummer angibt, und es [muss keine CNPs erheben](https://contabilul.manager.ro/a/29150/e-factura-b2c-noutati-in-2025-sanctiuni-si-studii-de-caz.html). Akzeptier diese 13 Nullen, bevor du `checkCnp` aufrufst, wenn das Feld in eine Rechnung fließt.

> Live-Demo (Invoicing): Der Check unter der Rechnung liest 0000000000000 als Käufer, der keinen CNP angegeben hat, den Platzhalter, den e-Factura auf B2C-Rechnungen akzeptiert. Probier sie unter [Invoicing](https://wingo-ui.com/components/invoicing) aus und installier sie mit `npx wingo-ui@latest add invoicing`.

Wenn du ihn erhebst, gilt zusätzlich zur DSGVO das [rumänische Gesetz 190/2018](https://legislatie.just.ro/Public/DetaliiDocument/203151). Sein Artikel 2 führt den CNP als nationale Identifikationsnummer, und Artikel 4 legt die Regeln für ihre Verarbeitung fest. Die Verarbeitung braucht eine Rechtsgrundlage nach DSGVO, und wenn diese das berechtigte Interesse ist, verlangt das Gesetz vier Schutzmaßnahmen: technische und organisatorische Maßnahmen für Datenminimierung und Sicherheit, einen Datenschutzbeauftragten, Speicherfristen mit Löschterminen und regelmäßige Schulungen für die Leute, die damit arbeiten. Das ist keine Rechtsberatung; dein Datenschutzbeauftragter entscheidet, was für dich gilt.

Was du technisch tun kannst: den Code von jedem Ort fernhalten, an dem er nicht sein muss. Fang beim Speichern an, denn ein Hash ist keine Anonymisierung. Der Raum gültiger Codes ist klein. Alle, die in den 1900ern geboren sind, passen in etwa 3,6 Milliarden Kandidaten: 36.524 Tage, 2 Geschlechtsziffern, 50 Kreiscodes und 999 laufende Nummern, die Prüfziffer jeweils berechnet. Auf einem Kern unseres Dev-Servers hat `createHash` von Node etwa 2 Millionen Codes pro Sekunde gehasht, also dauert das ganze Jahrhundert etwa eine halbe Stunde. Wer das Geburtsdatum kennt, hat nur noch 99.900 Kandidaten vor sich, etwa 50 Millisekunden Hashing.

Speichere also zwei Dinge: den Code verschlüsselt und einen Keyed Hash für Abfragen und Unique-Indizes. Der Schlüssel liegt nie in der Datenbank:

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

// zwei Schlüssel mit 32 Byte, base64, aus deinem Secret Manager: openssl rand -base64 32
// beim Aufruf gelesen, nicht beim Import, damit next build auf einem Rechner ohne die Secrets läuft
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} muss 32 Byte lang sein, base64`);
  return value;
}

// gleicher CNP, gleicher Wert: für Unique-Indizes und "ist diese Person schon Kunde"
export function cnpLookupHash(cnp: string): string {
  return createHmac("sha256", key("CNP_LOOKUP_KEY")).update(cnp).digest("base64url");
}

// IV + Auth-Tag + Ciphertext in einem String; jedes Mal ein neuer IV, also sehen gleiche CNPs nie gleich aus
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");
}

// was Support-Ansichten und Belege zeigen: "•••••••••3457"
export const maskCnp = (cnp: string) => "•".repeat(9) + cnp.slice(-4);
```

Die maskierte Form behält die laufende Nummer und die Prüfziffer und versteckt Geschlecht, Geburtsdatum und Kreis. Der Rest ist eine Checkliste:

- **Nie in einer URL.** Schick den Code im Body eines POST-Requests. Query-Strings landen in Access-Logs, im Browserverlauf und in Analytics-Tools.
- **Nie in Logs oder Fehlerberichten.** Schwärze `cnp` in deinem Logger und im Event-Filter deines Error Trackers, und prüf, ob Session Replay das Feld maskiert.
- **Nie zurück an den Browser.** Nach dem Speichern bekommt der Client `maskCnp`, und nur eine Server-Route, die den vollen Code braucht, ruft `decryptCnp` auf.
- **Ein Löschdatum.** Leg fest, wann jeder gespeicherte Code gelöscht wird, und lass den Job laufen, der ihn löscht.

## Solltest du stattdessen ein CNP-Validator-Paket nutzen?

Erst wenn du seine Kreistabelle gelesen hast. Der Code 70 ist neu, und ein Paket, das ihn nicht kennt, lehnt echte Codes ab. Stand Oktober 2026 ist das meistgeladene CNP-Paket auf npm, [romanian-personal-identity-code-validator](https://www.npmjs.com/package/romanian-personal-identity-code-validator), bei Version 1.1.4 (veröffentlicht am 19. Mai 2024, MIT, 667 Downloads vom 1. bis 7. Oktober 2026). Es liest Geburtsdatum, Geschlecht, Geburtsort und Alter aus, hängt von Moment.js ab, und seiner Kreistabelle fehlt die 70, also lehnt es jeden Code des neuen Systems ab. Die 80 führt es dagegen, als im Ausland vergebenen Code. Das [Modul ro.cnp von python-stdnum](https://github.com/arthurdejong/python-stdnum/blob/master/stdnum/ro/cnp.py) akzeptiert die 70 und außerdem 80 bis 83, beschriftet mit „Unknown“. Für 80 bis 83 haben wir keine offizielle Quelle gefunden, also lässt der Code oben sie weg. Wenn der Code eines echten Kunden an einem davon scheitert, ergänze ihn.

`lib/cnp.ts` hat etwa 100 Zeilen ohne Abhängigkeit, und du kannst jede Regel darin lesen. Wenn sich das Format wieder ändert, bearbeitest du eine Tabelle, statt auf einen Maintainer zu warten.

## Solltest du den Code kopieren oder installieren?

Kopier ihn, wenn du nur ein Feld brauchst: `lib/cnp.ts`, die zod-Regel und das kostenlose [Input](https://wingo-ui.com/components/input) mit `inputMode="numeric"` reichen für ein Vertrags- oder Lohnformular. Masked Input, das Feld aus der Demo, gehört zu [Wingo UI Pro](https://wingo-ui.com/de/pricing), genau wie die [invoicing-Lib](https://wingo-ui.com/components/invoicing), deren `parseCnp` und `isValidCnp` Geschlecht, Geburtsdatum und Kreis auslesen, die 70 akzeptieren und `0000000000000` als Käufer ohne CNP behandeln. Die Prüfung auf ein Geburtsdatum in der Zukunft lassen sie aus, also behalte `checkCnp` für das Formular. Installier das Feld mit `npx wingo-ui@latest add masked-input`.

Die anderen rumänischen Kennungen haben eigene Anleitungen: [CUI prüfen](https://wingo-ui.com/de/blog/romanian-cui-validation-javascript) für Firmen und [IBAN prüfen in JavaScript](https://wingo-ui.com/de/blog/iban-validation-javascript) für Bankkonten. Für den restlichen Aufbau des Formulars lies [React-Formularkomponenten: barrierefreie Felder, zod und Mobile](https://wingo-ui.com/de/blog/react-form-components-guide), oder stöbere in weiteren [Komponenten-Guides](https://wingo-ui.com/de/blog/category/components).

## Komponenten in diesem Artikel

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

## Häufige Fragen

### Wie prüfe ich einen rumänischen CNP?

Prüf, ob er 13 Ziffern hat, ob die erste Ziffer 1 bis 9 ist, ob die Ziffern 2 bis 7 ein echtes Geburtsdatum ergeben, das nicht in der Zukunft liegt, und ob die Ziffern 8 und 9 ein Kreiscode sind (01 bis 48, 51, 52 oder 70). Dann multiplizierst du die ersten 12 Ziffern mit 2, 7, 9, 1, 4, 6, 3, 5, 8, 2, 7, 9, addierst die Produkte und nimmst die Summe mod 11, wobei 10 zu 1 wird; das Ergebnis muss der letzten Ziffer entsprechen.

### Was bedeutet der Kreiscode 70 in einem CNP?

Seit Mai 2024 tragen die Codes, die Rumäniens neues IT-System für den Personenstand erzeugt, 70 anstelle des Kreises, egal wo die Person geboren ist. Früher ausgegebene Codes behalten ihren Kreis, also lehnt ein Validator, der nur 01 bis 52 akzeptiert, jeden Code des neuen Systems ab.

### Warum nutzt die CNP-Prüfziffer eine 1, wenn der Rest 10 ist?

Die Prüfziffer hat nur Platz für eine Ziffer, also wird ein Rest von 10 als 1 geschrieben. Der Preis: Die Reste 1 und 10 sehen gleich aus, also besteht ein Tippfehler, der die Summe zwischen den beiden verschiebt, den Check trotzdem; in unserem Test waren das 1,7 % der Tippfehler an einer einzelnen Ziffer.

### Kann ich das Alter einer Person aus ihrem CNP ablesen?

Bei Codes, die mit 1 bis 6 beginnen, ja: Die erste Ziffer liefert das Jahrhundert, die Ziffern 2 bis 7 Jahr, Monat und Tag. Codes für ausländische Einwohner und ausländische Staatsangehörige beginnen mit 7, 8 oder 9 und enthalten kein Jahrhundert, also frag nach dem Geburtsdatum, wenn das Alter zählt.

### Darf ich einen CNP in meiner Datenbank speichern?

Das rumänische Gesetz 190/2018 führt den CNP als nationale Identifikationsnummer: Du brauchst eine Rechtsgrundlage nach DSGVO, und wenn diese das berechtigte Interesse ist, außerdem Maßnahmen zu Datenminimierung und Sicherheit, einen Datenschutzbeauftragten, Speicherfristen und Schulungen für das Personal. Erheb ihn nur, wenn ein Gesetz oder ein Vertrag ihn verlangt, und frag vorher deinen Datenschutzbeauftragten.

### Brauche ich den CNP des Kunden für eine B2C-Rechnung über e-Factura?

Nein. Seit OUG 138/2024 darf ein Unternehmen 0000000000000 verwenden, also dreizehn Nullen, wenn ein privater Käufer keine Steueridentifikationsnummer angibt, und es muss den CNP nicht erheben.

---

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