Broken access control has been the #1 category in the OWASP Top 10 since 2021, and Express.js — the most-downloaded Node.js web framework, with over 30 million weekly downloads on npm — makes it unusually easy to ship. Express deliberately ships with no built-in authorization layer: no concept of roles, no route guards, no ownership checks. Every app.get() and router.post() you write executes whatever handler you attach, for whatever caller sends the request, unless you explicitly wire in a middleware that says otherwise. That's a feature for flexibility and a liability for security teams, because a single missing middleware call, a misordered app.use(), or a req.params.id passed straight into a database query without an ownership check is enough to let one user read or modify another user's data. This post covers where those gaps actually show up in Express codebases, how they've led to real breaches, and how to test for them before an attacker does.
What counts as broken access control in an Express.js app?
Broken access control is any case where an application lets a request through to a resource or action the caller shouldn't be authorized to reach — and in Express, that almost always traces back to a route that never invoked an authorization middleware. OWASP's 2021 Top 10 data set found broken access control present in 94% of the roughly 500,000 tested applications, with 318,487 total occurrences across 34 mapped CWEs — more than any other category, including injection. The relevant CWEs are specific: CWE-862 (Missing Authorization), CWE-863 (Incorrect Authorization), and CWE-284 (Improper Access Control). In Express terms, this maps to three concrete failure modes: a route mounted without its auth middleware, a middleware that checks authentication (who are you) but not authorization (what are you allowed to touch), and handler logic that trusts a client-supplied identifier — a user ID, an org ID, a file path — without verifying the caller owns it.
Why does Express make this bug so easy to introduce?
Express makes broken access control easy because middleware execution is manual and order-dependent, and there's no framework-level default that denies access until proven otherwise. Compare this to Django, which requires an explicit @login_exempt-style opt-out, or Rails with Pundit/CanCanCan conventions baked into scaffolding. In Express, middleware only runs if you attach it, and it only protects the routes registered after it in the chain. A common real bug: a team mounts app.use('/api/admin', adminRouter) before calling app.use(requireAdmin), so every route inside adminRouter executes before the check does. Another: a developer adds a new route to an existing router file and assumes the router-level middleware from router.use(isAuthenticated) also checks authorization, when it only confirms a valid session exists. express-jwt versions up to 5.3.3 had exactly this class of flaw — CVE-2020-15084, published April 2020 — where audience (aud) claim validation could be bypassed, letting a token issued for one application be accepted by another. Because these are logic gaps rather than memory-safety bugs, they're invisible to most SAST tools tuned for pattern-matching known-vulnerable syntax.
What are the most common broken access control patterns in production Express codebases?
The most common pattern is Insecure Direct Object Reference (IDOR): a route like GET /api/invoices/:id that fetches Invoice.findById(req.params.id) and returns it without checking invoice.userId === req.user.id. Change the ID in the URL, and you read someone else's invoice — no exploit chain required, just a browser. A close second is mass assignment: User.updateOne({ _id: req.user.id }, req.body) passed directly from a client payload lets an attacker add "role": "admin" or "isVerified": true to their own update request if the schema doesn't explicitly allowlist fields (Mongoose's strict mode and Sequelize's attribute allowlisting both default to permissive behavior unless configured). Third is function-level access control gaps: an admin-only action reachable at /api/users/:id/delete that checks isAuthenticated but never checks req.user.role === 'admin', often because the client-side UI simply hides the delete button for non-admins rather than the server enforcing it. Fourth, and increasingly common with the shift to SPA frontends, is CORS and static file misconfiguration — app.use(express.static('uploads')) mounted on a directory that also contains non-public files, or a wildcard Access-Control-Allow-Origin: * paired with credentials: true, which browsers will actually reject, but which signals the same missing-authorization mindset elsewhere in the codebase.
How has broken access control in APIs actually led to breaches?
Broken access control has repeatedly led to breaches because API endpoints are easier to enumerate and probe than UI flows, and the 2021 Peloton incident is the textbook case: researchers found that Peloton's API returned private account data — age, gender, weight, location — for any user ID passed to an endpoint, with no check that the request came from the account owner or a connection, even after the profile was set to "private" in the app. The bug was function-level and object-level access control failure, not a cryptographic or injection flaw, and it existed because the authorization check lived only in client-facing UI logic. In 2023, T-Mobile disclosed its second major API breach in two years after an API endpoint exposed data for roughly 37 million customer accounts; the company's own filing described attackers abusing "one Application Programming Interface (API)" over weeks without triggering detection, consistent with an endpoint that authenticated callers but never verified they should be able to iterate through other customers' records. Neither disclosure named the backend framework, and the point isn't that Express specifically was involved — it's that this exact bug class (a working, authenticated API route with no per-object authorization check) is precisely what Express's unopinionated middleware model makes trivial to ship at scale across dozens of routers and hundreds of routes.
How do you actually test an Express app for broken access control?
You test for broken access control by attempting the same request as two different users, not by scanning for a known vulnerable pattern, because the bug is a missing check, not a bad line of code. Concretely: authenticate as User A, capture the ID of a resource they own (an invoice, an order, a document), then replay the identical request while authenticated as User B, substituting User A's resource ID. If the response returns a 200 with User A's data instead of a 403 or 404, that route has an IDOR. Do the same for role escalation — call every admin-tagged route as a standard user and confirm you get rejected before any business logic executes, not after. For automated coverage, diff your Express route table (every router.get/post/put/delete call across the app, including nested routers) against your middleware chain to flag any route where no authorization middleware appears upstream of the handler in the call graph — this is the kind of static, route-to-middleware mapping that catches the "mounted before the auth middleware" class of bug that manual pentesting often misses because testers only try routes they know are supposed to be protected. Fuzzing numeric and UUID path parameters against an authenticated low-privilege session, across every environment (staging often has weaker checks than production), rounds out a practical test plan.
How Safeguard Helps
Safeguard closes the gap between "we have an authorization middleware in the codebase" and "every route that needs it actually calls it" by combining reachability analysis with behavioral code understanding through Griffin AI, our security-tuned model that traces Express route definitions through the full middleware chain to flag handlers reachable without an authorization check in the call path — not just routes matching a known-bad pattern. Because Griffin AI reasons about the actual call graph rather than regex-matching route strings, it catches the IDOR and mass-assignment cases described above even when the vulnerable logic is buried three files deep in a service layer. Safeguard generates and ingests SBOMs to correlate your Express and middleware dependency versions (including packages like express-jwt and passport) against known CVEs such as CVE-2020-15084, so you know immediately if a vulnerable version is not just present but actually reachable from an internet-facing route. When a gap is confirmed, Safeguard opens an auto-fix pull request that inserts the missing authorization middleware or ownership check directly into the flagged route, so engineering teams fix the specific broken route instead of re-auditing the entire router file.