RFC, CURP, CLABE and NSS: the Mexican ID Validation Algorithms, Explained with Worked Examples

Typographic cover: the word RFC above a diff, GODE561231GR with a missing check digit in red and GODE561231GR8 in green, and the sum 1026 mod 11 = 3, 11 − 3 = 8
Table of Contents

    Mexico’s four everyday identifiers each end in a check digit, and each one uses a different algorithm. The RFC (tax ID) uses a weighted modulus 11 over a 39-character alphabet. The CURP (population registry key) uses a weighted modulus 10 over 37 characters. The CLABE (bank account number) uses 3-7-1 weights that keep only the last digit of each product. The NSS (social security number) is plain Luhn. Below, each one is computed by hand on a public test vector, with the two traps that break most implementations: the SAT’s generic RFC that fails its own checksum, and the CLABE bug that looks like Luhn.

    A passing check digit tells you the string is well formed, not that it exists. It catches typos and swapped characters before a form submits. Whether an RFC is registered, a CURP belongs to a real person or a CLABE has an open account is a question for the SAT, RENAPO or the bank. Keep that line in mind: validation belongs in the form, verification belongs in a back-office call.

    Every number in this post was produced by running the code below, not worked out by hand, and the same functions pass the 41 checks in the site’s test script: the SAT’s canonical RFC vectors, python-stdnum’s CURP vector, the CLABE and NSS cases, and 500 generated people round-tripped through every validator.

    RFC: weighted modulus 11

    The RFC is 13 characters for an individual (persona física) and 12 for a company (persona moral):

    PartIndividual GODE561231GR8Company MAB9307148T4
    Name lettersGODE (4)MAB (3)
    Date YYMMDD561231930714
    HomoclaveGR8T
    Check digit84

    The homoclave is two characters the SAT derives from the full name to break ties between people with the same initials and birth date. Its algorithm needs the full name, so no validator can check it from the RFC alone. The check digit, though, only needs the characters before it.

    The alphabet. Each character maps to a value by its position in this 39-character string. Two details: & sits at 24, between N and O, and the space is worth 37:

    const ALPHABET = '0123456789ABCDEFGHIJKLMN&OPQRSTUVWXYZ Ñ';
    

    The computation. Left-pad the base to 12 characters with spaces, which is why the space needs a value, then multiply each value by a weight that runs from 13 down to 2:

    G=16×13  O=25×12  D=13×11  E=14×10  5=5×9  6=6×8
    1=1×7    2=2×6    3=3×5    1=1×4    G=16×3 R=28×2   → sum 1026
    

    1026 % 11 = 3, and the digit is 11 − 3 = 8. It matches. Two remainders are special: 0 gives the digit 0, and 1 gives the letter A. For the company MAB9307148T, the padding adds one leading space worth 37. The sum is 1217, 1217 % 11 = 7, and the digit is 11 − 7 = 4.

    export function rfcCheckDigit(base: string): string {
      const padded = ('   ' + base).slice(-12);
      let sum = 0;
      for (let i = 0; i < 12; i++) {
        const value = ALPHABET.indexOf(padded[i]);
        if (value < 0) return '?';
        sum += value * (13 - i);
      }
      const remainder = sum % 11;
      if (remainder === 0) return '0';
      if (remainder === 1) return 'A';
      return String(11 - remainder);
    }
    

    Trap 1: the generic RFCs. The SAT assigns two RFCs by decree, and a correct algorithm gets both wrong:

    • XAXX010101000, “the general public”, is on every retail invoice without a named customer. It fails its own checksum: the algorithm asks for 4 and the SAT put 0. A strict validator rejects the most common RFC on Mexican invoices.
    • XEXX010101000, for foreign residents, passes by coincidence: the sum is 1342 and the remainder is 0. Without special handling it would be classified as an individual born on January 1, 2001.

    The fix is an explicit allowlist, checked before the algorithm runs:

    const GENERIC_RFCS: Record<string, string> = {
      XAXX010101000: 'Generic RFC for the general public',
      XEXX010101000: 'Generic RFC for foreign residents',
    };
    

    One more thing the checksum can’t see is the date. 310231 passes the shape regex, but February 31 isn’t a day, so build a real date and compare it back. The RFC also doesn’t encode the century, so a two-digit year needs a cutoff window.

    CURP: weighted modulus 10 over 37 characters

    The CURP is always 18 characters. BOXW310820HNERXN09 breaks down like this:

    PartValueMeaning
    Name lettersBOXWthe second letter is always a vowel (or X)
    Date3108201931-08-20
    SexHH, M or X
    StateNEborn abroad (nacido en el extranjero)
    Internal consonantsRXNfrom the surnames and given name
    Century marker0a digit means born before 2000, a letter means 2000 or later
    Check digit9

    The century marker is a nice design choice: unlike the RFC, the CURP resolves the two-digit year without guessing.

    The computation. Same idea as the RFC, with a 37-character alphabet (& keeps its slot at 24 even though the CURP never uses it), weights running from 18 down to 2 over the first 17 characters, and modulus 10:

    const ALPHABET = '0123456789ABCDEFGHIJKLMN&OPQRSTUVWXYZ';
    
    export function curpCheckDigit(base: string): string {
      let sum = 0;
      for (let i = 0; i < 17; i++) {
        const value = ALPHABET.indexOf(base[i]);
        if (value < 0) return '?';
        sum += value * (18 - i);
      }
      return String((10 - (sum % 10)) % 10);
    }
    

    For the vector, the sum is 2551, so (10 − 1) % 10 = 9. It matches. The outer % 10 matters: when the sum already ends in 0, the digit is 0, not 10.

    A CURP also has to pass checks that have nothing to do with the check digit: a valid state code from the 33 in the catalogue (32 states plus NE), a real date, and the RENAPO rule that replaces rude four-letter combinations (the second letter becomes X), which is why a validator needs that word list too.

    CLABE: 3-7-1 weights, last digit of each product

    The CLABE is 18 digits: a 3-digit bank code, a 3-digit branch area (plaza), an 11-digit account number and 1 control digit. The algorithm comes from Banxico:

    1. Multiply each of the first 17 digits by the cyclic weights 3, 7, 1.
    2. Keep only the last digit of each product (the product modulo 10).
    3. Add them up, and the control digit is (10 − sum % 10) % 10.

    For the example base 01218000118359719 (bank 012), the kept digits are 0,7,2,3,6,0,0,0,1,3,6,3,5,3,7,3,3. They add up to 52, so the control digit is (10 − 2) % 10 = 8.

    const WEIGHTS = [3, 7, 1, 3, 7, 1, 3, 7, 1, 3, 7, 1, 3, 7, 1, 3, 7];
    
    export function clabeCheckDigit(base: string): string {
      let sum = 0;
      for (let i = 0; i < 17; i++) {
        const digit = Number(base[i]);
        if (Number.isNaN(digit)) return '?';
        sum += (digit * WEIGHTS[i]) % 10;
      }
      return String((10 - (sum % 10)) % 10);
    }
    

    Trap 2: the Luhn reflex. Anyone who has implemented a card check digit adds the digits of each product: 9 × 7 = 63 becomes 6 + 3 = 9. The CLABE keeps the last digit: 63 counts as 3. The two approaches agree on many inputs, which is why the bug survives testing. They split as soon as a product reaches two digits. For the base 09000000000000000, the correct algorithm gives 7 and the Luhn-style version gives 1. Put that vector in your tests.

    (Whether you reduce each product modulo 10 or only reduce the final sum makes no difference: they are mathematically the same. The bug is the digit sum, not the order.)

    The bank code. The first three digits identify the institution, and it’s worth checking them against Banxico’s own list, not a copy of a copy. The popular lists online still carry mistakes. Bineo, for example, is 165 in Banxico’s institution list (key 40165), and the 812 that circulates for it doesn’t appear there at all. An unknown bank code shouldn’t invalidate a CLABE that passes its checksum, because new institutions keep arriving. Show “unknown bank” instead.

    NSS: Luhn over ten digits

    The IMSS social security number is 11 digits: a 2-digit sub-delegation, the 2-digit year of registration, the 2-digit birth year, a 4-digit serial number and a Luhn check digit over the first ten. This one really is Luhn: double every second digit starting from the second, and subtract 9 from any result above 9.

    export function nssCheckDigit(base: string): string {
      let sum = 0;
      for (let i = 0; i < 10; i++) {
        const digit = Number(base[i]);
        if (Number.isNaN(digit)) return '?';
        const product = i % 2 === 1 ? digit * 2 : digit;
        sum += product > 9 ? product - 9 : product;
      }
      return String((10 - (sum % 10)) % 10);
    }
    

    For the made-up base 1234567890, the digit is 3.

    The whole thing, as a package

    The functions above are the module behind this site’s Mexican ID validator, and they’re also published as mx-identifiers on npm: MIT, zero dependencies, ESM and CJS, and the same code in Node and the browser. Validators never throw. They return the parts, the expected check digit, a decoded birth date and an errors array you can map to your own messages:

    import { validateRfc, validateCurp } from 'mx-identifiers';
    
    validateRfc('XAXX010101000');
    // { valid: true, kind: 'generico', genericNote: 'RFC genérico nacional (público en general)', ... }
    
    const r = validateCurp('BOXW310820HNERXN09');
    r.birthDate;  // '1931-08-20'
    r.stateName;  // 'Nacido en el extranjero'
    

    It also generates coherent fake people, where the RFC and CURP come from the same name and birth date and all four IDs pass their validators. That covers the other half of the problem: seeding a staging database without real personal data.

    What to validate where

    • In the form: normalise (uppercase, strip spaces, hyphens and dots), check the shape, the date and the check digit, and allow the two generic RFCs. This catches the typos.
    • In the back office: check the RFC against the SAT, including the list of taxpayers with fake invoices (the 69-B list), the CURP against RENAPO, and the CLABE with the penny test or your payment provider’s account-name lookup. This catches the fraud.
    • Never show a user “your RFC doesn’t exist” based on a checksum. Say “check the characters”, because that’s all a checksum can tell you.

    Frequently asked questions

    How is the RFC check digit calculated?

    Left-pad the characters before the digit to 12 with spaces, map each one to its position in the alphabet 0-9, A-N, &, O-Z, space, Ñ, multiply by weights running from 13 down to 2, and take the sum modulo 11. A remainder of 0 gives 0, 1 gives the letter A, and anything else gives 11 minus the remainder.

    Why does XAXX010101000 fail RFC validation?

    Because the SAT assigned it by decree without honouring its own algorithm: the modulus 11 asks for a 4 and the RFC carries a 0. Validators must allowlist it, along with XEXX010101000 for foreign residents, which passes the checksum by coincidence but should be classified as generic rather than as a person.

    Is the CLABE check digit the Luhn algorithm?

    No. The CLABE uses cyclic weights 3, 7, 1 and keeps the last digit of each product, so 63 counts as 3. Luhn adds the digits of the product, so 63 would count as 9. The two agree on many inputs, which is why the bug slips through tests. The base 09000000000000000 gives 7 with the correct algorithm and 1 with the Luhn-style one.

    Does a valid check digit mean the RFC, CURP or CLABE exists?

    No. A check digit only proves the string is well formed and catches typos. Whether it is registered and active is answered by the SAT for the RFC, RENAPO for the CURP and the bank or payment provider for the CLABE.

    Can I validate the RFC homoclave?

    Not from the RFC alone. The SAT derives the two homoclave characters from the full name, so checking them needs the name as input. A validator that only has the RFC can check the letters, the date and the check digit, but not the homoclave.

    Found it useful? Share it

    Found it useful? Get the next one by email

    Once a week: what breaks when you upgrade, AI for developers and what I'm building, with sources. No spam.

    By subscribing you accept our privacy policy.

    Search

    Tags

    AI Migration PHP Laravel JavaScript Tutorial Web Development Security Upgrade Best Practices TypeScript OpenAI SEO Backend Claude