What FF1 takes and returns
Normal AES encryption turns 16 digits into at least 16 random bytes, which won't fit a CHAR(16) column, won't pass a 'digits only' validation rule, and breaks every system between the database and the report that expects a card number. FF1 instead treats the input as n numerals in a radix (10 for digits, 36 for 0-9a-z) and returns n numerals in the same radix. It is a keyed permutation of that set: every n-numeral string maps to exactly one other, and the key holder can map it back.
Besides the AES key, FF1 takes a tweak: a public byte string, of any length the implementation allows, that changes the permutation without changing the key. The PAN format in this tool puts the kept BIN and last four digits in the tweak, so two cards that share middle digits but differ in their visible digits still encrypt differently. The tool passes any character outside the alphabet (spaces, dashes) through in place, the way commercial FPE products handle formatted input; FF1 itself only ever sees the numerals.
Ten Feistel rounds over numerals
FF1 splits the n numerals into a left half A of u = ⌊n/2⌋ numerals and a right half B of v = n − u. Each round reads B as a number, runs it through the round function, and adds the result to A modulo radix^m, where m is u on even rounds and v on odd ones. The sum, written back as m numerals, becomes the new B, and the old B becomes A. Addition modulo radix^m is what keeps every intermediate value inside the alphabet: there is no XOR, because XOR of two decimal strings isn't a decimal string.
Decryption runs the rounds from i = 9 down to 0 and subtracts instead of adding. The round function itself is only ever computed forwards, so FF1 never needs AES decryption, and the 2025 Rev. 1 draft explicitly disallows the inverse cipher.
const y = numBytes(S); // round function output as an integer
const m = i % 2 === 0 ? u : v;
const modulus = BigInt(radix) ** BigInt(m);
let c = numRadix(A, radix) + y; // decrypt: numRadix(B, radix) - y
c = ((c % modulus) + modulus) % modulus;
const C = strRadix(c, radix, m); // exactly m numerals, leading zeros kept
A = B;
B = C;BigInt keeps every step exact. A 19-numeral radix-36 half is far beyond what a JavaScript number holds without rounding.
The round function: AES-CBC-MAC
The round function is a pseudorandom function built from AES. A fixed 16-byte block P encodes the parameters: the version bytes 01 02 01, the radix in 3 bytes, the round count 10, u, n, and the tweak length t. For each round, Q is the tweak, zero padding so that P ‖ Q is a whole number of blocks, the round number i in one byte, and NUM_radix(B) in b bytes. R is the AES CBC-MAC of P ‖ Q with a zero IV. When the half is long enough that more than 16 bytes are needed, R is stretched by encrypting R ⊕ 1, R ⊕ 2, and so on, and the first d bytes form S.
The tool shows P, the split, b and d, and R for every round. b is ⌈⌈v·log2(radix)⌉/8⌉, the bytes needed to hold the largest possible half; the code computes it from the bit length of radix^v − 1 with integers, because the 2025 draft disallows floating point here.
const key = await crypto.subtle.importKey("raw", keyBytes, "AES-CBC", false, ["encrypt"]);
// P ‖ Q is always a whole number of 16-byte blocks, so Web Crypto
// adds exactly one PKCS#7 padding block. The block before it is the MAC.
const out = new Uint8Array(
await crypto.subtle.encrypt({ name: "AES-CBC", iv: new Uint8Array(16) }, key, pq)
);
const R = out.slice(pq.length - 16, pq.length);Web Crypto has no raw AES block function. CBC with a zero IV over whole blocks is exactly the CBC-MAC SP 800-38G specifies, and a single-block call doubles as CIPH_K for the R ⊕ j blocks.
Checked against NIST's samples
NIST publishes nine FF1 sample vectors: radix 10 and radix 36, with and without a tweak, under AES-128, AES-192, and AES-256. The unit tests run all nine in both directions and pass. The sample menu offers the six that use AES-128 and AES-256; Samples 4–6 use AES-192, which Chrome's and Safari's Web Crypto don't implement, though the same code passes them under Node.
The tool enforces SP 800-38G Rev. 1's minimum domain: radix^n must be at least 1,000,000. That's why a 15-digit card number in PAN mode is refused: keeping 6 + 4 digits leaves 5 to encrypt, and 10^5 is too small. The known attacks on Feistel-based FPE all get easier as the domain shrinks.
What FPE doesn't give you
FPE is deterministic: the same key, tweak, and input always give the same output. That's the point (joins and lookups still work on encrypted columns), and it also means equal inputs are visibly equal and an attacker who can submit chosen inputs can build a dictionary. Nothing in the output authenticates it either: any 16-digit string decrypts to some 16-digit string. Small domains need the most care, because a domain of a million values can be enumerated.
Like the other Playground tools, this one shows you the key. In a real deployment FF1 runs inside an HSM or a KMS-backed service, and applications only ever see the tokens.