Example finding How it works Coverage PENTEST METHODOLOGY DOCS PRICING FAQ MCP CONTACT LOG IN SIGN UP →

Sequential IDs security risk: what to fix first

FIX / SEQUENTIAL ID ENUMERATION / 9 MIN READ

Sequential IDs are a security risk only when the server skips the owner check. Add the check first. Then replace public IDs with random UUIDs. An ID that counts up (1, 2, 3) makes guessing easy. The missing check makes guessing useful.

This guide separates the two problems. It shows how to test your app, how to fix the check, how to change the IDs, and how to slow down enumeration.

What is a sequential ID, and what is enumeration?

A sequential ID is a number that the database increases for each new row. The first row gets 1. The next row gets 2. Many tools use this by default for the primary key.

Enumeration means that a caller tries many IDs in a row. The caller asks for /api/orders/1, then /api/orders/2, and so on. A script can do this fast. If the server answers with the record each time, the caller can read the whole table.

Two different problems exist here:

Problem What it is Is the ID type the cause?
Broken access control The server returns a record to a user who does not own it No. The missing check is the cause
Information disclosure The ID shows how many records exist or how fast they grow Yes. The ID type is the cause

The first problem is serious. The security community calls it broken object level authorization (BOLA) or insecure direct object reference (IDOR). The OWASP API Security list places it first. It describes a failure to check access on each request that uses an ID from the client.

The second problem is smaller. A new competitor can see your order numbers in a receipt, and then estimate your sales. A user can see that they are user number 12. This is not a data breach, but it can reveal facts that you want to keep private.

Why are sequential IDs a problem in AI-generated apps?

AI coding tools build a feature in small steps. A common prompt is “show the details of an order”. The tool writes a route that reads the order by ID. The route works in the first test. The tool often does not add a check that the order belongs to the user.

The ID type makes this mistake easy to exploit. If the IDs are random, a caller must first learn a valid ID. If the IDs are numbers that count up, a caller only needs to change the last digit.

For a longer look at this pattern, read BOLA in AI-generated CRUD code. This article stays with the ID question.

How do I test if my IDs can be enumerated?

Use two test accounts. Do not request records of real users.

  1. Sign in as account A. Create an order, a note, or another record.
  2. Write down the ID of the record. You see it in the URL or in the network tab of DevTools.
  3. Sign in as account B in a different browser profile.
  4. Copy a request from account B in DevTools as curl.
  5. Replace the ID in the request with the ID from account A.
  6. Send the request.

If you receive the record of account A, the server has no owner check. This is the serious problem. Fix it before anything else.

Warning: Test only your own app and your own test accounts. Do not run a scan of IDs against records that belong to real people.

You can also test with a short script. This script uses your own test account and a small range of IDs that you created yourself:

for id in 101 102 103 104 105; do
  code=$(curl -s -o /dev/null -w "%{http_code}" \
    "https://your-app.example/api/orders/$id" \
    -H "Authorization: Bearer TOKEN_OF_ACCOUNT_B")
  echo "$id $code"
done

Read the status codes. A 200 for an order that account B does not own means the leak exists. A 404 for every ID that account B does not own is the correct result.

Also look at the difference between 403 and 404. A 403 says that the record exists but you cannot read it. A 404 says nothing. The OWASP IDOR cheat sheet advises one public response for a missing record and a forbidden record. Use this when the existence of the record is sensitive.

How do I fix the missing owner check?

The owner check is the fix that matters. It works with any ID type. Do it first.

Caution: If you change IDs to UUIDs and skip the owner check, the leak stays. The app only becomes harder to attack. A leaked or shared UUID opens the record.

Option A: an owner condition in the query

Put the owner in the query. Do not load the record first and compare after.

const user = await getUserFromSession(req); // your auth check
if (!user) return res.status(401).end();

const order = await db.query(
  "select * from orders where id = $1 and user_id = $2",
  [req.params.id, user.id],
);
if (!order.rows[0]) return res.status(404).end();
return res.json(order.rows[0]);

Take the owner from the session on the server. Never take it from the URL or from the request body. Apply the same rule to the routes that update, delete, and list records.

Option B: Row Level Security in Supabase

If your app calls the Supabase API from the browser, put the rule in the database. The syntax follows the Supabase RLS documentation:

alter table public.orders enable row level security;

create policy "owner can read" on public.orders
  for select to authenticated
  using ( (select auth.uid()) = user_id );

Add a matching policy for insert, update, and delete. For the full set and the common errors, read the Supabase RLS guide.

After the fix, repeat the test with the two accounts. Account B must receive a 404 or an empty result for every ID of account A.

Should I switch to UUIDs?

Yes, as a second layer. The owner check is the lock. A random ID is a second lock that makes a scan of IDs much harder. The OWASP BOLA page advises that you prefer random and unpredictable values as GUIDs for record IDs. The IDOR cheat sheet states that random identifiers are a defense in depth and that access control checks stay essential.

PostgreSQL has a built-in function for this. According to the PostgreSQL documentation, gen_random_uuid() generates a version 4 (random) UUID.

You have two ways to change your IDs.

Way What you do When to choose it
Keep the number key, add a public ID Add a public_id column. Use it in URLs and API calls. You have many tables, many links between tables, or live data
Change the primary key to a UUID Change the key column and every foreign key You build a new table, or the table is small

For most apps with live data, the first way is simpler. It does not touch the foreign keys.

