Arxid: keyed, non-enumerable ID obfuscation (ARX-Feistel, no_std, spec-first)

Sequential IDs in URLs (/users/1042, then 1043, then 1044) leak how many records you have and let anyone walk them. Hashids/Sqids fix the enumeration but aren't really keyed. The alphabet is just salt-shuffled.

arxid is a keyed reversible permutation instead: a balanced Feistel network with an ARX round function over 40 bits, mapping to a 7-char base62 code. It runs ~16x faster than the same Feistel using HMAC-SHA256 as the round function, since an ARX round is a handful of integer ops rather than a hash call.

A few Rust-specific things:

  • no_std capable, forbids unsafe
  • features std / alloc / encoding / zeroize, plus wasm behind a flag
  • the round count (4) comes from measuring strict avalanche, not from picking a number that felt safe
  • it's the reference impl for a spec-first design: a frozen spec and 61 canonical test vectors, so a port in any language validates against the same vectors and stays byte-identical

The README is blunt about what this isn't. Not encryption, not a MAC, not audited. It stops casual enumeration and that's the job. It is not access control.

Curious what people think of the API and the construction.

Repo: GitHub - lucasolopes/arxid: Keyed, reversible permutation for obfuscating sequential integer IDs. A frozen spec plus canonical vectors, so every language agrees byte-for-byte. Rust reference + TypeScript port. · GitHub

No lookup table, no extra column, no state.

Isn't key considered state? Especially if it needs to be different per deployment.

Fair point, that was imprecise. The key is state, but it's a single static config value, not per-record state. A lookup table grows with your data and sits on every decode; the key is one constant per deployment, set once, O(1) no matter how many IDs. And yes, it has to differ per deployment and be kept safe, lose the key and you can't decode. That's the tradeoff: the mapping lives in the key instead of a table.

If it's sparse enough avoid users guessing the numbers and you're storing them anyway (like with user ids and such), then why not just use a random number? One retry on a random 64-bit number will have collisions below the random failures level even with a user for everyone on earth.

