Zum Inhalt springen
    WingoUI
    KomponentenVorlagenDokuPreiseKI-AgentenBlogChangelog
    1. Startseite
    2. Blog
    3. Komponenten-Guides
    4. CNP prüfen: rumänische Personennummer in TypeScript und Zod
    Read in English
    1. Blog
    2. Komponenten-Guides
    3. CNP prüfen: rumänische Personennummer in TypeScript und Zod
    Komponenten-Guides
    Komponenten-Guides

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

    SR
    Serban Rusu · Gründer von Wingo UI
    9. Okt. 2026 · 13 Min. Lesezeit

    Auf dieser Seite

    0%
    1. Wie ist der cod numeric personal aufgebaut?
    2. Wie funktioniert die CNP-Prüfziffer?
    3. Wie validiere ich einen CNP in JavaScript oder TypeScript?
    4. Wie lese ich Geburtsdatum, Alter und Kreis aus einem CNP?
    5. Wie schreibe ich eine zod-Regel für einen CNP?
    6. Wie baue ich ein CNP-Feld, das beim Tippen prüft?
    7. Solltest du CNPs überhaupt erheben und speichern?
    8. Solltest du stattdessen ein CNP-Validator-Paket nutzen?
    9. Solltest du den Code kopieren oder installieren?
    10. Häufige Fragen
      1. Wie prüfe ich einen rumänischen CNP?
      2. Was bedeutet der Kreiscode 70 in einem CNP?
      3. Warum nutzt die CNP-Prüfziffer eine 1, wenn der Rest 10 ist?
      4. Kann ich das Alter einer Person aus ihrem CNP ablesen?
      5. Darf ich einen CNP in meiner Datenbank speichern?
      6. Brauche ich den CNP des Kunden für eine B2C-Rechnung über e-Factura?

    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.

    Veröffentlicht am 9. Okt. 2026

    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 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 (opens in a new tab) beschreibt jeden davon. Hier sind sie am Beispiel 1960523123457, dem Beispielcode aus der Demo weiter unten:

    ZiffernTeilBedeutungIm Beispiel
    1SGeschlecht und Geburtsjahrhundert1: männlich, geboren 1900 bis 1999
    2 bis 3AAGeburtsjahr96
    4 bis 5LLGeburtsmonat05
    6 bis 7ZZGeburtstag23
    8 bis 9JJKreiscode12: Cluj
    10 bis 12NNNLaufende Nummer, 001 bis 999345
    13CPrüfziffer7

    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 (opens in a new tab), 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:

    Stelle123456789101112
    Ziffer196052312345
    Schlüssel279146358279
    Produkt2635402012951662845

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

    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
    $ npx wingo-ui@latest add masked-input
    ProDoku: 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 (opens in a new tab). Akzeptier diese 13 Nullen, bevor du checkCnp aufrufst, wenn das Feld in eine Rechnung fließt.

    Der Check unter der Rechnung liest 0000000000000 als Käufer, der keinen CNP angegeben hat, den Platzhalter, den e-Factura auf B2C-Rechnungen akzeptiert
    $ npx wingo-ui@latest add invoicing
    ProDoku: Invoicing

    Wenn du ihn erhebst, gilt zusätzlich zur DSGVO das rumänische Gesetz 190/2018 (opens in a new tab). 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 (opens in a new tab), 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 (opens in a new tab) 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 mit inputMode="numeric" reichen für ein Vertrags- oder Lohnformular. Masked Input, das Feld aus der Demo, gehört zu Wingo UI Pro, genau wie die invoicing-Lib, 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 für Firmen und IBAN prüfen in JavaScript für Bankkonten. Für den restlichen Aufbau des Formulars lies React-Formularkomponenten: barrierefreie Felder, zod und Mobile, oder stöbere in weiteren Komponenten-Guides.

    Komponenten in diesem Artikel

    • Masked Input

      A React masked input that formats itself as you type or paste: card number, expiry, CVC, IBAN, CNP, postal code or a custom mask.

      Pro
    • Invoicing

      Invoice math for React billing apps: VAT per rate the way e-Factura checks it, Romanian VAT rates, and CUI, IBAN and CNP validation.

      Pro
    • Form validation

      Zod validation rules for React forms with one message set (English or Romanian), react-hook-form glue and a pretend request.

      Pro
    • Input

      A React text input: icons and addons inside the box, a clear button, a loading spinner, a character counter and floating labels.

      Free

    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.

    • CNP
    • Rumänien
    • Formularvalidierung
    • Zod
    • TypeScript
    • Datenschutz

    Teilen

    SR

    Über den Autor

    Serban Rusu

    Gründer von Wingo UI

    Serban Rusu hat Wingo UI gegründet. Er baut die Komponenten, die CLI und den MCP-Server und schreibt über React-UIs für Smartphones und KI-Agents.

    Mehr von Serban
    Weiterlesen

    Ähnliche Artikel

    Alle Artikel
    Komponenten-Guides

    IBAN prüfen JavaScript: Mod 97, Regex und ein React-Feld

    IBAN prüfen JavaScript: Mod 97 in TypeScript fängt Tippfehler, die ein Regex durchlässt. Dazu ein React-Feld, das beim Tippen formatiert, und rumänische Banken.

    SRSerban Rusu·9. Okt. 2026·11 Min. Lesezeit
    Komponenten-Guides

    CUI prüfen: rumänische Steuer-ID mit TypeScript und Zod

    CUI prüfen in TypeScript: die Prüfziffer mit dem Schlüssel 753217532 Schritt für Schritt, das Präfix RO, eine zod-Regel, ein React-Feld und die ANAF-Abfrage.

    SRSerban Rusu·9. Okt. 2026·10 Min. Lesezeit
    Komponenten-Guides

    React Form Components: barrierefrei, mit Zod und mobil

    React Form Components mit einer Anatomie (Label, Hinweis, Fehler, Zähler), angebunden an react-hook-form und zod und fürs Smartphone gebaut, mit vollem Code.

    SRSerban Rusu·9. Okt. 2026·25 Min. Lesezeit
    Newsletter

    Neue Artikel per E-Mail

    Neue Guides, Tutorials und Vergleiche aus dem Wingo UI Blog, sobald sie erscheinen. Die meisten Artikel sind auf Englisch.

    Kein Spam. Du kannst dich jederzeit abmelden.

    WingoUI

    Animierte, konfigurierbare, mobile-first React-Komponenten und Blöcke. Kopier den Quellcode, pass ihn an und lass deinen Coding-Agenten damit bauen.

    Produkt

    • Komponenten
    • Vorlagen
    • Preise
    • KI-Agenten
    • Theme-Generator

    Ressourcen

    • Doku
    • Blog
    • Changelog
    • Changelog-RSS
    • llms.txt

    Rechtliches

    • AGB
    • Datenschutz
    • Rückerstattung
    • Lizenz
    • Impressum

    Komponenten-Kategorien

    • Buttons & Actions
    • Inputs
    • Forms
    • Navigation
    • Overlays
    • Feedback
    • Data Display
    • Tables & Lists
    • Charts & Stats
    • Layout
    • Media
    • AI Kit
    • Text & Effects
    • Mobile
    • Commerce
    • Marketing Sections
    • Blocks
    • Hooks & Utilities

    © 2026 Wingo UI

    Wingo UI