Your AI-scaffolded API uses sequential IDs, and that's an authorization bug
By Seaworthy · 30 August 2026
Photo by Rahul Mishra on Unsplash
Ask an AI assistant to scaffold a REST API for orders, documents, or invoices, and you'll get something close to this:
app.get('/api/orders/:id', async (req, res) => {
const order = await db.query('SELECT * FROM orders WHERE id = ?', [req.params.id])
res.json(order)
})
It works. You test it with order 42, it comes back, you move on. Nothing here checks whether the order at id 42 belongs to the person making the request, and nothing in the response, the test, or the code itself hints that a check is missing.
The bug is the missing question, not the missing code
This is an insecure direct object reference. The endpoint trusts that if you can name a resource, you're allowed to see it. IDs generated by an auto-incrementing primary key are trivial to name: order 42 implies order 41 and order 43 exist too, and a script that walks the integer range will find them all. No credentials to steal, no injection payload to craft. Just a for loop.
What makes this so common in AI-generated code specifically is that the scaffold is functionally correct. It fetches the row, it returns JSON, it matches the route the prompt asked for. An authorization check isn't a feature the endpoint is missing, it's a question the endpoint never asked: does the current user own this? Nothing about the happy-path prompt ("add a GET endpoint for orders by id") implies that question needs asking, so a model optimizing for a working demo won't add it unprompted.
Fix the query, not just the handler
app.get('/api/orders/:id', requireAuth, async (req, res) => {
const order = await db.query(
'SELECT * FROM orders WHERE id = ? AND user_id = ?',
[req.params.id, req.user.id]
)
if (!order) return res.status(404).json({ error: 'Not found' })
res.json(order)
})
The ownership check belongs in the query, not as a separate if (order.userId !== req.user.id) after the fetch. A query scoped to the current user returns nothing for someone else's order, which you then correctly report as 404. A fetch-then-check pattern often leaks the distinction between "doesn't exist" and "exists but isn't yours" through timing, error messages, or a lazy 403 that confirms the resource is real. Returning 404 for both cases is deliberate: it tells an attacker nothing about which order IDs are valid.
UUIDs help, but they're not the fix
Switching from sequential integers to UUIDs is a common reaction, and it does remove the ability to enumerate resources by guessing nearby IDs. It does not add an authorization check. A UUID that leaks in a shared link, a browser history, or a referrer header is just as readable by the wrong person as an integer, and an API that never checks ownership will happily return the resource to whoever holds the string. Unguessable IDs raise the cost of finding a resource. They don't decide who's allowed to have it.
Every resource-by-id route needs this same question answered
The same missing check shows up wherever a route takes an id from the URL and a query runs off it directly: PATCH /api/documents/:id, DELETE /api/invoices/:id, GET /api/users/:id/settings. Each one needs the same answer to the same question, scoped consistently, not reinvented per route as a bespoke if statement that's easy to forget on the next one someone adds.
Seaworthy flags database queries built from a route parameter that never join against the authenticated user's identity.
This article was generated by AI and summarises publicly available sources.
Seaworthy scans your repo for the issues covered in articles like this one.
Security gaps, exposed secrets, and misconfigurations — caught before you deploy. Free to run, no account needed.