This is open-source code that lets you secure or mask numbers (within Postgresql DB) for use as unique IDs that are 6-digits or more.
This is what they look like:
https://example.com/order/053124
Many applications require numerical IDs or unique numbers. Some Examples:
- Credit Card numbers
- National ID Card numbers or Social Security numbers
- Phone numbers
- Prepaid topup voucher codes
- Shopping coupon codes
- User ID numbers
- Order numbers
- Invoice numbers
- Transaction numbers
Using incrementing numbers for any of the above examples may expose your application to various security problems.
Your IDs might expose more information than you might realize. For instance in a web shop when you make an order you will probably be redirected to a success site with your order_id
found as a query parameter (or similar):
https://myshop.com/account/orders?orderid=7865
From this you can probably estimate how many orders they processed. But it gets worse. If you make another other one, let's say 14 days later, and it gets the ID 7921 you can deduce that they receive about 4 orders a day.
Typically, one could use gen_random_number()
to mask such order numbers, but the result is not collision-resistant, meaning generated numbers are not guaranteed to be truly unique.
Prepending a numerical timestamp could be one way to avoid collision (just like UUID), but that would make the ID way too long for use as short numerical codes (e.g. for input over Phone Numeric Keypad).
Password hashing KDF functions such as Argon2id, scrypt, bcrpyt do not generate unique numbers, which means their output cannot be used for numerical IDs.
Some may propose using Non-cryptographic Hash Function such as FNV
or Murmur
that can produce short 32-bit numbers.
However, because the inputs are inherently small numbers, hackers can use Rainbow Tables to crack such hashes. Also because they are not keyed, more modern hash functions that better resist Collision attacks should be preferred.
SipHash is one such Non-cryptographic Hash Function that is used by Bitcoin for short transaction IDs. It protects against Denial-of-Service attacks via Hash Flooding. However, it produces 64-bit numbers (e.g. 18_446744_073709_551615
) that are too long.
In cryptography, Format-preserving encryption (FPE), refers to encrypting in such a way that the output (the ciphertext) is in the same format as the input (the plaintext). It is able to take in unique inputs (numbers) and produce unique outputs (numbers).
NIST's FF1 standard is the most popular FPE standard as of this writing (and is secure as long as the tweak
is suitably selected or not user-determined or provided by the user).
A 128-bit or 256-bit secret key (derived from your Postgres DB root keys) is used to convert a unique int4
/int8
number to a secure numerical text (also unique). If the number is less than 6 digits, it is padded to 6 digits (e.g. 003124
) to protect against FPE's security weakness on small domains.
NIST Test vectors are provided to ensure full compliance with the NIST FPE-FF1 Standard. Parts of this code is based on FPE-FF1 noble-ciphers implementation by the renowned Paul Miler.
Performance is good enough. If alphanumeric IDs are ok for your use-case, you should consider using the simpler gen_short_code() alternative here instead.
If you would like to store the generated code, use Postgres' generated-column:
-- Create a table
CREATE TABLE profile (
id int4 PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
public_id text GENERATED ALWAYS AS (gen_mask_code(id, gen_mask_secret('_profile'))) STORED,
...
);
-- (optional) add an index on the generated column to speed up the query
CREATE INDEX profile_public_id_idx ON profile (public_id text_pattern_ops) WITH (fillfactor = 50);
If you don't want to store the generated short codes on-disk, use Postgres' computed-column:
-- Create a table
CREATE TABLE profile (
id int4 PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
...
);
-- Create a computed field
CREATE FUNCTION public_id(profile)
RETURNS text AS $$
SELECT gen_mask_code($1.id, gen_mask_secret('_profile'));
$$ LANGUAGE SQL;
-- Query the computed field
SELECT *, profile.public_id FROM profile LIMIT 100;
-- (optional) add an index on the computed field to speed up the query
CREATE INDEX profile_public_id_idx ON profile (public_id(profile) text_pattern_ops) WITH (fillfactor = 50);
- Supabase
- Postgres v15
- AWS Relational Database Service (RDS)