Choose based on what the ID must resist, not developer taste. Sequential integers are fast but expose your row count and let anyone guess adjacent records. UUIDs are safe and unordered but hostile to debugging. Prefixed IDs — Stripe's cus_ and inv_ pattern — combine opacity with human-readable context, and are the right default for most public APIs.

Quick answer: Never expose raw auto-increment primary keys. Use random UUIDs (or ULIDs) when you need maximum unguessability, prefixed opaque IDs like Stripe's cus_/inv_ when you also want debuggability, and keep sequential integers strictly internal — behind a join column no client ever sees.

Why Resource ID Design Is a Security Decision, Not a Data-Type Choice

Resource IDs sit at a security boundary because most APIs check "is this user authenticated" far more rigorously than "does this user actually own this specific object." A predictable ID turns that gap into a working exploit: change one digit in a URL and you're looking at someone else's data.

OWASP's API Security Top 10 has ranked exactly this flaw — Broken Object Level Authorization (BOLA), the modern name for what used to be called Insecure Direct Object Reference (IDOR) — as the single most common and most damaging API vulnerability class since the list existed.

The enumeration attack is almost embarrassingly simple to run. If GET /orders/10482 returns a valid order for an authenticated attacker, nothing stops them from looping through /orders/10483, /orders/10484, and every neighboring integer, harvesting every other customer's order regardless of who they're logged in as. No password guessing, no injection payload — just a for-loop and a sequential key you handed them for free.

This is why ID scheme is a product decision as much as a schema one. It's one of the concrete choices a PM has to make explicit and defensible in the same document that pins down auth model, pagination, and error shapes — the kind of decision covered in depth in the PM's job of specifying an API contract. Get it wrong and you're not fixing a bug later — you're issuing new IDs to every existing customer, which is its own migration project.

Three separate risks travel together inside a bad ID scheme:

  • Enumeration — an attacker (or a curious competitor) can guess valid IDs they were never given.
  • Volume leakage — the gap between two IDs reveals how many records were created in between, exposing growth rate, order count, or user count to anyone with two data points.
  • Type confusion — a raw integer or generic string gives a developer, or a script, no signal about what kind of object it's looking at, which invites bugs where an invoice ID gets passed where a customer ID was expected.

Fixing all three at once is exactly what the rest of this article walks through.

Sequential IDs: Fast, Familiar, and Quietly Leaking Information

Auto-incrementing integers are the default because your database hands them out for free, indexes them efficiently, and sorts naturally by creation time. The cost is that every sequential ID leaks two things simultaneously: existence — an attacker can guess neighboring records — and volume — the numeric gap between any two known IDs reveals exactly how many records were created in between.

That volume leak is not theoretical. If a competitor can obtain two of your invoice numbers a week apart — one from a public receipt, one from a leaked screenshot, one from their own test purchase — subtracting them tells them almost exactly how many invoices you issued that week. Do this monthly and they've reverse-engineered your revenue trend without ever touching your database.

A well-documented real-world case: after the January 2021 Capitol riot, researchers were able to systematically download the near-entirety of Parler's public posts, including ones users believed deleted, in large part because the platform assigned posts predictable, sequentially incrementing numeric IDs — no randomness, no per-object check required to walk the entire dataset.

Sequential IDs aren't wrong everywhere. They're excellent for:

  1. Internal foreign keys that never leave your database or cross a service boundary.
  2. Append-only event or log tables where write throughput and index locality matter more than secrecy.
  3. Single-tenant internal tools where every user is already trusted to see every record.

The line is simple: the moment an ID is serialized into a response body, a URL, or a support ticket that a customer or partner can read, sequential is the wrong choice.

DimensionSequential integerRandom UUID (v4)Prefixed opaque ID
Enumerable by guessingYes — trivialNo — ~122 bits of randomness per RFC 4122No
Leaks record volume / growth rateYesNoNo
Debuggable in logs / support ticketsLow — just a numberVery low — 36 opaque charactersHigh — type is visible, e.g. cus_1a2b3c
Sortable by creation timeYes, naturallyNo — randomDepends on implementation
Write/index performance at scaleBest — append-onlyWorst — random inserts fragment B-tree indexesSame as its underlying storage type
Safe to put in a public URLNoYesYes

Read the table as a translation exercise: sequential wins on performance and loses on every safety dimension, and the other two schemes trade a little performance back for that safety.

UUIDs: Opaque and Unordered, But Not Free

