Don't Put Your Row Numbers on the Internet
Auto-incrementing IDs are perfect for your database and a quiet liability the moment they leave it. They leak, they invite tampering, and they turn a URL into a map of your business. Here's what they give away, and what to hand out instead.
Listening · Don't Put Your Row Numbers on the Internet
Your database hands you a beautiful primary key for free: 1, 2, 3, counting up forever. It's fast, it's unique, it's the obvious thing to use. And then you put it in a URL, /invoices/1043, or a JSON response, "user_id": 5012, and you've quietly published two things you didn't mean to.
What a sequential ID gives away
The first is a tampering target. If your invoice is /invoices/1043, the single most natural thing a curious or malicious user does is change it to 1044. Now you'd better hope every endpoint checks whether this user owns that invoice, because the ID itself just handed them the next guess for free. Sequential IDs don't cause the authorization bug, but they load it, aim it, and hand the attacker the ammunition. A random identifier makes "just try the next one" a non-starter.
The second leak is quieter and often worse: business intelligence. Sequential IDs count things, in public. If a competitor signs up and their user ID is 48213, they now know you have roughly forty-eight thousand users. If they place two orders a week apart and the order numbers jump by nine hundred, they know your weekly volume. Your invoice numbers tell a supplier how many customers you bill. You are printing your internal metrics on the outside of every URL, and anyone who cares can read them by signing up twice and doing subtraction.
What to expose instead
The fix is to keep the tidy integer where it belongs, inside your database, and expose something opaque to the outside world. A couple of options, roughly in order of how much I reach for them.
A UUID is the boring, correct default for most things. Random, unguessable, no sequence to count, and every language and database has support for it. The one wrinkle worth knowing: fully random UUIDs (v4) can fragment your database indexes because they don't sort in insertion order, so for high-volume tables people reach for time-ordered variants like UUIDv7, which stay unguessable from the outside but index far more kindly. If you don't know which to pick, v4 is fine until it isn't.
For short, user-facing codes where a UUID is too ugly (think a public order number a human reads over the phone), a random short ID with a library like nanoid gives you something compact and unguessable without the sequence. Just make it actually random, not a lightly-obfuscated version of the row number, because "encoded" is not "unguessable."
The pattern under all of them: your internal ID and your external ID are two different things with two different jobs. Internal wants fast and sequential. External wants opaque and meaningless. Keep a column for each, and never let the internal one out.
"But the ID isn't a secret"
The common objection is that IDs aren't supposed to be secret, and that's true. An opaque ID is not a security control, and if changing an ID to a neighbour's lets you read their data, the bug is the missing authorization check, not the guessable ID. Fix that first, always.
But defence in depth is the whole game, and sequential IDs are a cost with almost no upside once you're client-facing. They make the authorization bug trivially exploitable instead of merely present, and they leak metrics whether or not your authorization is perfect. An opaque ID closes the metrics leak entirely and turns "enumerate every record by counting" into "guess a random 128-bit value," which nobody is doing. You still write the authorization check. You've just stopped mailing attackers a map on the way.
Use the integer inside. Show the world something that means nothing.