ScopeMask - A way to mask Database IDs
5 min read

Does your API look like this?
This looks harmless initially, but deep inside, it is riskier than you think. Now, the question is: Is this a hard problem to solve?
Spoiler Alert: It’s easier than you think. You can use a UUID or Snowflake-Styled ID to solve this problem, but with some caveats, which trade a URL problem for a database problem. You’ll see in this post which other solutions exist, their pros and cons, and the one approach that fixes the URL without touching your schema.
TL;DR Raw IDs like
Raw IDs like /users/1337 leak data and growth signals. UUIDs and Snowflake IDs solve it but cost a migration, and Hashids or Sqids cannot tell a real ID from a tampered one. ScopeMask masks the ID in your application instead, so you keep your integer key and encode it to an opaque string on the way out and back on the way in. Every ID is scope bound and carries an HMAC checksum, so a tampered ID is rejected. There is no migration, and it is not a replacement for authentication or authorization.
The Problem
Enumeration
If 1337 exists, it is possible that 1335 and 1336 do as well. An attacker will be happiest in this scenario; he is one for loop away from scraping a chunk of your database. If you have rate limiting and IP Banning, that will make it hard, but attackers have all the tricks up their sleeves.

Information Leak
Your API might expose information that does not belong to the user. (Authentication and authorization are really, really important!)
Business Intelligence
Sign up today, get a user 48211. Sign up next week, get 49903. Congratulations, you just told a competitor your weekly growth rate. Invoice IDs leak revenue. Order IDs leak volume.
In reality, very few people leak user information like this. Some might do it by mistake, but the problem lies in the API Data/URLs you overlooked. Some of these problems can be solved by adding permission-based checking in the API, but it is still best to hide or mask the ID. Attackers don’t come knocking on your door; they come silently, and you are probably not at your desk. As a backend engineer, it’s your job to make attackers/bots’ lives harder.
You will see many tech companies and organizations use a masked or scrambled ID to protect against attackers, bots, etc.
For example, YouTube,
https://www.youtube.com/watch?v=dQw4w9WgXcQ
The Identifier here is not ?v=42, it’s ?v=dQw4w9WgXcQ
What are the solutions?
The most common solution people use is UUID or Snowflake-style IDs or Sqids / Hashids.
Hashids / Sqids
You can encode the integer ID into a short string with a library like Hashids or Sqids and decode it back when the request comes in, with no extra database column and no migration. This is the closest of the common options to what we want, and it is easy to adopt. The downside is that if you flip a character, it can decode to a different, but perfectly valid, integer, so there is no way to tell a real ID from a tampered one. There is also no notion of scope, and Hashids only handles integers ( Note: Hashids has been upgraded & rebranded to Sqids)
Using UUID
You can switch your primary key to a UUID, or if your table already has an Int/BigInt key you cannot afford to change, add a new uid or public_id column, store the UUID there, and index it. On a smaller database, or one you are just starting, this is really easy to implement. But UUIDs are not free; a UUID is 16 bytes, whereas INT is 4 bytes, and BIGINT is 8 bytes, and that size multiplies across every secondary index and foreign key. Random UUIDv4 values also insert poorly into B-Tree indexes because they land at random positions rather than being appended sequentially. In MySQL InnoDB, especially when the primary key is clustered with the table, this means more page splits, fragmentation, and worse cache locality. PostgreSQL is less sensitive, but larger random keys still grow the index size and increase write amplification over time. This is why many teams now prefer time-ordered UUIDv7 or ULIDs, whose inserts stay sequential while keeping global uniqueness. In a very large production database, the migration itself is expensive. Backfilling UUIDs for millions or billions of rows, rebuilding indexes, updating foreign keys, and changing queries require significant engineering effort that costs both time and money and may introduce downtime or performance degradation, which is not desirable for some companies or projects, where you might have contractual SLAs.
Using Snowflake-style IDs
You can generate IDs the way Twitter, Discord, and Instagram do. A 64-bit number generated using a timestamp, a machine/worker ID, and a per-millisecond sequence counter. On a new project, it is easy to implement; all you have to do is pick a generator, run it per node or pod with a unique worker ID, and because they are time-ordered, they insert into B-Tree indexes almost as cleanly as sequential Integers. But again, if you didn’t plan for this earlier, and now your database consists of millions or billions of rows, the cost is time and money. Backfilling every row, rebuilding indexes, rewriting every foreign key, the same heavy migration as moving to a UUID, which can lead to unintended downtime or performance degradation if not being careful. Also, they can still leak creation time and volume through timestamp and counter bits.
All of these solutions might work for your use case, but there is one thing: they are not tamper-evident. Obfuscation hides the number, but it doesn’t tell you whether the number coming back in a request is one you actually issued. That’s the difference between an opaque ID and a trustworthy one.
So the question becomes: can you get the opacity and the trust, keep your integer primary key, and skip the migration entirely? That is exactly the gap ScopeMask fills.
Where ScopeMask fits
ScopeMask works directly in your application rather than in the database. You keep your fast integer primary key exactly as it is, and you mask the ID only on the way out to the client and decode it back on the way in. Your database never sees the opaque string, and the client never sees what you are hiding in your database.
ScopeMask is built on top of Sqids for the short, reversible encoding, but what makes it different is:
Scope binding: every ID is tied to a scope, so the same value produces a different ID under user than it does under order, and an ID generated for one scope will not decode under another. The secret and the scope together derive the alphabet the ID is built from, which is why IDs from different scopes are unrelated, and decoding under the wrong scope simply fails.
Integrity: every ID carries a short keyed checksum, the first four bytes of
HMAC-SHA256(secret, scope + input)which are recomputed and compared in constant time on decode. A tampered ID, an incorrect scope, or an incorrect secret is rejected rather than silently decoded into a different but valid-looking value.
What ScopeMask is not
ScopeMask is not an encryption library. The 4-byte MAC is an integrity check sized for URLs or API Data, not a 256-bit auth tag. For sensitive data, use proper encryption libraries and protocols.
It is not a replacement for authentication or authorization. Decoding a valid user ID only tells you the ID is real and untampered. It does not tell you who is asking or whether they are allowed to see that record. Always keep authentication in place, and always run your own permission checks on every request. Verify that the caller actually owns the data before returning it, and never serve a record to someone who does not own it. ScopeMask stops the ID from being guessed or forged.
When to use it and when not?
If you already have a large database, your current operations and queries use an existing integer-based primary key, and you want to avoid a risky migration, ScopeMask can help and make your life easier. Instead of changing the database schema and handling migration, you only add a few lines in your application code.
If you don’t plan to use UUID / Snowflake-Styled IDs but want to obfuscate IDs or other information, then ScopeMask will be beneficial.
You need stateless encode/decode at the edge.
You want tamper-evident IDs.
If these are not your concerns, then UUID, Snowflake-Styled ID, Sqids, or anything that provides a similar end result is good enough.
How to use it
Installation
Encode and decode
One secret token and derive everything from it; keep it private and stable. Integers, strings, bytes, and UUIDs are supported, and the original type is restored on decode.
Python
Golang
TypeScript / JavaScript
Prefixes
usr_, whs_, cus_ readable prefixes, Stripe-style. Pass the same prefix when decoding.
Python
Golang
TypeScript / JavaScript
Bind a scope once
In real code, you can work in one scope per module or one scope per ORM Table Definition. Bind it and stop repeating yourself.
decode raises on a bad ID; try_decode returns None/null/ok == false.
Python
Golang
TypeScript / JavaScript
For more references, visit the documentation
Under the hood
Encode builds the signing input as VERSION + TAG + body, appends HMAC-SHA256(secret, scope + 0x00 + input)[:4], splits the result into chunks, sqids-encodes them with the alphabet derived from secret + scope, then prepends the prefix.
Decode strips the prefix, sqids-decodes with the scope’s alphabet, rejects the ID if re-encoding doesn’t reproduce it, checks the version, then recomputes and constant-time-compares the MAC before reading the TAG to restore the original value and type. Previous secrets are tried in turn until one verifies.

First and foremost, ensure the authentication/authorization system, then stop shipping /users/1337. Keep the integer your database loves; hand the world something opaque, scoped, and tamper-proof.
ScopeMask is open source; feel free to criticize and give it a star.
Source, docs, and the full spec:
https://github.com/khan-asfi-reza/scopemask

