A fintech startup pushed a dashboard update on Friday afternoon. By Monday morning, premium users could see transaction data for other users by changing a single URL parameter. 400+ accounts exposed before they rolled back.
The authorization code looked fine. They had role checks. They logged everything. But they forgot something stupidly simple that breaks 60% of REST APIs.
Broken Access Control Sits at #1 for a Reason
The 2021 OWASP Top 10 moved this from #5 to #1. Not because it's harder to exploit (it's trivial), but because it's everywhere.
Authorization failures happen in three flavors:
- Vertical: Regular users accessing admin endpoints
- Horizontal: User A reading User B's data
- Context-dependent: Valid access at the wrong time/state
Most teams nail vertical. Block /admin routes, check isAdmin flags, done. Horizontal access is where production databases leak.
The "Assume Ownership" Bug
Check this Express.js route that looks totally reasonable:
app.get('/api/orders/:orderId', authenticate, async (req, res) => {
// User is authenticated, JWT is valid
const order = await db.orders.findById(req.params.orderId);
if (!order) {
return res.status(404).json({ error: 'Not found' });
}
// Ship it
return res.json(order);
});
This code authenticated the request. It validates the order exists.
It never checks if the authenticated user owns that order.
Anyone can hit /api/orders/12345 and get someone else's order if they know the ID. Order IDs are usually sequential integers or guessable UUIDs if you scrape enough of them.
Why This Happens So Much
Three reasons:
1. Authentication middleware creates false security. Passport.js or similar puts req.user in scope. Devs see user context and assume ownership is handled. It's not.
2. Testing focuses on happy paths. Tests check "can I get MY order" but rarely "can I get SOMEONE ELSE'S order".
3. Frontend hides the issue. The UI only shows your orders, so you never try accessing others. Attackers don't use your UI.
RBAC Doesn't Save You
Role-Based Access Control handles vertical privilege escalation fine. Admin vs user roles, different permission sets, works great.
It doesn't handle horizontal authorization at all. Two users with identical roles shouldn't see each other's data, but RBAC has no concept of "data ownership."
Production example from an e-commerce platform:
// Middleware checks role
function requireRole(role) {
return (req, res, next) => {
if (req.user.role !== role) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
// Route uses role check
app.put('/api/profile/:userId', requireRole('customer'), async (req, res) => {
// Any customer can update any customer profile
await db.users.update(req.params.userId, req.body);
res.json({ success: true });
});
The role check passes because the attacker IS a customer. They're just editing the wrong customer's profile.
What Actually Works
Ownership checks need to happen at the data layer, not the route layer:
app.get('/api/orders/:orderId', authenticate, async (req, res) => {
const order = await db.orders.findOne({
id: req.params.orderId,
userId: req.user.id // Enforce ownership in query
});
if (!order) {
// Don't leak whether order exists for someone else
return res.status(404).json({ error: 'Not found' });
}
return res.json(order);
});
Better yet, use scoped queries by default:
class OrderRepository {
async findByIdForUser(orderId, userId) {
return this.db.orders.findOne({
id: orderId,
userId: userId
});
}
// No raw findById that ignores ownership
}
This forces developers to think about ownership at query time. Can't forget what you can't call.
Multi-Tenant Apps Are Worse
SaaS apps have an extra layer: tenant isolation. Not just "does this user own this resource" but "does this resource belong to this tenant."
Leaked Zendesk tickets across organizations. Exposed Slack messages between companies. These bugs are horizontal access control failures at the tenant level.
Fix: scope EVERY query by tenant ID from the JWT/session:
app.get('/api/tickets/:id', authenticate, async (req, res) => {
const ticket = await db.tickets.findOne({
id: req.params.id,
tenantId: req.user.tenantId, // From JWT
userId: req.user.id
});
return ticket ? res.json(ticket) : res.status(404).json({});
});
Some ORMs support tenant scoping globally. Use it.
Indirect Object References Don't Help
Replacing /api/orders/12345 with /api/orders/a7f3b9c2 random tokens feels safer. It's not.
Burp Suite or similar tools scrape hundreds of IDs from responses. Feed them into Intruder. Test if any return data they shouldn't.
Obscurity slows attackers down by 30 seconds. Proper authorization checks stop them completely.
Context-Dependent Access Is Sneaky
Sometimes access is valid but the timing is wrong:
- Editing an order after it shipped
- Canceling a subscription after the billing cycle closed
- Updating a document after it was marked final
State machines help here. Check both ownership AND current state:
app.post('/api/orders/:id/cancel', authenticate, async (req, res) => {
const order = await db.orders.findOne({
id: req.params.id,
userId: req.user.id
});
if (!order) {
return res.status(404).json({ error: 'Not found' });
}
// Check state before allowing cancellation
if (!['pending', 'confirmed'].includes(order.status)) {
return res.status(400).json({
error: 'Cannot cancel order in current state'
});
}
await order.cancel();
res.json({ success: true });
});
Testing for Access Control Bugs
Manual testing misses most of this. You need to test with multiple users and check cross-user access attempts systematically.
OWASP ZAP has an access control testing add-on. Configure it with two user sessions (low-privilege and high-privilege) and let it spider the API. It'll flag endpoints where User A can access User B's resources.
Static analysis catches some patterns. ScanMyCode.dev's security audit flags routes that use URL parameters for resource IDs without corresponding ownership checks in the query logic.
Don't Trust Client-Side Checks
Frontend devs hide buttons users shouldn't click. That's UX, not security.
If the backend allows PUT /api/users/:id/promote-to-admin and checks req.user.isAdmin, someone WILL call it directly. They don't need your React app's permission.
Every endpoint must enforce authorization server-side, even if the UI "shouldn't" allow access. Because attackers ignore the UI.
GraphQL Makes This Harder
REST has discrete endpoints you can audit. GraphQL lets clients request arbitrary nested data.
query {
user(id: 123) {
email
orders {
id
items {
product {
internalCost # Oops
}
}
}
}
}
Authorization in GraphQL needs to happen at the resolver level, not the root query. Check ownership for EVERY field that touches sensitive data.
Most teams slap @auth decorators on root queries and call it done. Nested resolvers leak data.
Automated Security Scanning
Manual code review catches obvious cases. Automated tools find the sneaky ones buried in nested routes and service layers.
ScanMyCode.dev's security audit specifically checks for:
- Route handlers that use ID parameters without ownership validation
- Database queries missing tenant/user scoping
- Authorization logic that only checks roles, not resource ownership
- GraphQL resolvers lacking field-level authorization
The report shows exact file and line numbers, plus recommended fixes.
Ship Authorization, Not Hope
Broken access control isn't subtle or hard to fix. Scope queries by user/tenant. Check ownership before mutations. Test with multiple user contexts.
Don't wait for user data to leak. Run a security audit and get the full report in 24 hours.