Title: How Broken Object-Level Authorization happens in Express.js and how to fix it
In the src/v1/routes/index.js file of a budget management API, we discovered a critical authorization bypass that allowed any authenticated user to access arbitrary financial data. The vulnerability—classified as Broken Object-Level Authorization (BOLA)—stems from a classic security anti-pattern: authenticating users without authorizing their specific resource access.
This flaw in the route handler at lines 5-14 meant that simply possessing a valid API key granted unrestricted access to every budget in the system. Let's examine how this happened and how the automated fix closed this dangerous gap.
The Vulnerability Explained
The Missing Authorization Check
The vulnerable code in src/v1/routes/index.js looked like this:
router.use('/budgets/:budgetSyncId', authorizeRequest, async (req, res, next) => {
try {
res.locals.budget = await Budget(req.params.budgetSyncId, req.get('budget-encryption-password'));
next();
} catch(err) {
// error handling...
}
});
The critical flaw: The authorizeRequest middleware only validates that the API key is legitimate. It does not check whether this specific API key is allowed to access the budgetSyncId provided in the URL path parameter.
Notice how req.params.budgetSyncId flows directly into the Budget() constructor at line 8 without any authorization verification. This creates a direct path from user input to data access.
How Attackers Exploited This
An attacker with any valid API key could:
- Enumerate budgets: Try sequential or predictable
budgetSyncIdvalues (e.g.,budget-001,budget-002,personal-2024) - Access sensitive financial data: View income, expenses, account balances, and transaction history for any budget in the system
- Modify arbitrary budgets: If the route supported POST/PUT/DELETE operations, mutate others' financial records
Example attack scenario:
GET /v1/budgets/company-payroll-2024
Headers: X-API-Key: {any_valid_key}
Even with a personal API key, the attacker receives the corporate payroll budget data because no check ties the API key to permitted budget IDs.
Real-World Impact
For this budget management application, the impact is severe:
- Financial data exposure: Personal and business finances exposed to unauthorized parties
- Regulatory violations: Potential GDPR, SOC 2, or PCI-DSS compliance failures
- Data integrity risks: Unauthorized modifications to budget allocations
- Reputational damage: Loss of user trust in financial data security
The Fix
The automated fix introduces a defense-in-depth authorization layer using an environment-based allowlist. Two files were modified:
1. Configuration Addition (src/config/config.js)
exports.config = {
// ... existing config ...
allowedBudgetSyncIds: process.env.ALLOWED_BUDGET_SYNC_IDS
? process.env.ALLOWED_BUDGET_SYNC_IDS
.split(',')
.map(id => id.trim())
.filter(Boolean)
: null,
// ...
};
This adds a new allowedBudgetSyncIds array, populated from a comma-separated environment variable. The filter(Boolean) removes empty strings from malformed input.
2. Route Authorization Enforcement (src/v1/routes/index.js)
const { config } = require('../../config/config');
router.use('/budgets/:budgetSyncId', authorizeRequest, async (req, res, next) => {
try {
if (config.allowedBudgetSyncIds &&
!config.allowedBudgetSyncIds.includes(req.params.budgetSyncId)) {
res.status(403).json({"error": "Forbidden"});
return;
}
res.locals.budget = await Budget(req.params.budgetSyncId, req.get('budget-encryption-password'));
next();
} catch(err) {
// error handling...
}
});
Key security improvements:
| Aspect | Before | After |
|---|---|---|
| Authorization | None | Explicit allowlist check |
| Error response | 500 (unhandled) or data leak | Controlled 403 Forbidden |
| Configuration | Hardcoded implicit trust | Environment-driven explicit policy |
| Attack surface | Any budgetSyncId |
Only enumerated IDs |
The fix strategically places the authorization check after authentication (authorizeRequest) but before data access (Budget()). This follows the OWASP Authorization Cheat Sheet pattern of "early deny."
The config.allowedBudgetSyncIds && guard ensures backward compatibility—if the environment variable isn't set, the check is skipped (though production deployments should always configure this).
Prevention & Best Practices
For Express.js Applications
- Separate authentication from authorization: Never assume authentication implies authorization for specific resources
- Use resource-specific middleware: Create reusable authorization middleware that receives
(user, resource)and returns boolean decisions - Implement object-level checks: For every route with
:idparameters, verify the authenticated principal has access to that specific instance
Architectural Patterns
// Better pattern: database-level authorization
const budget = await Budget.findOne({
where: {
syncId: req.params.budgetSyncId,
ownerId: req.user.id // implicit authorization
}
});
if (!budget) return res.status(404).json({error: "Not found"});
Detection Tools
- Semgrep: Rules like
express-missing-authenticationand custom IDOR patterns - CodeQL: Queries for "Uncontrolled data in path expression"
- OWASP ZAP: Dynamic testing for forced browsing/BOLA
Standards & References
- CWE-639: Authorization Bypass by User-Controlled Key
- OWASP API Security Top 10 2023: API1:2023 Broken Object Level Authorization
- NIST SP 800-205: Attribute-Based Access Control (ABAC)
Key Takeaways
-
Authentication ≠ Authorization: The
authorizeRequestmiddleware name was misleading—it only handled authentication. Always verify both explicitly. -
URL parameters are untrusted:
req.params.budgetSyncIdat line 8 ofsrc/v1/routes/index.jsflowed directly to data access without validation—treat all route parameters as attacker-controlled. -
Environment-based policies enable least privilege: The
ALLOWED_BUDGET_SYNC_IDSconfiguration allows operators to enforce strict budget isolation without code changes. -
Early authorization prevents information leakage: The fix returns 403 before any database query, preventing timing attacks that might reveal valid
budgetSyncIdvalues. -
Audit your middleware chain: Review all Express middleware that runs before data access—gaps in this chain create authorization bypass opportunities.
How Orbis AppSec Detected This
Source: HTTP request parameter budgetSyncId in URL path (/budgets/:budgetSyncId)
Sink: Budget() constructor call at src/v1/routes/index.js:8 that loads budget data
Missing control: No authorization check between authorizeRequest middleware (authentication only) and the Budget() data access that would verify the API key holder is permitted to access the specific budgetSyncId
CWE: CWE-639: Authorization Bypass by User-Controlled Key
Fix: Added explicit allowlist validation using config.allowedBudgetSyncIds.includes(req.params.budgetSyncId) before the vulnerable data access, returning HTTP 403 for unauthorized requests.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
The BOLA vulnerability in src/v1/routes/index.js demonstrates how easily authorization gaps emerge when developers conflate "authenticated" with "authorized." The fix's elegant simplicity—adding a configuration-driven allowlist check—shows that robust security often requires just adding the right validation at the right point in your request pipeline.
For teams building Express.js APIs, this serves as a reminder: always ask "is this user allowed to access this specific resource?" not just "is this user legitimate?" The distinction protects your users' most sensitive data.
References
- CWE-639: Authorization Bypass by User-Controlled Key
- OWASP API Security Top 10 2023 - API1:2023 Broken Object Level Authorization
- OWASP Authorization Cheat Sheet
- Express.js Routing Documentation
- Semgrep Rule: javascript.express.security.audit.express-check-csurf-middleware-usage
- fix: the api endpoints accept a budgetsyncid paramet... in rules.js