Your primary key shouldn't be in the URL

dev.to

Open an order confirmation in a lot of apps and the URL ends in something like /orders/48213. That number tells a stranger more than you'd think: roughly how many orders you've taken, how fast you're growing, and — if a single authorization check is ever missing — exactly which URLs to try next. On Saturdays, a food delivery platform, the IDs that leave the server are never the ones the database uses internally.

What an auto-increment ID leaks

A sequential integer primary key is a great internal identifier: compact, fast to index, naturally ordered. It is a poor external one, for three reasons:

  • It's a business metric. Place one order on Monday and another on Friday, then subtract. You now know the platform's weekly order volume. Competitors do exactly this.
  • It's a map. If /orders/48213 is yours, /orders/48212 is someone else's. An attacker doesn't have to guess anything.
  • It turns one bug into a breach. An endpoint that forgets to check ownership — an IDOR, one of the most common web vulnerabilities there is — goes from "exploitable if you happen to find an ID" to "download every order by counting."

Two identifiers, two jobs

The fix is to stop making one column serve two audiences:

class Order(models.Model):
    id = models.BigAutoField(primary_key=True)      # internal: joins, indexes
    public_id = models.UUIDField(
        default=uuid.uuid4, unique=True, editable=False
    )                                               # external: URLs, APIs, events
Enter fullscreen mode Exit fullscreen mode

The integer stays inside the database, doing what it's good at. Every URL, API response, event payload and webhook uses public_id. A random UUID says nothing about volume, has no neighbour, and can't be enumerated.

This is not access control

It's worth being blunt about the limit, because this is exactly where teams talk themselves into a mistake: an unguessable ID is not a permission check. URLs get shared, logged, pasted into support tickets and saved in browser history. If knowing an order's UUID is enough to read it, you haven't fixed the vulnerability — you've hidden it.

Every lookup still has to be scoped to the caller: this order belongs to this customer, this restaurant, this rider's assignment. The public ID is defence in depth. When the ownership check is correct, it changes nothing. When one is ever missing, it turns a catastrophic, enumerable leak into a narrow, hard-to-exploit one.

Doing it without regret

A few details make the pattern cheap to live with:

  • Add it early. Retrofitting a public ID onto a table that outside systems already reference means migrating every consumer. On day one it's a single field.
  • Index it. unique=True gives you the index, so lookups by public ID stay fast.
  • Never expose both. An API that returns id and public_id has undone the whole point. Serializers should simply leave the internal key out.

The takeaway

Separate the identifier your database needs from the identifier the world sees. It costs one column and removes a whole class of information leak — as long as you remember it's the second lock on the door, never the only one.

More of the design decisions behind the platform — the data model, the payment path, the POS integration — are in the case study.

👉 Read the case study: Saturdays — Food Delivery Platform


Divyakush Punjabi — Full-Stack & AI Systems Engineer

🌐 https://www.divyakush.com · 💼 LinkedIn · 💻 GitHub

Source: dev.to

arrow_back Back to News