19 // journal

Sequential IDs leak your business: UUIDs and opaque identifiers

engineering · 2026-11 · 7 min read · engineering · security · databases

journal/sequential-ids-leak
Sequential IDs leak your business: UUIDs and opaque identifiers

fig.01 · rfc 9562, the uuid spec: 128 bits, eight versions, and why insert order matters

Sign up for a competitor's service, place two orders a month apart, and you know their monthly order volume. If invoice 12,400 followed invoice 11,950 in thirty days, they did about 450 orders that month. Take a few more samples and you can estimate their entire history, because anyone with four sequential IDs can run the same math the Allies used on German tank production in 1943. Your public identifier is a telemetry feed you did not mean to publish.

The constraint

Identifiers show up everywhere the outside world can see: URLs, API responses, invoice numbers, export filenames, support tickets. They have to be unique across every writer (servers, devices, offline queue), unguessable enough to resist enumeration, and cheap to store and index. Sequential integers nail the first and third requirements and fail the second completely.

The options

Sequential integer. serial, bigserial, AUTO_INCREMENT, the database default nobody chose. Unique, tiny, sorts by creation. Also a published counter: the maximum ID you have ever seen is a lower bound on how many rows exist, and the German tank problem gives the estimator that turns samples into a volume figure. Enumeration is equally trivial: if your account is user/4001, accounts 1 through 4000 exist, and scraping is a for loop.

Sequential underneath, encoded on top. Base62 the counter, Hashids it, XOR it with a constant. The URL looks opaque and the leak stays, because the encoding is a bijection over a sequence: anyone who recovers the scheme (and these schemes are all public libraries) recovers every ID and their order. This is obfuscation, and obfuscation has a single point of failure.

UUIDv4, random. 128 bits where 4 carry the version, 2 carry the variant, and 122 are random per RFC 9562. No sequence, no count, no velocity. The cost lands on the index, and it is the entire reason the newer versions exist; details in "where it bites" below.

UUIDv7, time-ordered random. The most significant 48 bits hold a Unix timestamp in milliseconds; the remaining 74 usable bits are random. Rows sort by creation time, so inserts cluster at the end of the index like a bigserial, while a single ID still reveals nothing about its neighbors. The honest trade: the timestamp is visible, so every ID leaks when the record was created.

Prefixed opaque string. Stripe's pattern: cus_, ch_, pi_ followed by random characters. Self-describing in logs and dashboards, unguessable, and documented as opaque: their versioning policy reserves the right to change "the length or format of opaque strings, such as object IDs," warns that IDs "can contain up to 255 characters," and treats even the ch_ prefix as removable. The prefix is a debugging aid, not a contract.

The mechanism

The estimator is six lines. Run it in Node:

// order ids captured from a shop's public invoices over a month
const observed = [2041, 2187, 2403, 2519];
const m = Math.max(...observed);
const k = observed.length;
console.log("max seen:", m);                       // 2519
console.log("est. total orders:", m + m / k - 1);  // 3147.75

// one more sample arrives
console.log("new estimate:", 2999 + 2999 / 5 - 1); // 3597.8

m + m/k - 1 is the minimum-variance unbiased estimator for a uniform sequence sampled without replacement: the max you observed, plus the average gap, adjusted for sampling. This is not a toy. Ruggles and Brodie's 1947 paper describes how the Economic Warfare Division "started to analyze markings and serial numbers obtained from captured German equipment" to estimate production, and the published table is brutal for conventional intelligence: June 1940, statistical estimate 169 versus actual 122 while spies reported 1,000. June 1941, 244 versus 271 actual. August 1942, 327 versus 342 actual. The serial numbers were more accurate than the intelligence network by an order of magnitude.

The fix is to stop publishing the counter. This is what the alternatives actually contain:

UUIDV4
4 version bits + 2 variant bits + 122 random
no order anywhere: each insert lands on a

random b-tree page
UUIDV7
48-bit unix-ms timestamp + 4 + 2 + 74 random
sorts as raw bytes; new inserts cluster
UUIDV1
60-bit timestamp + mac address as node
leaks the host that minted it; do not use
journal/sequential-ids-leak
fig.02 · the keyspace an attacker sees: a range they can walk, or a field where guesses land nowhere
fig.02 · the keyspace an attacker sees: a range they can walk, or a field where guesses land nowhere