(And if that's a problem, well can just use uuids.)

You’re right, if you’re storing the mapping anyway, a random number (or a UUID) is simpler and I’d reach for that too. arxid is for when you specifically don’t want to store it: the code is a pure function of the key, so you can decode back to the real ID with zero lookup. Sequential internal IDs (autoincrement PKs) stay as-is in the DB, and the public code is derived on the fly in both directions. No mapping column, no collision retries. If storing a random ID is acceptable for your case, that’s the simpler path.

First of all, congratulations on releasing a crate and publishing something online. You're doing much better than me at the moment :slight_smile: I've written a lot below but I do get the feeling this crate is more of a 'I made something for fun' thing. This makes most of the concerns below not really important but I do want to raise them for fun on my end too, since critiquing can be fun.


The first issue I see is that you've not had any beta period of public review, the specification is cemented as unchanging. This isn't a great way to handle developing software, usually you ask for feedback before everything is set in stone.

The second issue I see is don't really specify a threat model or security properties. I'm not sure what guarantees I'd actually get when using this library. The idea I would have as a user is that if I run your obfuscation function it would be computationally infeasible to deobfuscate it without a key. Is this the case?

The third issue is I'm not sure this is a very strong solution if you want to target the 'this is infeasible to reverse without a key'. I see the following immediate problems:

  • The input is very small in practice. This forum post on the Rust forum has ID 141698, that's around 18 bits of the 40 bit input. The rest are going to be zero
  • The input is guessable in practice. Many websites show statistics of things like 'we have X number of users' or use the ID outside URLs. If I go to About - The Rust Programming Language Forum I see there's 37192 members. I could create a new account and know it's the 37192rd, or even start watching new sign-ups or posts if I manage to figure out the number of one item.
  • The ciphertext is fed directly to the cipher function meaning there is a direct association between the key and the number which can be attacker controlled or leaked. This seems like a ripe place for known plaintext analysis.
  • As a quick example I found this PDF: Generic Attacks on Feistel Schemes It suggests picking the number of rounds as 4 weak. Picking the rounds from measuring the output instead of strength against attacks was not a great idea.
  • You specify "Consecutive ids do not produce consecutive codes:" This seems wrong as it would spill information about the ciphertext to the output.
  • There's no documentation or explanation on the round_fn and why it works the way it does or how it was selected. I can't really find any other hashes or ciphers that use this type of round function.
  • According to my reading of the Feistel cipher you should be using a new key for each round. To do this you made your own key stretching algorithm (subkey) but this isn't explained either.

If we conclude that this scheme is cryptographically weak and it's not meant to prevent attacks but instead just be obfuscation (like it says), I do worry about having a false sense of security here. Maybe a message like 'someone with enough time could reverse these counts'.

Ergonomically I'm not sure how much I like this design. You have a secret (the plaintext ID) and you're storing it in plaintext in a database accessible by the rest of your program. It would be very easy to leak this ID inadvertently by returning too much data accidentally.

I think the only proper solution here is random numbers or UUIDs.


Okay, time for the non-cryptographic stuff:

  • Being aware of some of the pitfalls of integer math is a great sign
  • Using test vectors to avoid round trips is a great choice
  • The documentation is good
  • Some of the documentation repeats itself about being frozen behaviour
  • Not having a version number as part of your key is a problem, ideally your software should be able to tolerate change and be backwards compatible
  • I think the spec might be a bit too much compared to a reference implementation or the library code itself.
  • Using a fixed random key in the documentation which people will copy might be a bad idea
  • I don't see any warnings about endianness. This might need to be mentioned at least for key storage. Having the key in an opaque u8 set of bytes would let you solve this problem and add metadata for things like versioning.
  • For tests vectors you've picked specific fixed values. It would be good to use a large amount to fuzz test this. Property based testing in general would be good here.

Thank you for this. arxid is my first published package and my first open-source project, so a detailed critique is worth a lot more to me than silence would have been, and I'd rather hear it now than after someone deployed it. You said critiquing can be fun; getting critiqued this thoroughly turned out to be useful in a way I didn't expect.

I went and measured everything you raised. Most of it held up, some of it was worse than you said, and one of my own follow-up guesses turned out wrong. The result is shipping as spec v2.

The threat model. You asked the direct question and the answer is no: it is not computationally infeasible to recover an id without the key. The repo said a lot about what arxid isn't and never once stated what it defends against. There's now a threat model section that says plainly what it stops (URL-walking, row counts, growth rates), what it doesn't (forgery, anyone with the key, anything your authz layer should have caught), and the limits with numbers.

On rounds, you were right, and I took the paper's actual recommendation. I built the distinguisher. Fixing one Feistel half and varying the other, the statistic Lout XOR Lin collides at roughly twice the rate of a random permutation. I validated the methodology against an actual Fisher-Yates permutation first (ratio 0.989, as theory says). At the real 40-bit width, 4 rounds separates at ~2^13 chosen queries and reaches ratio 1.85 by 2^16. The structure is gone by 5 rounds. But you pointed at Patarin, and Patarin's recommendation is at least 6 rounds for a pseudorandom permutation, so rather than argue that 5 was enough, v2 ships 6. The extra round over the measured threshold is lost in the noise of the base62 step (below), so there was no reason not to take the recommendation. That's what picking the count by measuring avalanche instead of resistance to attacks had cost me, and the worst-pair column in my own published table (0.1117 at 4 rounds vs 0.0039 at 5) was already saying so. The signal was in my README and I didn't act on it.

Your consecutive-codes point was the sharpest thing in your post, and it's worse than you argued. You said guaranteeing it would spill information. Correct, an ideal permutation produces adjacent pairs about twice per full domain, so guaranteeing zero is itself a distinguisher. But when I measured, v1 didn't produce too few. It produced them hundreds to thousands of times more often than chance. Consecutive ids landing on adjacent codes leaks exactly the ordering the library exists to hide. Concrete counterexample in shipped 0.1.0, identical in the Rust and TypeScript ports: under key 0x652BFD48C7ED0458, ids 48326508 and 48326509 encode to 5kusgvr and 5kusgvs. That claim was asserted in three READMEs and pinned by three test files that passed only because they happened to sample points where it held. All removed. Zero occurrences at 6 rounds.

The one you got at sideways is real and neither of us named it. "Ciphertext fed directly to the cipher", determinism is inherent to stateless reversible FPE, that part is the product. But underneath it is that arxid has no tweak. FF1/FF3-1 have one for exactly this reason. /orders/5kusgvr and /users/5kusgvr were the same id under one app-wide key, and that was undocumented. Now documented, with a per-resource-type key as the mitigation.

Also fixed: the key schedule. You flagged it as homemade, and the key equals not-key property, discovered after the fact and then frozen as "not a defect", was the evidence you were pointing at. The cause was folding the key halves with XOR, which cancels under complement. Changed to a wrapping add; full 2^64 key space back, and u64::MAX is now a meaningful test vector instead of a duplicate of key 0.

Where I'd push back a little. On ergonomics, keeping a sequential PK and deriving the public code is the design goal, not an accident, the id is an internal identifier, not a secret. But you're right about the coupling, and it turned out to matter more than either of us said: the distinguisher above is unreachable from the encode side, because it needs thousands of ids sharing their low 20 bits and a deployment with ids under a million has at most one per class. From the decode side it's free, the attacker picks the codes, and all 2^20 codes with a fixed high half are valid strings, so I measured that direction all the way to the 2^20 wall. The only thing it also needs is the application revealing the decoded id. So your "it's easy to leak this ID inadvertently" objection is precisely what converts a theoretical attack into a reachable one. Fair hit, and now in the docs as "do not echo internal ids back".

And you're right that random ids or UUIDs are the proper answer when ids need to be confidential. There's now a "use something else" table: random/UUIDv7 for confidentiality, FF1 or FF3-1 (NIST SP 800-38G) for real format-preserving encryption, HMAC in a lookup column for unforgeable public ids. Saying that out loud costs the project nothing.

Smaller ones, all conceded: hardcoded key in the docs that people would copy (worst practical item on your list, it was in four files, gone); no version in the key; endianness for key storage (from_key_bytes / fromKeyBytes, big-endian); repeated "frozen" boilerplate. On property-based testing you were right and I overstated my fix in an earlier draft: proptest is in the Rust suite, but the TypeScript port only had fixed-sample structural tests, so adding real property tests there is on me. What did land now is a CI job that generates 200k randomized rows from the Rust reference each run and checks the TypeScript port against them, differential fuzzing across the ports, which the repo had none of.

What the extra rounds cost, since it's the obvious objection. On the bare permutation each ARX round costs on the order of ~1.5 ns, so two extra rounds is about +3 ns, real, but on a number no one ships. On the path an app actually pays, permutation plus the base62 String, it vanishes: the allocation dominates and its own jitter is several ns, larger than the round cost. One run of my own benchmark clocked the 5-round encode as faster than the 4-round one, which is impossible and is the whole point, on that path the round count is below the noise floor. I had to interleave every arm in one process to see even that much; timing one count in one session and another later gives noise several times larger than the effect, which is the trap the old benchmark table fell into. So I've stopped quoting the benchmarks to three significant figures, the section now tells you to measure on your own hardware, and the round count I used to defend on performance grounds was never buying me anything measurable where it mattered.

The spec is no longer described as frozen. v1 shipped frozen before anyone had looked at it, that was the actual mistake, upstream of every technical one. You called it as the first issue in your post and it was the right call. v2 is the current version, not the final one, and the contributing guide now asks for a measurement rather than an argument when someone wants to propose v3. Every experiment above lives in examples/ so nobody has to take my word for any of it.

Thank you. This was worth more than the release was.