If your API exposes /users/1042, someone can request /users/1043. And /users/1044. Sequential integer IDs leak two things you probably didn't mean to leak: roughly how many records you have, and a trivial way to walk through all of them.
arxid fixes that. Feed it an integer, get back an unpredictable-looking code; feed the code back with the same key, get the integer. Keyed, reversible, and byte-for-byte identical across every language it's ported to.
// Rust
let a = Arxid::new(key);
let code = a.obfuscate_str(1042); // -> "7ZjcH7o"
let id = a.deobfuscate_str(&code); // -> Some(1042)
// TypeScript
const a = new Arxid(key); // key is a bigint
const code = a.obfuscateStr(1042); // -> "7ZjcH7o"
const id = a.deobfuscateStr(code); // -> 1042 | null
Install:
# Rust
cargo add arxid
# TypeScript / JavaScript
npm i arxid
How it works
arxid is a balanced Feistel network with an ARX (add-rotate-xor) round function, over a 40-bit domain that maps to a 7-character base62 code.
A Feistel network is a bijection by construction. Split the input into two halves, and for each round, replace one half with itself XORed against some function of the other half. It doesn't matter whether that function is invertible: you can always run the network backward, because Feistel decryption reuses the same round function in reverse order. So decode(encode(id)) == id for every id, always, with zero collision checks and no lookup table anywhere.
The round function is ARX: three cheap integer operations, addition (mod 2^32), bit rotation, and XOR. No S-boxes, no hash calls, no lookup tables. SPECK and other lightweight ciphers use the same primitives. Each round is a handful of CPU instructions.
x = r + subkey // wrapping add, u32
x ^= rotl(x, 7)
x = x + rotl(x, 13) // wrapping add, u32
x ^= rotl(x, 17)
return x & mask
Four rounds of that. The result is a permutation that runs at roughly 225M ops/sec for the raw integer transform, about 16x faster than a structurally identical Feistel using HMAC-SHA256 as its round function. Same security shape, one round function swapped, an order of magnitude faster, because ARX rounds are integer ops instead of hash calls.
These numbers come from a 13th Gen Intel Core i7-1355U, 16 GB RAM, Windows 11 Pro, measured with criterion, single-threaded, reproducible with cargo bench. The ratio against the HMAC-Feistel matters more than the absolute figure: both run on the same machine in the same harness, so the ~16x gap holds regardless of how fast your CPU is.
Why the round count is 4, and how it was chosen
The round count isn't a guess. It's calibrated with a harness that measures the Strict Avalanche Criterion: flip one bit of the input, and on a well-mixed permutation about half the output bits should flip, unpredictably. If flipping input bit 5 always flips the same 3 output bits, the code is enumerable. If it flips ~50% of them no matter which input bit you touch, the output looks like noise from outside.
Sweeping 1 to 12 rounds over 200,000 samples each, the average avalanche hits 0.5001 at 4 rounds with full bit-coverage: every input bit can influence every output bit. Rounds beyond 4 buy nothing on that metric. So the count is fixed at 4, and frozen in the spec.
One honest caveat, stated in the README and the security docs rather than buried: 4 rounds closes the average avalanche, but the worst individual bit-pair is still 0.164 from ideal at 4 rounds and only fully closes at 5. The README's avalanche table has a dedicated "worst single pair" column showing that gap. Four rounds is a diffusion target calibrated for statistical non-enumerability, not a cryptographic safety margin. Which is exactly what arxid is for, and exactly what it isn't (more below).
Same output in every language
The point of a library like this is that a code obfuscated in one language decodes identically in another. You store an obfuscated ID somewhere, and the service that reads it might be written in something else entirely. That only works if every implementation produces byte-identical output.
arxid guarantees it spec-first: a frozen specification pinning every parameter down to the byte (width, rounds, ARX constants, key schedule, endianness, the base62 alphabet and its ordering, padding), a reference implementation in Rust, and 61 canonical test vectors that act as the contract. Every port validates against the same vectors. Pass the vectors, and you're interoperable with every other implementation by definition. This is how Sqids reached more than 50 language implementations: one consistent spec, not one shared binary.
The Rust reference is no_std-capable and forbids unsafe. The TypeScript port is ESM with zero runtime dependencies. One detail worth knowing if you port it yourself: the 40-bit domain fits safely in a JavaScript number, but the key schedule operates on 64 bits, so the subkey derivation needs BigInt and a careful narrowing back to 32 bits. Get it wrong and the round-trip still passes locally while the output silently diverges. The vectors catch it: one vector uses a key with only the high bit set, so a port that truncates the key to 32 bits reads zero and fails loudly.
When to use it, and when not
If you need multiple numbers bundled into one ID, an unbounded domain, or a profanity blocklist, use Sqids. It's mature, it has an ecosystem, and those are real features arxid doesn't have.
Use arxid when you want a keyed permutation with a real key schedule instead of a shuffled alphabet, over a fixed integer domain, that runs fast and produces the same output in every language.
And use it knowing what it is. arxid defeats casual enumeration. It is not encryption, it is not a MAC, and it has not been independently audited. Obfuscation is not access control: if a resource must stay secret, put real authorization in front of it. The obfuscated ID is a speed bump against enumeration, not a lock.
The code, the spec, and the vectors are on GitHub: github.com/lucasolopes/arxid. Ports to other languages are the next step, and the contribution path is simple: implement the spec, pass the vectors, open a PR.
Top comments (0)