CWE-639, Authorization Bypass Through User-Controlled Key, is the weakness behind most insecure direct object reference (IDOR) bugs. The system does not stop one user from reaching another user's data by changing the key that identifies the record. It hits any application that looks up records by an ID the client supplies, which is nearly all of them.
How it happens
Picture an invoice page at /invoices/1042. The server takes 1042 from the URL, fetches that invoice and renders it. The user is logged in, so the request passes authentication. But nothing asks whether invoice 1042 belongs to this user.
MITRE describes it this way: retrieval of a user record occurs based on a key value under user control, typically identifying a record related to the user. The attacker usually has to be an authenticated user, but the authorization step does not check that this user is entitled to this data. That bypasses any other authorization checks present.
# Vulnerable: authenticated, but never authorized for this record
@app.get("/invoices/<int:invoice_id>")
@login_required
def invoice(invoice_id):
return Invoice.query.get_or_404(invoice_id).to_json()
# Fixed: the lookup itself is scoped to the owner
@app.get("/invoices/<int:invoice_id>")
@login_required
def invoice(invoice_id):
inv = Invoice.query.filter_by(id=invoice_id, owner_id=current_user.id).first_or_404()
return inv.to_json()
The key does not have to be a number in a URL. It can sit in a form field, a cookie, a JSON body, a header or a file name. MITRE's observed example, CVE-2021-36539, is an educational application that did not restrict file IDs to a particular user, so an attacker could brute-force guess IDs.
A common but wrong belief is that random or long identifiers fix this. They make guessing harder, but they do not make the check exist. If an ID leaks through a shared link, a log or an email, the lack of authorization is still there.
What an attacker gains
MITRE lists three consequences:
- Access control checks for specific user data or functionality can be bypassed.
- Horizontal escalation of privilege: one user can view or modify information belonging to another.
- Vertical escalation of privilege, if the user-controlled key is actually a flag that indicates administrator status.
The third one surprises people. A hidden field such as role=user or is_admin=0 is also a user-controlled key into a privilege table. Changing it is the same bug.
Because the attacker can often enumerate keys, the impact scales. One missing check turns into every record in the table.
How to find it
MITRE lists automated static analysis (SAST) as a way to find some instances, by modeling data flow from request parameters into lookups. It helps most when the code shows a lookup by ID with no ownership condition.
Manual and dynamic testing is the most reliable method for this weakness, because the flaw is about intent, not syntax:
- Create two test accounts, A and B.
- As A, record the IDs of A's records from requests.
- Log in as B and request A's IDs. Try read, update and delete.
- Repeat for every endpoint that takes an identifier, including file downloads, exports, API calls and attachments.
- Try swapping role or ownership fields in request bodies.
In code review, search for lookups like get(id) or find(params[:id]) and ask where the ownership condition is.
Click through it at your own pace, or open it full screen.
How to fix it
From MITRE's mitigations:
- For each and every data access, ensure that the user has sufficient privilege to access the record being requested.
- Make sure the key used to look up a specific user's record is not controllable externally by the user, or that any tampering can be detected.
- Use encryption to make other legitimate key values harder to guess, or associate a digital signature with the key so the server can verify it has not been tampered with.
In practice, the first item does most of the work. Put authorization in one place, close to the data layer, so that developers cannot forget it:
- Scope queries by the current user or tenant by default.
- Return the same response for "not found" and "not yours", so attackers cannot learn which IDs exist.
- Keep roles and ownership on the server. Never accept them from the request.
- Add automated tests that call each endpoint as the wrong user and expect a denial.
Signed or encrypted keys, the third item, are a layer on top. They make guessing hard, but they should not replace the entitlement check.
Quick check
Are random IDs enough to prevent this weakness?
No. They make guessing harder, but the server still needs to check that the user may access the record. An ID that leaks through a link or log would still work.
How can a user-controlled key lead to vertical escalation?
If the key is really a flag that indicates administrator status, changing it grants administrative access.
What is the most reliable way to test for it?
Use two accounts and request one account's record identifiers from the other, across every endpoint that takes an ID.