How to Hide Auto-Increment IDs in Public APIs Without Adding a Column
Here’s a common scenario: your backend database uses numeric auto-increment IDs as the primary key and unique identifier for each record. But you don’t want to expose these IDs in your public API, because sequential numbers leak your data volume and growth trends.
What’s the lowest-friction way to solve this? Here are two uncommon, but cheap, and remarkably effective approaches.
Method 1: AES-128-ECB
Pad the auto-increment ID to 128 bits, run it through one round of AES encryption, and output the 128-bit ciphertext as hex.
import os
import struct
from Crypto.Cipher import AES
# 16-byte key, stored as hex in an environment variable
# Generate with: python3 -c "import os; print(os.urandom(16).hex())"
_key = bytes.fromhex(os.environ["ID_ENCRYPTION_KEY"])
def to_opaque_id(original_id: int) -> str:
"""Auto-increment ID -> 32-character hex string"""
plaintext = struct.pack(">QQ", 0, original_id) # High 64 bits zeroed, low 64 bits hold the ID
cipher = AES.new(_key, AES.MODE_ECB)
return cipher.encrypt(plaintext).hex()
def to_original_id(opaque_id: str) -> int:
"""32-character hex string -> auto-increment ID"""
cipher = AES.new(_key, AES.MODE_ECB)
plaintext = cipher.decrypt(bytes.fromhex(opaque_id))
_, original_id = struct.unpack(">QQ", plaintext)
return original_id
In action:
>>> to_opaque_id(114514)
'1a8af8d9bab8e5f1de4d421c76cc352d'
>>> to_original_id('1a8af8d9bab8e5f1de4d421c76cc352d')
114514
The output is 32 hex characters—128 bits, the same width as a UUID.
Note that while this output is UUID-width, inserting hyphens in the 8-4-4-4-12 format does not guarantee that the version and variant bits conform to RFC 9562. Don’t pass it to libraries or systems that validate UUID format. Just call it an opaque ID or public ID.
Method 2: FF1
If you have requirements on the output format, such as numeric input and equal-length numeric output, you can use Format-Preserving Encryption (FPE).
FF1 is the NIST-recommended FPE algorithm. It constructs a bijection over a domain of any radix and fixed length.
import os
from FPE import FPE
# key: 16 bytes hex
# tweak: arbitrary length hex (FF1 allows variable-length tweaks)
_key = bytes.fromhex(os.environ["ID_ENCRYPTION_KEY"])
_tweak = bytes.fromhex(os.environ["ID_ENCRYPTION_TWEAK"])
_cipher = FPE.New(_key, _tweak, FPE.Mode.FF1)
WIDTH = 10 # 10 decimal digits, supports up to 9,999,999,999 records
def to_opaque_id(original_id: int) -> str:
"""Auto-increment ID -> equal-length numeric string"""
plaintext = str(original_id).zfill(WIDTH)
return _cipher.encrypt(plaintext, FPE.Format.DIGITS)
def to_original_id(opaque_id: str) -> int:
"""Equal-length numeric string -> auto-increment ID"""
return int(_cipher.decrypt(opaque_id, FPE.Format.DIGITS))
In action:
>>> to_opaque_id(114514)
'7484127525'
>>> to_original_id('7484127525')
114514
Ten digits in, ten digits out. Perfect for human-facing identifiers where you want to hide ordering information—ticket numbers, order numbers, and the like.
How It Works
AES is a bijection from 128 bits to 128 bits: the same plaintext always encrypts to the same ciphertext, different plaintexts always encrypt to different ciphertexts, and anyone holding the key can decrypt back to the original. On top of that, AES is a pseudorandom permutation (PRP): to an observer without the key, its output is computationally indistinguishable from a truly random bijection. Pad your auto-increment ID to 128 bits, run it through AES, and you get an unpredictable, irreversible, collision-free 128-bit identifier.
FF1 follows the same idea, but solves a more specialized problem: when the ciphertext needs to preserve the same format and length as the plaintext—say, both are ten-digit decimal numbers—FF1 constructs a bijection over a finite domain of arbitrary radix. (Naturally, the ciphertext length shouldn’t be too short; for decimal digits, it should be at least >= 6.)
Neither approach requires any changes to your database schema. The mapping happens entirely in the application layer: decrypt once on the way in to recover the auto-increment primary key and query the database directly; encrypt once on the way out to produce the opaque ID. The database has only ever had one column: the auto-increment ID.
Why?
When you search or ask an AI “how to hide auto-increment IDs in public APIs,” the most common answers are either adding a UUID column to the table or bringing in an encoding library like Hashids/Sqids. These solutions work, but each has drawbacks.
Adding a random UUID (v4) column means 16 extra bytes of storage per row, an additional secondary index (and on InnoDB, random UUIDs cause page splits—Ew! Get out of my way MySQL!), a schema migration, and one extra index lookup from UUID to primary key on every query path. UUIDv7 solves the random-index performance problem by putting a timestamp in the high bits, but that also means it directly leaks each record’s creation time. An observer can easily read the distribution of creation times from UUIDv7s and infer growth trends, exactly the information we’re trying to hide.
Sqids and similar schemes are lighter-weight and also don’t require schema changes, but they are fundamentally encoding, not encryption. Sqids explicitly states in its FAQ that it’s not suitable for sensitive data, and the custom alphabet can be reverse-engineered. More practically, Sqids is an extra dependency, while AES is infrastructure that comes built into the standard library of virtually every language. Since AES achieves stronger guarantees with fewer dependencies, introducing a specialized ID obfuscation library becomes unnecessary.
FAQ
Isn’t AES overkill? Can’t you just XOR or hand-roll a few rounds of Feistel?
The reason to reach for AES here isn’t to pursue maximum cryptographic strength. It’s to guarantee a high floor with minimal cognitive overhead.
Hand-rolling Feistel or rolling XOR can certainly produce random-looking output, but they introduce a chunk of complexity unrelated to your business logic: How many rounds? What round function? How do you pick modular-inverse parameters to avoid statistical bias? Each of these questions demands your own judgment and verification. The advantage of AES is that all these questions have been thoroughly vetted over decades of use, and the cost of getting that assurance is virtually zero—one standard-library call, with AES-NI hardware acceleration thrown in for free, almost always outperforming anything you’d hand-roll.
Isn’t ECB the textbook example of what not to do?
The classic problem with ECB is that when encrypting multiple blocks, identical plaintext blocks produce identical ciphertext blocks, leaking repeated patterns in the data. But here the padded input is exactly one block. There is no “multiple blocks” to speak of.
On the other hand, ECB’s determinism. The same input always producing the same output, is precisely the requirement in this scenario. We need the same ID to map to the same opaque ID every time. If you used a mode like CBC or CTR that requires a random IV, you’d have to store the IV/nonce somewhere, which brings you right back to adding a column.
The auto-increment ID is only 64 bits and the high 64 bits are all zeros. Doesn’t that much fixed structure give the ciphertext statistical characteristics?
AES’s PRP property guarantees this isn’t a problem. As stated above: to an observer without the key, the output of AES is computationally indistinguishable from a truly random permutation, regardless of how much known structure the input has. This property has been validated by decades of cryptanalysis. And this is precisely why AES is more reassuring than a home-grown permutation. It’s often very difficult to prove that a custom construction achieves the same degree of statistical uniformity.
Can I replace AES with ChaCha20 or Salsa20?
Absolutely not. ChaCha20 and Salsa20 are stream ciphers. They encrypt by XORing plaintext with a keystream. With a fixed key and nonce, the keystream is fixed. This means encrypt(a) ^ encrypt(b) = a ^ b. An attacker only needs to XOR two opaque IDs to obtain the difference between the original IDs; knowing any single plaintext–ciphertext pair lets them recover the keystream and decrypt every ID. This is essentially a fixed XOR mask, not a pseudorandom permutation.
This problem is not limited to ChaCha20 and Salsa20. All stream ciphers share the same issue: they are essentially a linear transformation and do not constitute a PRP. While technically still a bijection, the mapping is trivially reversible to an observer.
Caveats
Key Management
The security of this scheme depends entirely on the key. A leaked key means the mapping between every opaque ID and its auto-increment ID is fully exposed. Protect the key the way you’d protect your Stripe API key—at the very least, don’t hardcode it in your frontend code.
Key Rotation
Changing the key means the same auto-increment ID will map to a different opaque ID. If external systems persist references to your opaque IDs, you’ll need to keep the old key around as a fallback for decryption: try the new key first, fall back to the old one on failure.
Input Validation
For the AES-ECB approach, check that the high 64 bits are zero after decryption. If they aren’t, the input was not a legitimate opaque ID generated by your system, and should be rejected or trigger a security alert. This is essentially a free integrity check.
FPE Security Margin
FF1 is currently the only FPE standard retained by NIST (SP 800-38G Rev. 1 Draft 2 withdrew the FF3 family in February 2025). Each FF1 encryption/decryption operation costs roughly 10x that of Method 1 (10 Feistel rounds, each using AES-CBC-MAC as the round function), but for a single ID conversion it’s still in the microsecond range, which is not a bottleneck.
However, because FPE constructs a permutation over a domain far smaller than 2^128, its security margin is inherently narrower than a standard block cipher like AES. For hiding auto-increment IDs, it’s more than sufficient; but if you don’t have an absolute requirement for “digits in, digits out,” AES-ECB from Method 1 is the better choice.
Acknowledgments
hanbings came up with this problem. it is delicious, go check it out