Safeguard
Application Security

What Your Identifiers Tell the World

A random identifier is not an access control. What identifier design does affect is discovery, inference and what leaks when a URL travels, and a sequential integer in a URL publishes your customer count.

Shadab Khan
Engineering
5 min read

Your database uses auto-incrementing integers as primary keys, which is correct, and you expose them in URLs and API responses, which is a separate decision nobody made.

To be clear about what this is and is not: a random identifier is not an access control. If your endpoint does not check whether the caller may see the object, the identifier's format only changes how quickly they find one. The authorisation check is the control, always.

What identifier design does affect is discovery, inference, and what leaks when a URL travels. Those are worth getting right too.

What a sequential identifier tells the world

Your volume. /invoices/4821 says you have issued about 4,821 invoices. Sign up twice a week apart, compare the identifiers you are assigned, and you have a growth rate. This is competitive and investor-relevant information given away in a URL, and it is the reason many companies notice this only when somebody publishes their numbers.

What exists. An attacker who finds one object knows that neighbouring identifiers exist, which turns a single authorisation gap into complete extraction rather than a lucky hit.

Relationships. Adjacent identifiers were usually created at adjacent times, so the ordering reveals sequence even when you did not expose timestamps.

Random identifiers change the cost of discovery

Switching public identifiers to something unguessable does not fix a missing check. It does three useful things:

It makes enumeration impractical, so a gap you have not found yet is a single-object exposure rather than a bulk one. It removes the volume inference. And it removes the accidental-adjacency case, where a user edits an identifier in a URL out of curiosity and lands somewhere they should not be, which is how a meaningful share of these problems are discovered by ordinary users rather than attackers.

That last one matters commercially. A customer who stumbles into another customer's record is an incident with a disclosure conversation, regardless of intent.

Keep the internal key and the public identifier separate

The pattern worth adopting: the database keeps its integer primary key, because it is efficient and because relational integrity is built on it. The API exposes a separate, random, immutable public identifier.

ALTER TABLE invoices ADD COLUMN public_id text UNIQUE NOT NULL
  DEFAULT encode(gen_random_bytes(12), 'base64url');

Then joins, indexes and foreign keys use the internal key, and nothing outside your systems ever sees it. The cost is one indexed column and a lookup.

Two details. Make the public identifier immutable, because it will end up in customer bookmarks, tickets and integrations. And prefix it by type, as inv_ or doc_, which makes logs readable, makes support conversations unambiguous, and lets you reject an identifier of the wrong type early.

Pagination leaks the same way

Offset pagination exposes the collection's size, and it lets a caller jump to an arbitrary position. Cursor pagination, where the cursor is an opaque token, exposes neither, and it happens to be the correct choice for correctness under concurrent writes as well.

If a list endpoint returns a total count, decide deliberately whether that count is something the caller should know. For a customer's own records it clearly is. For anything aggregated across tenants it may not be.

Where identifiers end up

Worth knowing, because it determines how much the format matters:

  • Browser history, and the referrer header on any outbound link.
  • Server access logs, yours and your CDN's.
  • Analytics and error tracking, where a URL is recorded with the event.
  • Support tickets and chat messages, pasted by users.
  • Link previews generated by chat tools, which fetch the URL server-side.

None of that is a problem for a random identifier attached to a proper authorisation check. All of it compounds if the identifier is guessable or if the identifier is the capability.

The migration

You cannot change identifiers people already have. The realistic path is to add the public identifier, accept both forms at the API boundary for a deprecation period, switch every generated link and response to the new form, log any use of the legacy form so you can see who is still sending it, and then stop accepting it.

That last step is the one that gets skipped, leaving both forms live forever, which means the enumeration surface never actually closes.

The concession

For plenty of systems this is not worth doing. An identifier for a public blog post, a documentation page or anything genuinely non-sensitive gains nothing from being random, and sequential identifiers are more debuggable, shorter, and easier to talk about on the phone.

The trigger is whether the identifier names something belonging to a specific customer, or whether the count is information you would not publish. If either is true, separate the public identifier from the key. If neither is, an integer is fine and the effort belongs elsewhere.

The implication

The authorisation check is the control and identifier design is the blast radius. Get the first one right first.

Then look at what your identifiers say. If somebody can learn your customer count by signing up twice, that is a decision you made by not making one.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.