A random UUID (version 4) solves the enumeration and volume-leakage problems completely — with roughly 122 bits of randomness per RFC 4122, guessing a valid one is computationally infeasible, and no two IDs reveal anything about how many others exist between them. Multiple services can also generate them independently with no central counter or coordination, which matters the moment you shard a database or run distributed writers.

What you give up is real, and it shows up in three places:

  • Debuggability. f47ac10b-58cc-4372-a567-0e02b2c3d479 tells a support engineer nothing. Pasted into a Slack thread next to four other UUIDs, no one can tell at a glance which is the customer and which is the invoice — a small tax that adds up across thousands of incident-response conversations.
  • Index locality. Random UUIDs inserted as a primary key scatter writes across a B-tree instead of appending to its tail, causing page splits and index bloat that degrade write performance as tables grow — a well-known operational cost documented in both PostgreSQL and MySQL performance guidance.
  • No implicit ordering. You lose "sort by ID equals sort by creation time" for free and must maintain a separate created_at column and index if you need chronological ordering.

Time-sortable variants exist specifically to close that last gap. UUID version 7 (standardized in RFC 9562) and the community ULID spec both embed a timestamp prefix ahead of random bits, so IDs sort chronologically while staying effectively unguessable — the same idea Twitter's open-sourced Snowflake ID generator and Segment's KSUID format popularized for exactly this tradeoff. If you're choosing a UUID variant today, default to a time-sortable one rather than pure v4 unless you have a specific reason not to.

The deeper point: opaque doesn't have to mean useless. It only means useless to an outside observer — you can still recover ordering and metadata for your own systems while giving attackers nothing.

Prefixed IDs: Stripe's Pattern for Debuggable Opacity

Prefixed IDs solve the debuggability problem UUIDs create by tattooing the resource type onto the front of an otherwise opaque identifier — Stripe's cus_1a2b3c... for a customer, inv_9x8y7z... for an invoice, ch_... for a charge. The suffix stays random and unguessable; only the prefix is meaningful, and it's meaningful to humans and machines alike.

That small addition pays for itself repeatedly. A support engineer scanning a log line instantly knows whether they're looking at a customer, a subscription, or a charge, without querying anything. A backend developer catches a type-confusion bug — code that accidentally passes a customer ID where an invoice ID belongs — at the point of a simple prefix check, before it ever reaches the database. And a monitoring dashboard can group errors by resource type just by parsing the first few characters of an ID string.

This pattern also plays well across API styles. A REST endpoint returns cus_... directly in a JSON body and URL path; a GraphQL API doing Relay-style Global Object Identification base64-encodes a type-plus-ID pair into one opaque node identifier — different encoding, same underlying idea of binding type information to the ID itself. Deciding which style fits your API in the first place is its own upstream decision, covered in choosing between REST, RPC, and GraphQL, and the ID convention you pick should follow from that choice, not fight it.

A minimal prefixed-ID implementation is genuinely simple:

POST /v1/customers
→ { "id": "cus_9k2j1h8g7f", "email": "..." }

GET /v1/invoices/inv_4d3c2b1a
→ { "id": "inv_4d3c2b1a", "customer": "cus_9k2j1h8g7f", "amount_due": 4200 }

Nothing here is guessable, and nothing here is a mystery to whoever reads it next.

Never Expose Raw Database Primary Keys

Whatever scheme you choose, the ID your database uses internally and the ID your API exposes should be two different columns — never one. Keep a fast internal primary key (typically a plain auto-increment integer) purely for joins and index locality, and add a separate public_id field — UUID, ULID, or prefixed string — as the only identifier your API ever serializes.

This separation isn't defensive paranoia; it's what makes the ID a genuine contract instead of an accidental byproduct of your schema. Once you've properly named and scoped your resources — the exercise covered in modeling API resources as nouns — the public ID for each one deserves the same deliberate design as its fields and relationships. A stable ID contract means:

  1. An ID never changes once issued, even if the underlying row is migrated, re-sharded, or moved to a new table.
  2. An ID is never reused after its object is deleted — a recycled ID silently pointed at a different record is a data-integrity incident waiting to happen.
  3. Case sensitivity and character set are fixed and documented, so client-side comparison and storage don't quietly break.
  4. The prefix (if used) is permanent per resource type — renaming cus_ to cust_ later is a breaking change to every integration you have.

