Skip to main content

Broken access control is still number one, and 2025 showed why

· 8 min read
Vulkro
Security research

The most damaging bug in web software is also the least interesting to describe. A request asks for record 1041. The server checks that you are logged in, loads record 1041 and returns it. It never asks whether record 1041 is yours. Change the number and you get your neighbour's.

That is broken object-level authorization, also called an insecure direct object reference or IDOR. The OWASP Top 10:2025 keeps broken access control at number one, and says that "100% of the applications tested were found to have some form of broken access control." Among the weaknesses it maps there is CWE-639, authorization bypass through a user-controlled key: the changed number.

What it looked like in 2025​

A hiring platform and a sequential id​

In June 2025 two researchers looked at the platform a large fast-food chain used to screen job applicants. As they describe it, a test administrator account accepted 123456 as both username and password. Once inside, an API call, PUT /api/lead/cem-xhr, took a lead_id. Their own test applicant had an id around 64 million. Decrementing it returned other applicants' names, email addresses, phone numbers and addresses, plus tokens that opened their chat histories. The researchers estimated up to 64 million records were reachable.

The vendor said that "five candidates in total had information viewed because of this incident, and it was only viewed by the security researchers," and the issue was closed within a day of disclosure. The default password was the way in. The missing ownership check was what turned one account into everyone.

A partner portal and a guessable key​

In May 2024 Dell notified customers that an attacker had obtained order data. The person claiming responsibility told TechCrunch that he registered partner accounts under fake company names, then "brute-forced customer service tags" at "more than 5,000 requests per minute" for nearly three weeks. TechCrunch matched names and service tags in the stolen data against real customers who had received breach notices. Dell said it was already investigating.

Treat the method as the attacker's account. The shape is still worth naming: an authenticated caller could look up records by a key, every valid key returned a customer, and nothing slowed the lookup down.

Generated apps and a missing database policy​

In May 2025 CVE-2025-48757 described sites generated by an AI app builder that queried their Supabase database straight from the browser "using a public and unprivileged anon key", and relied on Row Level Security to keep each user to their own rows. Where that policy was missing or too loose, anyone could read the tables. The researcher lists users, API keys and payment records among what was exposed. The supplier disputes the CVE, arguing each customer is responsible for securing their own data.

It is the same bug moved into the database. The query trusts the caller to ask only for what is theirs.

Salesforce: the same bug in Apex​

Salesforce has its own version, and it is the one behind the March 2026 mass scanning of public sites, which Salesforce attributed to customer-configured guest access rather than a platform flaw. An @AuraEnabled method takes a record id and runs without sharing:

public without sharing class InvoiceController {
@AuraEnabled(cacheable=true)
public static Invoice__c getInvoice(Id invoiceId) {
return [SELECT Id, Amount__c, Account__c FROM Invoice__c WHERE Id = :invoiceId];
}
}

without sharing means the record-level rules do not apply, so any user who can call the class can read any invoice by id. If the class is granted to a site's guest profile, "any user" includes anonymous visitors. We walked that path hop by hop in the anatomy of a guest user data leak. The fix is to let the platform do the check:

public with sharing class InvoiceController {
@AuraEnabled(cacheable=true)
public static Invoice__c getInvoice(Id invoiceId) {
return [SELECT Id, Amount__c, Account__c FROM Invoice__c
WHERE Id = :invoiceId WITH USER_MODE];
}
}

Why scanners miss it​

Injection has a signature: untrusted data reaches a dangerous function. Access control does not. SELECT * FROM applicants WHERE id = $1 is a perfectly safe query, parameterized and correct. The bug is a predicate that is not there: no AND owner_id = $2, no tenant filter, no sharing mode. A scanner that only looks for dangerous calls has nothing to match.

Finding it means reading three things together: where the id comes from (the request), what it reaches (a lookup or a write), and what is missing in between (any check that ties the record to the caller). Then deciding who can reach the handler at all, because a missing check on an admin-only route is a different finding from one on a public route.

What Vulkro checks​

In application code (Vulkro Core)​

Here is the hiring-platform shape in Express:

app.get('/api/applicants/:id', requireAuth, async (req, res) => {
const row = await db.query('SELECT * FROM applicants WHERE id = $1', [req.params.id]);
res.json(row.rows[0]);
});

vulkro scan reports it as idor-authn-no-ownership: the route authenticates the caller, but the query is keyed by the caller-supplied id with no ownership or tenant check. The fix is one predicate:

app.get('/api/applicants/:id', requireAuth, async (req, res) => {
const row = await db.query(
'SELECT * FROM applicants WHERE id = $1 AND owner_id = $2',
[req.params.id, req.user.id],
);
if (row.rows.length === 0) return res.sendStatus(404);
res.json(row.rows[0]);
});

The related checks cover the variations:

  • AUTHZ-001: a taint-aware cross-tenant IDOR, where the request value reaches the lookup through helpers.
  • MT-001: a tenant id read from the request body instead of the session, so any caller can name another tenant.
  • AUTHZ-005: a privileged route under /admin that takes a sequential numeric id.
  • rate-001: endpoints with no rate limiter in scope, which is what turns one guessable key into three weeks of scraping.
  • SUPA-RLS-003, SUPA-RLS-002 and SUPA-RLS-001: browser code querying Supabase tables with no Row Level Security in the repo, a service-role key in the browser, and Firebase rules that allow anything.

Each finding names the route, the lookup and the check that is missing, and vulkro prove shows whatever data-flow hops it can establish behind it. All of these are in the Free tier and run on your machine. With Pro, the local console and the VS Code extension join findings into attack paths, from every entry point to every sink across the whole application, so you can see which gaps a public route can actually reach.

In Apex (Vulkro for Salesforce)​

vulkro-sf scan reports the Apex shape above in several ways, depending on how much it can prove:

  • apex-taint-id-flow-idor: a request-supplied record id reaches SOQL or DML with no sharing or ownership check on the method or any method that dominates it.
  • broken-object-level-auth/idor: an @AuraEnabled or REST method takes a record id and queries or changes it in a class that runs without sharing.
  • apex-rest-entrypoint-without-authz: an @HttpGet to @HttpDelete method that reads or writes records from a client value with no sharing, ownership, CRUD or field-level gate.
  • sf-guest-aura-read-no-fls: a guest-reachable Aura method that returns records without field security.

When the guest profile, the class grant and the missing check line up, the findings are joined into one path from the anonymous visitor to the object. The code and metadata checks run on your project without an org connection. Checking who actually holds each profile and permission set in the live org is part of Pro.

Look for the missing check​

Access control bugs survive because they are absences, and absences do not show up in code review unless someone goes looking for them. Run the check on the repository you have open: Vulkro Core for application code, Vulkro for Salesforce for Apex and metadata. Start here.

Sources​