Broken access control leads the OWASP Top 10:2025 as A01, and OWASP's prevention advice for it rests on the point this post is about: access control is only effective in trusted server-side code. Many deployments put it somewhere else. A reverse proxy denies /admin, a WAF or CDN rule refuses a path, an ingress annotation allows an internal IP range, or a middleware compares the request path against a string. Each returns a convincing 403 Forbidden, and each decides on how the request is spelled, not on what the application will actually run.
An access control bypass is a second spelling of the same request that the gate does not recognize and the application still serves. The same reasoning applies to a 401 challenge added by a proxy's basic authentication. SelfSec starts from pages that answer 403 and reports a bypass as CWE-284, Improper Access Control, at high severity.
The mental model: the gate and the router read different URLs
A gate in front of the application and the router inside it are two parsers. The gate compares bytes; the router resolves them, folding case, ignoring a trailing slash, stripping path parameters or honoring a rewrite header. Every rule that the router applies and the gate does not is a way in.
| Request |
Front-end rule denying exactly /admin |
A back end that resolves it to /admin |
GET /admin |
Denied |
— |
GET /admin/ |
Allowed, a different string |
Routers that ignore a trailing slash, such as Express by default |
GET /ADMIN |
Allowed |
Case-insensitive routers, such as Express and ASP.NET Core |
GET /admin;x |
Allowed |
Java servlet containers, which strip ; path parameters |
GET /static/..;/admin |
Allowed, it sits under /static/ |
A servlet container that drops the empty ; parameter, then resolves .. |
GET / with X-Original-URL: /admin |
Allowed, the path is / |
Frameworks that route by IIS rewrite headers |
The list is open-ended: encodings, double decoding and dot segments differ between proxies, frameworks and versions.
How it happens
The common shape is a proxy rule protecting an application that never checks for itself:
location = /admin {
deny all;
}
location / {
proxy_pass http://127.0.0.1:3000;
}
Behind it, an Express app serves app.get('/admin', showAdmin) with no role check, because "nginx handles that". The exact-match location covers one string. Express, with strict and case-sensitive routing off by default, sends /admin/ and /ADMIN to the same handler.
Three more families follow the same pattern:
- Rewrite headers. IIS URL Rewrite passes the original path in
X-Original-URL, and some frameworks route by it. Symfony did so until it removed that support in 2018 (CVE-2018-14773), because any client could send the header. The gate sees /; the application serves /admin.
- Methods the rule never named. A rule scoped to
GET leaves other methods open. Apache's <Limit GET POST> restricts only the listed methods, a servlet <security-constraint> naming only GET leaves the rest uncovered, and Express answers HEAD with the GET handler. Method-override middleware such as Express's method-override or Rack::MethodOverride can turn an allowed POST with X-HTTP-Method-Override: GET back into a GET after the gate has passed it.
- Headers that claim to be local. "Admin only from localhost" checks often read
X-Forwarded-For, X-Real-IP or True-Client-IP. With app.set('trust proxy', true), Express takes req.ip from the left-most X-Forwarded-For entry, which the client wrote.
A request smuggled inside another is a further way past a front-end gate without any spelling trick; see HTTP request smuggling.
A concrete test: prove the same resource, not just a 200
An authorized tester starts from a real 403 and two baselines. Request the forbidden URL again to confirm it is still denied, then request a random sibling such as /k3v9q2m7x1d4p8w2 to see what a missing page looks like. Then try one variant at a time:
GET /admin/ HTTP/1.1
Host: shop.example
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
A 200 alone proves nothing. Login pages, soft-404 templates, WAF block pages and maintenance screens all return 200. The bypass is a response whose content is clearly neither the 403 page nor the not-found control, such as the admin page's own title and navigation.
For rewrite headers, first check whether the application reads them at all:
GET / HTTP/1.1
Host: shop.example
X-Original-URL: /ssec-does-not-exist
If the home page now returns 404, routing follows the header, and X-Original-URL: /admin is the real test. For client-IP headers, repeat the forbidden request with X-Forwarded-For: 127.0.0.1. Use bodiless requests and stop at the first proof.
The gate refused a string. The application served a resource. Authorization belongs where the resource is known.
What an attacker gains
Front-end gates usually guard the most sensitive surfaces, so a bypass tends to be unauthenticated access to them:
- Admin consoles and user management, often with no second check behind the gate.
- Internal endpoints such as metrics, health detail, framework management pages and debug consoles.
- Exports, backups and configuration, which feed broader information disclosure.
- State-changing actions, when a method bypass reaches handlers for
POST, PUT or PATCH.
The fix: authorize the resolved endpoint
The durable fix is to make the authorization decision after the application has resolved the request, attached to the handler that will run, and to deny anything not explicitly allowed. Then every spelling the router accepts reaches the same check, because the router is what delivered it. A front-end rule can stay as defense in depth, but it must not be the only one.
In Express, put the check on the router that owns the routes, so it runs for whatever path the mount matched:
const express = require('express');
const app = express();
app.set('trust proxy', 'loopback');
function requireRole(role) {
return (req, res, next) => {
if (!req.user) {
res.sendStatus(401);
return;
}
if (!req.user.roles.includes(role)) {
res.sendStatus(403);
return;
}
next();
};
}
const admin = express.Router();
admin.use(requireRole('admin'));
admin.get('/users', listUsers);
app.use(authenticate);
app.use('/admin', admin);
Here authenticate is whatever session middleware sets req.user, and listUsers is the route handler. trust proxy names only the local nginx, so req.ip comes from the address that proxy writes, not from the client's own header.
In ASP.NET Core, a fallback policy denies anonymous access to any endpoint without its own rule, and the admin group carries an explicit policy:
using System.Net;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddCookie();
builder.Services.AddAuthorizationBuilder()
.AddPolicy("Admin", policy => policy.RequireRole("admin"))
.SetFallbackPolicy(new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build());
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownProxies.Add(IPAddress.Parse("10.0.0.5"));
});
var app = builder.Build();
app.UseForwardedHeaders();
app.UseAuthentication();
app.UseAuthorization();
app.MapGroup("/admin")
.RequireAuthorization("Admin")
.MapGet("/users", () => Results.Ok());
app.Run();
Forwarded headers are applied only when the connection comes from a listed proxy. Spring Security follows the same model with authorizeHttpRequests, and its default StrictHttpFirewall rejects semicolons, encoded slashes and non-normalized paths before they reach a handler.
At the edge, overwrite identity headers and drop routing headers no client should send:
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header True-Client-IP "";
proxy_set_header X-Client-IP "";
proxy_set_header X-Original-URL "";
proxy_set_header X-Rewrite-URL "";
proxy_set_header X-HTTP-Method-Override "";
proxy_set_header X-Method-Override "";
}
The proxy sets every forwarding header that Express trusts, so a client cannot inject its own, and an empty value tells nginx not to forward a header at all.
Fixes that do not hold
- Adding deny rules for
/admin/, /ADMIN and /admin;: the next encoding, dot segment or parser upgrade adds another spelling.
- Normalizing only at the proxy: the back end may still decode twice, strip path parameters or honor a rewrite header.
- Authorizing by source IP: forwarding headers are client input unless a proxy you control overwrites them.
- Checking only
GET and POST: HEAD, PUT, PATCH and method overrides reach the same handlers.
- Returning 404 instead of 403: hiding a route is not authorizing it.
- Hiding admin links in the interface: the server still answers whoever asks.
A developer review checklist
- List every gate outside application code: proxy locations, WAF and CDN rules, ingress annotations and IP allowlists.
- Confirm each protected route carries its own authorization requirement, with a deny-by-default fallback.
- Compare case, trailing-slash,
; and percent-decoding behavior between the edge and the router.
- Register explicit methods per route, and disable method override or restrict it to the methods you need.
- Strip
X-Original-URL, X-Rewrite-URL and method-override headers at the edge, and overwrite X-Forwarded-For and every other forwarding header the application reads.
- Name trusted proxies explicitly, and never treat an IP address as proof of identity.
- Add tests that request protected resources through path, header and method variants and expect
401 or 403.
For object-level checks, where a signed-in user reaches someone else's record by changing an ID, see broken authorization in GraphQL.
References