Predictable object IDs let a stranger walk your API one record at a time
The concrete risk is simple to state. If a public API response contains an object ID like 1041, and the record before it lives at 1040 and the next at 1042, then anyone who sees one ID can guess the rest. They request 1, then 2, then 3, and keep going. Nothing is decrypted and nothing is brute-forced. The IDs are just countable, so the whole collection becomes walkable from a single starting point.
Separate what is observable from what is only possible. Observable: open a response in DevTools and read the id field. If it is a small, sequential integer, that is a fact you can confirm in seconds. What is only possible is the impact, and that depends entirely on a separate control. If the endpoint requires the right ownership check, requesting another user's ID returns a 403 or an empty result, and predictable IDs are merely untidy. If the endpoint returns whatever ID you ask for without checking who you are, predictable IDs turn one leaked record into the entire table.
That second case is the one to worry about. In the OWASP taxonomy it is Broken Object Level Authorization, often called BOLA or IDOR, and predictable IDs are what make it cheap to exploit at scale. The ID format is the amplifier; the missing ownership check is the actual hole.
- Predictable IDs alone are an information signal, not a breach.
- Predictable IDs plus a missing per-record authorization check is the combination that leaks data in bulk.
- This is an authorization question (can this caller read this specific record), not an authentication question (is the caller logged in at all). A logged-in user can still enumerate other users' records if the server never checks ownership.
Why AI-built apps ship sequential IDs by default
The default comes straight from the database. A generated schema usually gives each table an auto-incrementing integer primary key, because that is the simplest thing that works and the most common pattern in tutorials the model trained on. In raw SQL it is a SERIAL or an IDENTITY column. In Prisma it is the Int @id @default(autoincrement()) field you get without asking. In Supabase the table wizard offers an int8 identity column as the standard primary key. Each of these produces 1, 2, 3, and each of these lands in your API response when the generated CRUD route returns the row as-is.
The second half of the problem is that the generated read route often filters by ID alone. A prompt like "add an endpoint to fetch an order by id" produces a handler that looks up the order by its primary key and returns it. It works perfectly in the demo, because the developer is logged in as the one user who owns that order. The missing line is the one that also checks the order belongs to the calling user. Nothing in the happy path reveals its absence, so the route looks finished.
- The integer key is the framework default, not a decision anyone made about exposure.
- The fetch-by-id route works in testing because the tester owns every record they try.
- Fast deploy means the public URL is live before anyone reviews whether each endpoint checks record ownership, not just record existence.
Check your own API responses before launch
You can confirm both halves of this on your own app without attacking anything. Stay on your own domain and your own accounts.
First, read the ID format. Open your app, go to DevTools and the Network tab, and trigger a normal action that loads data: open an order, a profile, a document. Look at the JSON the API returns and find the identifier the URL or payload uses. A small integer that goes up by one each time you create a record is the predictable case. A long random string, a version-4 UUID, or an opaque slug is the safer case.
Second, test the authorization check, which matters more than the format. Create two of your own accounts, A and B. Logged in as A, note the ID of one of A's records. Now, still as A, request B's record by its ID, by editing the ID in the URL or the API call. The result tells you which world you are in.
- Suspicious result: you get B's data back while authenticated as A, or while not logged in at all. The endpoint trusts the ID and skips the ownership check. Predictable IDs make this trivially walkable.
- Safer result: you get a 403, a 404, or an empty response when asking for a record you do not own. The server is checking ownership, so the ID format is a minor concern.
- Check more than one endpoint. Authorization is enforced per route, so a protected order endpoint tells you nothing about the invoice or message endpoint next to it. Check unauthenticated calls too, where a missing session is the cleanest test of whether the route protects itself at all.
Switch to opaque IDs, and fix the check underneath
There are two changes here, and the order matters. The authorization check is the real fix; the ID format reduces how easily the gap is exploited.
Add the ownership check first. Every read, update, and delete route that takes an ID should verify the record belongs to the calling user (or their tenant, or their allowed role) before returning anything. In a Supabase setup this is Row Level Security scoping rows to the authenticated user, so PostgREST never returns another user's row regardless of the ID asked for. In Prisma or raw SQL it is a WHERE clause that includes the owner column, not just the primary key, so a lookup for someone else's ID returns nothing. This check is what closes the hole.
Then make IDs hard to guess, which limits damage if a check is ever missed. Switch user-facing identifiers to version-4 UUIDs, or to opaque random slugs generated with a cryptographically secure source. In Prisma that is @default(uuid()) or a cuid; in Postgres it is a uuid column defaulting to gen_random_uuid(); in Supabase you select uuid as the primary key type. You can keep the internal integer key for joins and performance and expose only the random one. Random IDs mean an attacker cannot count their way through your records even if a route forgets to check ownership.
- The ownership check is the control that actually denies access; do this on every route, not just the obvious ones.
- Opaque IDs are defense in depth, not a substitute for the check. A UUID on an unprotected endpoint is still readable by anyone who has the link.
- Rate limiting and pagination caps slow bulk scraping but do not stop a determined caller who already has valid IDs; treat them as friction, not a fix.
Switching to UUIDs does not make an endpoint private. It only removes the ability to guess the next ID. If the route returns any record to any caller who supplies a valid ID, the data still leaks through shared links, referrer logs, and anyone who legitimately saw one ID. Authorization on the server is the boundary; the ID format only changes the cost of finding the IDs.
Where VibeCodeGuard fits, and what it does not prove
VibeCodeGuard's Launch Check inspects your public API responses from the outside, and it can surface sequential integer IDs sitting in JSON payloads. When a response exposes countable IDs, the scan flags that as an enumeration signal with severity and fix direction, because it is one of the patterns an outside visitor or crawler can read directly. It is pointing at the observable half: the ID format that makes walking your data cheap.
What it cannot do is prove the authorization check is missing. Confirming whether your server enforces per-record ownership requires testing the actual two-account flow described above, or reading the route code, and that is manual API testing or code review, not something a public-surface scan decides for you. The scan sees the shape of your IDs; it does not log in as two users and compare what each one can reach.
A flagged integer ID is a prompt to verify the ownership check, not proof your data is exposed; a clean scan with random IDs is not proof every route checks ownership. The enumeration signal and the authorization gap are two separate findings, and only your own access test confirms the second one. Next step: run the two-account check on your highest-value endpoint, and run a focused Launch Check before sharing the URL publicly so predictable IDs show up in your scan, not in someone else's traffic.
> launch check
Scan the public surface before launch.
Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.