- Security
- A
Encrypting IDs with Feistel Network: API Protection Without Database Modifications
Many APIs still expose incremental IDs in URLs - one curl in a loop, and the attacker has the entire table. This is exactly what APCOA fell victim to in April 2025. I analyze an inexpensive second line of defense: encrypting IDs with the Feistel network. Forty lines, no migrations, no new dependencies.
Working demo project: github.com/mlivirov/fiestel-cipher-demo
I work as a contractor and regularly find myself in other people's codebases. And in many of them — for quite understandable reasons: there’s no money, there are no people, deadlines were yesterday, the code was inherited from someone else — the choice is almost always in favor of “easier” rather than “safer.” This article is specifically written for such projects. It’s not an appeal to “do security properly” (which is, of course, necessary). This is a specific technique that can be implemented in an evening, has virtually no runtime cost, and closes an entire class of trivial attacks that are still commonly found in production.
A ton of web applications expose integer primary keys in the URL:
GET /invoices/1043
GET /invoices/1044
GET /invoices/1045
One valid URL plus a for loop — and you have the whole table in your hands. Exactly this is what APCOA fell victim to in April 2025, with parking bills leaking across five countries at once. And this is exactly what takes the top spot every year in OWASP under the title Broken Access Control / IDOR. It occurs everywhere: internal utilities, B2B portals, admin panels that haven’t been touched in three years, MVPs that quietly became production.
The normal solution is authorization checks. But “go and add authorization to every endpoint” — this is not a task for a sprint, this is a project. And while there is no project, there is a cheap second line of defense that can actually be rolled out this week: stop exposing the primary key externally. Let the client receive an opaque number — the same 64 bits, still unique, still reversible back on the server — but guessing adjacent values will no longer be possible.
The Feistel network does exactly this, in about forty lines. No new dependencies, no migrations, no changes to current queries. That’s the whole point.
Idea in one paragraph
The Feistel network takes any function with the key F(data, key) — even a mediocre one — and transforms it into a reversible permutation over a fixed-size block. You split the input in half, run several rounds: one half is XORed with F(the_other_half, key), and the halves are swapped. The output is ciphertext. To decrypt, you run the same rounds in reverse order. That's it. The function F can be irreversible — the reversibility comes from the structure itself.
This is the basis of DES. This is the basis of Blowfish. Here, it is used for a much humbler task: to make /persons/1 unguessable.
What it looks like in the repository
The database still has a boring bigint with auto-increment. The API only exposes the encrypted value.
public class PersonIdValueConverter(byte[] key) : ValueConverter(
id => FiestelId.Decode(key, id.Value), // entity -> DB: decrypting
value => new PersonId(FiestelId.Encode(key, value))) // DB -> entity: encrypting
{ }
The converter lives on the boundary of EF Core. Business code, DTOs, and routes do not know about the raw PK at all. The database, in turn, does not know about the encrypted representation. There’s no risk of leaking one into the other — the boundary is enforced by the type.
The cipher itself (Domain/FiestelId.cs) is classic textbook material:
for (var i = 0; i < Rounds; i++)
{
var roundIndex = reverse ? Rounds - 1 - i : i;
var f = RoundFunction(right, key, roundIndex);
var newRight = left ^ f;
left = right;
right = newRight;
}
RoundFunction is simply SHA-256(key || data || round), truncated to 32 bits. Eight rounds. No S-boxes, no substitution tables, no key schedule. The entire file is 58 lines long.
What happens in practice: consecutive IDs from the database (1, 2, 3) turn into something like 5823901847263…, 9174625038192…, 2847193650471…. Added one to your ID — you don’t recognize anything about anyone else’s.
What this gives
Simplicity. The technique can be read in one sitting and summarized in one paragraph. Zero external libraries, zero new columns, zero migrations. If it needs to be refined by a junior in six months — they will manage it.
Zero changes in the schema. Internal PKs remain sequential. Indexes are dense, inserts go to the end, foreign keys are integers. The write path is not touched at all.
Bijection, not hash. Each internal ID corresponds to exactly one external ID and vice versa. No collisions, no mapping table, no "id → slug" cache that needs to be kept in sync.
Pennies at runtime. Eight calls to SHA-256 for encoding—fractions of microseconds on any modern machine. You can safely run it on each row in hot reads; it fits within the budget.
Stable URLs. Unlike UUIDs, which are randomized at the time of insertion, the encrypted ID is a pure function of the PK. Restored the table from a backup, reprocessed events, recovered from a snapshot—the same rows received exactly the same public IDs. The key did not change—links did not break.
What this does not provide
This is not authorization. This should be written in marker on the monitor. An opaque ID stops someone who is iterating through a for loop but not someone who already has one valid ID—from logs, from Referer, from a shared screenshot, from a support ticket—and who now wants to find out if the server will return this row to them. With every request, the server still has to answer the question, "can this caller access this record?" Feistel-ID is defense in depth, nothing more.
The key is a single point of failure. If the secret leaks, brute force works again. Whoever has the key encrypts 1, 2, 3, … and goes through the table sequentially. Store it accordingly: environment variables or a secrets manager, never in a repository; if there is suspicion of compromise—rotate. A placeholder in appsettings.json is indeed just a placeholder, not an example of "how it should be".
Rotation is painful. The encrypted ID is a pure function of the key. If you change the key, all public IDs in the system change completely. Old URLs, cached links, QR codes on parking tickets break. Schemas where identifier and integrity token are separated (say, HMAC-signed IDs where the ID itself is stable, and only the signature is rotated) avoid this—but at the cost of extra bytes on the wire.
This is not a certified cipher. The construction uses SHA-256 as the round function and a homemade key schedule. For the non-exhaustiveness of IDs—tasks well studied, the stakes are low—this is quite sufficient. For the confidentiality of sensitive data—no. If you want to encrypt data—use AES-GCM from System.Security.Cryptography, not this file.
64-bit block, without nonce. The same pair (key, plaintext) always produces the same ciphertext. For public IDs, this is just what you need—links are stable. But it means that seeing two encrypted IDs, an attacker will understand whether it is one row in the database or different ones. That's fine. However, for any number of encrypted IDs, you cannot determine how many rows are in the table, what their numbers are, or in what order they were inserted—as long as the key remains intact.
When this is really appropriate
Feistel-ID makes sense to use if at the same time:
The API currently exposes sequential primary keys, and this needs to stop.
Switching to UUID as PK is either impossible or undesirable—due to bloated indexes, joins, or simply the cost of migration.
A normal authorization layer either already exists or is about to appear, and a simple reversible second line of defense on the key, that does not interfere with the database schema, is needed.
If at least one point does not apply to you—it’s better to choose another tool. For a new schema—UUIDv7. When key rotation is genuinely looming in operation—HMAC-signed IDs. When protecting data, not identifiers—normal encryption.
In a nutshell
The Feistel network is the cheapest way to make public IDs unguessable without touching the database. And the cheapest way to confuse unguessability with access control. The first is to adopt. The second is not to do.
If you are a lead or CTO looking at the codebase that was hastily assembled and now needs at least to stop the bleeding—this is a one-hour job, first in line. It does not fix the architecture, but it raises the cost of the most common, automated, and embarrassing category of attacks from "curl in a loop" to "you need to know something real." For a world where simplicity usually beats security, the shift is more than tangible.
- How many people are needed to develop a web product in 2026: breakdown of a viable team composition
- What to really expect from AI and why it will start generating profits sooner than it seems
- How to find both the goblin and the rat. Interview with researchers from the Solar Group who discovered the GoblinRAT malware
Write comment