Broken access control has topped the OWASP Top 10 since the 2021 edition, and the 2025 edition — finalized in January 2026 — made the case worse, not better: OWASP now reports that 100% of tested applications showed some form of broken access control, up from 94% four years earlier. The category also grew in scope. A01:2025 now maps 40 CWEs, up from 34 in 2021, after absorbing Server-Side Request Forgery (CWE-918) alongside long-standing members like CWE-200 (information exposure), CWE-201 (sensitive data in transit), and CWE-352 (CSRF). Across OWASP's dataset, the category accounts for over 1.8 million recorded occurrences and more than 32,000 associated CVEs. For teams building on Express.js — still one of the most widely deployed Node.js web frameworks, and one that ships with essentially no access-control primitives out of the box — that's a direct warning: the framework will happily route a request straight to a handler that touches someone else's data unless you put a check in the way yourself. This post walks through the specific failure modes OWASP tracks and the middleware patterns that close each one in a real Express app.
What exactly counts as broken access control?
Access control is the set of server-side checks that keep a logged-in user confined to what they're actually supposed to do — viewing another user's invoice, deleting a record they don't own, or hitting an admin endpoint without the admin role. OWASP defines broken access control as any failure of that enforcement, and A01:2025 groups a wide range of concrete bugs under it: violating the principle of least privilege, forced browsing to authenticated pages by guessing URLs, insecure direct object references (IDOR) where an ID in a request maps straight to a database row with no ownership check, missing access control on state-changing API methods like POST, PUT, and DELETE (teams often protect GET routes but forget the others), JWT tampering that escalates a user's claimed role, permissive CORS configuration that lets any origin call an authenticated API, and — newly folded into this category for 2025 — SSRF, where a server is tricked into making requests to internal resources on an attacker's behalf. The common thread: the server trusted something the client controlled.
Why does the classic IDOR example still work in 2026?
OWASP's canonical illustration is still the simplest one: an application serves https://example.com/app/accountInfo?acct=notmyacct and the attacker just edits the acct parameter to a different value. If the server queries the database using that parameter without independently verifying that the requesting session actually owns the account, the request succeeds. This pattern persists in Express apps specifically because route parameters and query strings are just strings — req.params.id or req.query.acct — and it's trivial to write a handler that passes them straight into a Mongoose or Sequelize lookup without an ownership clause. The fix is not a smarter regex or a WAF rule; it's re-deriving the resource from the authenticated session and checking it against the requested ID before any read or write executes. Every route that accepts an identifier from the client needs this check, which is exactly why OWASP recommends centralizing the logic rather than writing it inline in each handler, where it inevitably gets copy-pasted incorrectly or skipped on a new route.
How should ownership checks be structured as Express middleware?
The pattern that scales is a small, reusable middleware factory that loads the resource once, checks it against req.user, and attaches it to the request for the handler to use — never a bespoke if statement per route. For example:
const requireOwner = (loadResource) => async (req, res, next) => { const resource = await loadResource(req.params.id); if (!resource) return res.status(404).end(); if (resource.ownerId !== req.user.id) return res.status(403).end(); req.resource = resource; next(); };
Mounted as router.get('/invoices/:id', requireAuth, requireOwner(Invoice.findById), handler), this guarantees the ownership check runs before the handler regardless of who writes the handler later. Role checks follow the same shape: a requireRole('admin') middleware that reads req.user.role and calls next() or returns 403, applied at the router level with router.use(requireRole('admin')) for an entire admin sub-app rather than scattered per-route. The goal, per OWASP's own guidance, is deny-by-default: routes are unreachable until an explicit middleware grants access, not open until someone remembers to lock them down.
What do CORS misconfiguration and CSRF have to do with access control?
Both are access-control failures because each lets a request execute in a context the server never intended to trust. A permissive CORS policy — app.use(cors({ origin: '*', credentials: true })) — tells browsers it's fine for any website to make authenticated, cookie-bearing requests to your API, which defeats the same-origin protections the rest of the web relies on; the origin should be an explicit allow-list, and credentials: true should never be paired with a wildcard origin. CSRF (CWE-352), one of the longest-standing members of this OWASP category, exploits the same gap from the opposite direction: a browser automatically attaches session cookies to a request forged by a malicious page, so the server processes it as if the legitimate user sent it. Express has no built-in CSRF protection since the original csurf package was deprecated; current guidance is a double-submit-cookie or synchronizer-token library applied as middleware on every state-changing route, combined with SameSite=Lax or SameSite=Strict cookies as a second layer, since neither control alone is considered sufficient by itself.
What belongs in access-control middleware that people usually skip?
Three things OWASP calls out explicitly and that Express apps routinely miss: server-side session invalidation on logout, rate limiting on APIs, and logging of access-control failures. Express sessions backed by a store like Redis need an explicit req.session.destroy() on logout — clearing a client-side cookie alone leaves the session valid server-side until it expires naturally. Rate limiting (via middleware such as express-rate-limit) blunts brute-force enumeration of IDOR-style endpoints, where an attacker without a valid ID simply iterates. And logging every 403 and 401 response — not just 500s — gives a security team the signal to notice when someone is systematically probing acct or id parameters, which is often the only visible sign of an IDOR attack in progress before data actually leaves the system.
How Safeguard Helps
Safeguard's own guardrails engine — the policy layer that gates builds, registry pushes, and deployments — is built on the identical principle this post argues for at the application layer: deny-by-default, with rules centralized in one policy definition instead of scattered checks, evaluated automatically, and every BLOCK or WARN decision written to an audit log rather than left to a developer's memory. Where Safeguard fits directly into an Express codebase's security posture is upstream of the code you write by hand: it scans your package.json dependency tree, generates a CycloneDX SBOM on every build, and flags known-vulnerable packages — including CORS, session, and CSRF middleware libraries themselves — before a broken access-control bug ships to production riding on a broken access-control library.