That stability matters most at the exact moments a customer actually sees the ID: in a password-reset link, an invoice PDF, an email receipt, or read aloud to a support agent on a call. Those touch points sit inside the customer journey whether or not a PM ever consciously designed them — an ID that changes after a refund, a merge, or a data migration turns a routine support call into "I don't understand why my old confirmation number doesn't work anymore."

Decoupling internal and public IDs also buys you freedom later: you can re-platform your database, switch ORMs, or shard a table without touching a single public-facing identifier, because the contract your customers depend on was never tied to your storage implementation in the first place.

Choosing an ID Scheme: A Decision Framework

The right scheme depends on who has to work with the ID and what they're trying to do with it — which is, in effect, the job the ID is hired to perform for a support engineer, a partner developer, or an attacker probing your surface, the same lens Jobs-to-Be-Done thinking applies to any other product decision. Match the scheme to the job, not to whichever type your ORM defaults to.

ScenarioRecommended schemeWhy
Public REST resource shared in URLs, emails, support ticketsPrefixed opaque IDNon-enumerable, and instantly readable by type
Internal foreign key / join column, never serializedSequential integerFastest index, smallest storage footprint
High-throughput events written by many distributed servicesTime-sortable UUID/ULID or Snowflake-style IDNo central counter needed; still roughly chronological
Payment, invoice, or other financial objectPrefixed IDMatches the audit-friendly convention most finance and support tooling already expects
GraphQL node identifierBase64 opaque global IDMatches the Relay Global Object Identification convention

Treat this table as a starting point, not a rule to apply uniformly across every resource in one API — a single product often mixes prefixed public IDs for customer-facing objects with plain integers for purely internal join tables, and that's correct, not inconsistent.

Making the decision explicit, not accidental

Most bad ID schemes aren't chosen — they're inherited from whatever an ORM or migration tool defaulted to on day one, and nobody revisits the choice until an enumeration report or a support ticket forces the issue. Because the pillar guide on API product design treats identifiers as a first-class contract decision alongside versioning and pagination, it's worth designing the ID scheme at the same time you design the resource, not retrofitting it after launch.

Key Takeaways

  • Sequential integers leak volume and invite enumeration — never serialize an auto-increment primary key directly to an API client.
  • OWASP's Broken Object Level Authorization (BOLA/IDOR) risk is amplified, not caused, by a predictable ID — fix authorization checks and the ID scheme, not one or the other.
  • Random UUIDs are safe but hostile to debugging and index performance — prefer a time-sortable variant like UUID v7 or ULID if you go this route.
  • Prefixed opaque IDs (Stripe's cus_/inv_ pattern) give you most of a UUID's safety plus real debuggability, and are the right default for most public-facing resources.
  • Always separate your internal database primary key from a public-facing ID field — the two should never be the same column.
  • An ID is a stability contract: never change it, never reuse it after deletion, and document its format before customers start depending on it.
  • Match the scheme to the resource's job — public and shared IDs need opacity and readability; purely internal join keys don't.

Frequently Asked Questions

Is it safe to use UUIDs as database primary keys?

Yes for security — random UUIDs are effectively unguessable — but they carry a real performance cost as a clustered primary key, since random inserts fragment a B-tree index and slow writes at scale. Many teams use an internal sequential key for storage and expose a separate UUID or prefixed public ID for clients.

Should I use UUID or auto-increment for API IDs?

Never expose auto-increment IDs directly in an API — they're enumerable and leak record volume. Use a UUID (ideally time-sortable, like v7) or a prefixed opaque ID for anything a client can see, and keep sequential integers strictly internal.

How do I stop competitors from guessing my order or user counts?

Switch any customer-facing ID to a non-sequential format — random UUID or prefixed opaque ID — so no two IDs reveal the gap between them. Sequential IDs are the mechanism that makes volume estimation possible in the first place; removing the sequence removes the leak.

What is a prefixed ID like Stripe uses, and why bother?

A prefixed ID puts a short, fixed tag for the resource type (cus_, inv_, ch_) in front of an otherwise random, opaque string. It stays as unguessable as a UUID while letting anyone reading a log or support ticket instantly identify what kind of object they're looking at.

Can I switch from sequential IDs to UUIDs without breaking my API?

Not without a migration and a deprecation window — existing integrations and bookmarked URLs depend on the old IDs. The safer path is adding a new opaque public_id field alongside the old one, supporting both for a transition period, then sunsetting the sequential ID with advance notice.