How do I add a public ID to an existing table?

Run this SQL in the SQL Editor. The default value fills each existing row with its own random value:

alter table public.orders
  add column public_id uuid not null default gen_random_uuid();

create unique index orders_public_id_key on public.orders (public_id);

Then change your routes and your pages. The URL uses public_id. The query uses public_id and the owner:

const order = await db.query(
  "select id, public_id, total from orders where public_id = $1 and user_id = $2",
  [req.params.publicId, user.id],
);

Follow these steps:

  1. Add the column and the unique index.
  2. Change the API routes to read public_id from the URL.
  3. Change the links in the frontend to use public_id.
  4. Stop sending the number id to the browser. Remove it from the API responses.
  5. Keep the old route for a short time if old links exist. Make the old route return 404 after that time.
  6. Run the two-account test again.

Step 4 matters. If the API still returns the number, the old route still works, and the ID problem stays.

Which UUID version should I use?

Version 4 is the safe default, because it is random. PostgreSQL 18 also has uuidv7(), which makes time-ordered UUIDs. The documentation states that the value holds a timestamp and random data. A time-ordered UUID leaks the creation time of the record. If you use a UUID as a secret, use version 4.

How do I slow down enumeration?

Rate limits do not replace the owner check. They make a scan of IDs slow and easy to see. Add them after the first two fixes.

  1. Limit requests per user. Use a counter in your database or a rate-limit service. Set a low limit on routes that read a record by ID.
  2. Limit requests per address. This helps against callers who create many accounts.
  3. Count the 404 answers. A caller who gets many 404 answers in a short time tries IDs. Block or slow that caller.
  4. Return the same answer for missing and forbidden records. Use 404 for both. The caller then cannot tell which IDs exist.
  5. Add pagination limits. A list route must return a small page. It must not return the whole table.

See API abuse protection for more patterns. For the wider rules on who can do what, see authorization patterns.

What other places show sequential numbers?

IDs in URLs are the obvious place. Check the other places too.

  • Order and invoice numbers. A customer sees the number on a receipt. Add a separate random reference for links, or keep the number but require a signed-in owner.
  • Usernames and profile links. A link like /user/42 invites a scan. Use a slug or a random public ID.
  • File names. A file at /uploads/1.pdf is easy to guess. Use random file names, and check the owner before you serve the file.
  • Password reset and invite tokens. A token must never count up. Use a long random value from a secure random function.
  • Error messages. A message like “user 45 not found” tells a caller which IDs exist. Use one generic message.

A reset token is a different case from a record ID. A token is a secret, so it must be unpredictable. A record ID only needs to be hard to guess as a second layer, because the owner check is the main control.

How do I check if someone already enumerated my IDs?

Read your logs for the time since the route went live. Look for these signs.

  1. Many requests from one client. Look for one user or one address that sent hundreds of requests to the same route.
  2. IDs in order. Look for requests with IDs that step up by one: 101, 102, 103.
  3. Many 404 or 403 answers. A scan of IDs creates many failed requests, mixed with a few successes.
  4. Successful reads of other users’ records. If you log the user and the record owner, compare them. A mismatch with a 200 answer is a confirmed leak.

If you use Supabase, open Logs in the dashboard. The Supabase logs documentation states that the explorer queries Postgres and API Gateway logs by default, and that retention depends on your plan. If your logs are short, you cannot rule out a scan.

If the route returned personal data, write down which route, which dates, and which fields. You may have a duty to tell the affected users.

How do I prevent this in new features?

  1. Write the owner check in the same change as the route. A route without the check is not finished.
  2. Use a random public ID from the start. Give each new table a public_id uuid default gen_random_uuid().
  3. Do not return internal IDs. Return only the fields that the page needs.
  4. Write a two-account test for each route. The test reads, changes, and deletes the record of account A with account B. All calls must fail.
  5. Tell your coding tool the rule. Add to the project rules: “Every route that reads a record by ID also checks the owner.”
  6. Review each new route. Look for an ID from the URL that goes straight into a query.

When should I get help?

Get help in these cases:

  • Your test shows that account B can read records of account A, and the table holds personal or payment data.
  • The logs show a client that stepped through many IDs and received successful answers.
  • You have many routes and you cannot say which ones check the owner.
  • You use numbers in password reset links or invite links.
  • You must change the primary key of a large live table and cannot afford downtime.

After the fix, send the request for a record of account A with the token of account B. The answer must be 404. Then check that the API no longer returns the number ID.

Common questions

Are sequential IDs insecure?
Not by themselves. They are a risk when the server returns a record without a check of the owner. They also reveal how many records exist.
Do UUIDs fix the problem?
No. A UUID makes the ids hard to guess, but a leaked or shared UUID still opens the record. The owner check is the real fix.
Should I change my primary key to a UUID?
You do not have to. You can keep the number as the primary key and add a random public id for URLs and API calls.
Can rate limits stop enumeration?
They slow it down. A limit per user and per address makes a long scan of ids expensive, but it does not replace the owner check.
Does this apply to order numbers and invoice numbers?
Yes. A number that counts up lets a visitor guess the next one. Add the owner check and consider a random public reference.

Test your ID endpoints

A VibeEval scan changes the id in the requests of your deployed app and checks if the server returns records that belong to another user.

14-day free trial · No credit card · Cancel anytime

SCAN YOUR APP →