Safeguard
Application Security•

CWE-566 User-Controlled SQL Primary Key: Cause and Fix

When a user can choose the primary key in a query, they can read rows that belong to someone else. Here is how CWE-566 works and how to fix it.

Safeguard Security Team
5 min read

CWE-566 is an authorization bug that lives in a SQL query. The application looks up a record by primary key, and the user decides which key to send. Nothing checks that the record is theirs.

You will also hear this called an insecure direct object reference. The row ID in the URL, form field or API call is trusted, and the user simply changes it. Invoice 1041 becomes invoice 1042, and now they are reading someone else's invoice.

MITRE lists the platforms as SQL and database servers. The application language can be anything.

How it happens

MITRE describes three conditions that combine:

  1. Data enters the program from an untrusted source.
  2. That data is used as the primary key value in a SQL query.
  3. The source does not have permission to access every row in the table.

All three are common. A typical web app lets a user view an order at /orders?id=1041. The handler runs SELECT * FROM orders WHERE id = ? and returns the row. The query is parameterized, so it is safe from SQL injection. But it does not ask whether order 1041 belongs to the person asking.

Here is a vulnerable handler in Python:

# Vulnerable: key comes from the request, ownership is never checked
def get_invoice(request):
    invoice_id = int(request.GET["id"])
    cur.execute("SELECT * FROM invoices WHERE id = %s", (invoice_id,))
    return cur.fetchone()

Parameterization fixes injection. It does nothing about authorization. These are separate problems, and treating one as the cure for the other is how this bug survives code review.

Sequential integer keys make exploitation trivial, since an attacker just counts upward. Random keys slow guessing but do not solve the problem, because IDs leak through logs, emails, shared links and referrers.

What an attacker gains

MITRE lists confidentiality, integrity and access control consequences: reading application data, modifying application data and bypassing a protection mechanism.

  • Read other users' records. Orders, invoices, messages, medical or financial data.
  • Modify or delete them. If the same ID drives an update or delete endpoint, the attacker can change records they do not own.
  • Scale it. A script can walk every ID in minutes and export the whole table.

The impact is determined by what the table holds and how widely the ID pattern is reused across your endpoints.

How to find it

MITRE's detection entry says automated static analysis can find some instances. Static tools are weaker here than for injection, because the code is syntactically fine. The flaw is a missing check, and missing things are hard to see.

Better practice is to test behavior:

  • Two account test. Log in as user A, record the IDs of user A's objects. Log in as user B and request those IDs. Any success is a bug.
  • Enumerate every route that takes an ID. Query strings, path parameters, JSON bodies, headers. Include update and delete routes, not only reads.
  • Dynamic and API scanning. Tools that replay requests with substituted IDs can catch many cases.
  • Code review focus. For each query that filters on a key from the request, ask where the ownership condition is.

How to fix it

MITRE's mitigations are about validating input and using parameterized queries, and then making sure accepted values conform to business rules. The business rule that matters here is ownership.

  1. Assume all input is malicious. Validate length, type, syntax and business rules, and use an accept known good approach.
  2. Use a parameterized query and enforce business rules. The accepted key must be one this user is allowed to touch.
  3. Put the owner in the query. Do not fetch by ID and check later. Fetch by ID and owner together, so a record you may not see simply does not come back.
  4. Derive the scope from the session. The user ID comes from the authenticated session, never from a request field.
# Fixed: the owner is part of the query
def get_invoice(request):
    invoice_id = int(request.GET["id"])
    user_id = request.session["user_id"]
    cur.execute(
        "SELECT * FROM invoices WHERE id = %s AND owner_id = %s",
        (invoice_id, user_id),
    )
    row = cur.fetchone()
    if row is None:
        raise Http404()      # same response for missing and forbidden
    return row

Returning the same not found response for both cases avoids telling an attacker which IDs exist.

Where your database supports it, row level security can enforce the same rule below the application, so one missed WHERE clause does not expose the table. Add automated tests for the two account scenario to your CI, and run them for every new endpoint.

Authorization bugs also live in the libraries and frameworks you use. Knowing what you ship helps. The free scan at gold.safeguard.sh/scan lists your dependencies in the browser with no login, keeps the file on your machine, and supports up to 100 dependencies.

Try it yourself

Click through it at your own pace, or open it full screen.

Quick check

If my query is parameterized, am I safe from this?

No. Parameterization stops SQL injection. It does not check whether the requested row belongs to the caller. You need an ownership condition as well.

Do random IDs fix CWE-566?

They make guessing harder, but IDs leak through links, logs and emails. The server still has to check access on every request.

Where should the user identity come from?

From the authenticated session on the server, not from a field the client sends.

Sources

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.