Where it bites

Opaque is not authorization. This is the failure mode that matters most, so it goes first. OWASP's API1:2023, Broken Object Level Authorization, the top of their API list, states that "object IDs can be anything from sequential integers, UUIDs, or generic strings." Their own prevention list puts unpredictable IDs third, behind authorization checks that run on every request. An attacker cannot enumerate /invoice/x9f2... at scale, but a logged-in user who finds one valid UUID still gets the record if you never checked ownership. RFC 9562's security section says it in normative language: UUIDs "MUST NOT be used as security capabilities (identifiers whose mere possession grants access)." Opaque IDs stop enumeration. They do not check permissions.

Randomness costs index locality. RFC 9562's motivation section admits it plainly: versions that are not time-ordered, "such as UUIDv4, have poor database-index locality... they require inserts to be performed at random locations," and the effect on B-trees "can be dramatic." Section 6.11 quantifies it: locality versus random inserts "can be one order of magnitude or more." MariaDB's own docs are blunter about the wall: once the index exceeds cache, "most INSERTs involve a disk hit. Even on a beefy system, this limits you to a few hundred INSERTs per second." UUIDv7 exists to fix exactly this: per the spec, v6 and v7 "sort as opaque raw bytes," so the index behaves like it does under bigserial.

v7 leaks creation time. The same spec, section 8: "Timestamps embedded in the UUID do pose a very small attack surface. The timestamp in conjunction with an embedded counter does signal the order of creation." Small, but real: every v7 ID says when its row was born. If creation time is itself sensitive, the spec's answer is v4: "if UUIDs are required for use with any security operation within an application context in any shape or form, then UUIDv4 SHOULD be utilized." And watch the generator: section 6.2 warns that incrementing a per-tick counter by 1 "SHOULD NOT be used by implementations that favor unguessability, as the resulting values are easily guessable." Use a library that seeds with a random start or fills rand_b randomly.

v1 is a privacy bug by design. RFC 9562's motivation list calls out that a MAC address in the node field "can be used as an attack surface to locate network interfaces" and that containerization broke its uniqueness anyway. There is no modern reason to mint v1.

The numbers

  • UUIDv4 carries 122 random bits; UUIDv7 carries 74, after the 48-bit timestamp and fixed bits.
  • Postgres stores uuid as a 128-bit quantity; store the text form instead and, per RFC 9562 section 6.13, you spend "288 bits to represent 128-bit UUID values."
  • The German-tank estimator needed four numbers, not insiders: statistical estimates of German tank production missed reality by an average of about 30 across three published months (worst case, 47), while traditional intelligence overshot by a factor of five to eight.
  • Stripe IDs are opaque strings that "can contain up to 255 characters," a documented reminder that an identifier's format is the vendor's to change.

Our position

Every entity that can appear in a URL or API payload gets two identifiers: an internal bigint primary key that never leaves the database, and a public opaque ID, UUIDv7 by default because our indexes are real, UUIDv4 when creation time itself is a secret. Encoding a counter does not count, and neither does treating the opaque ID as permission. Authorization is checked against the session, every request, regardless of how unguessable the keyspace is.

The takeaway

LEAKS
sequential ids publish count + growth; 4 samples
estimate volume with m + m/k - 1, and the whole

keyspace is enumerable
PATTERN
internal pk stays bigint and private; public id
is opaque and separate. never expose the counter
PICK
uuidv7 default: sorts as bytes, 74 random bits
uuidv4 when created_at is itself sensitive
AVOID
uuidv1: embeds mac + timestamp
encoded counters: obfuscation, not opacity
AUTHZ
opaque ids stop enumeration, not idor; check
object ownership on every request anyway
STORAGE
native uuid column (128 bits); text form costs
288 bits per id. don't varchar(255) a guess either

This is the kind of default we set in the data model on day one of a custom software build, because retrofitting public IDs means touching every URL your users have ever bookmarked. If your API serves sequential IDs and you want the migration scoped, send us the schema.

// next step

We work this way on client projects too.

Tell us what you are making. We reply with a plan, a timeline, and a price.

← the journal