Skip to content
Access Control

Access Control Bypass, Explained: Why a 403 at the Proxy Is Not Authorization

A 403 Forbidden only proves that one spelling of a request was refused. Here is how trailing slashes, path parameters, rewrite headers like X-Original-URL, spoofed client IPs and alternate methods walk past gates enforced at a proxy or router, and why the decision has to live in the application.

Part 1 of 1From the Access Control series

At a glance

CWE-284
Interpreter
Front-end proxy or gateway rules and the application's router
Required condition
Access is enforced on one spelling of a request that the application also serves under other paths, methods or headers.
Potential impact
Unauthenticated access to admin panels, internal endpoints and protected data or actions.
Primary defense
Authorize the resolved endpoint inside the application, deny by default, and ignore client-set routing and identity headers.

Safe practice: use benign proof only, in a disposable local lab or on a system you are explicitly authorized to test.

On this page

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

  1. List every gate outside application code: proxy locations, WAF and CDN rules, ingress annotations and IP allowlists.
  2. Confirm each protected route carries its own authorization requirement, with a deny-by-default fallback.
  3. Compare case, trailing-slash, ; and percent-decoding behavior between the edge and the router.
  4. Register explicit methods per route, and disable method override or restrict it to the methods you need.
  5. 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.
  6. Name trusted proxies explicitly, and never treat an IP address as proof of identity.
  7. 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

Module: Access Control Bypass (403). How it probes: Runs on crawled pages that answered 403 Forbidden, skipping static assets, once per path per run. It first requests up to sixteen spellings of the same path (trailing slash or dot, ; and ..;/ path parameters, doubled slashes, a /. prefix, single- and double-encoded characters, an overlong UTF-8 slash and an upper-cased last segment). With header attacks enabled, it then requests the root path with X-Original-URL or X-Rewrite-URL naming the forbidden path, and the forbidden path with ten client-IP and forwarding headers such as X-Forwarded-For, X-Real-IP, True-Client-IP and X-Custom-IP-Authorization set to 127.0.0.1, with X-Forwarded-Host and X-Forwarded-Server also tried as localhost. Last, it sends HEAD, OPTIONS, POST, PUT and PATCH, plus a POST carrying X-HTTP-Method-Override and X-Method-Override set to GET. How it confirms: The forbidden URL must still answer 403 on a fresh request, and a random sibling path serves as a not-found control. A variant counts only with a 200, 201, 202, 203 or 206 that is not a WAF block and whose body is under 90 percent similar to both the 403 page and the control. Path and rewrite-header variants also need a body of at least 64 characters that is less than half similar to the 403 page, client-IP and forwarding headers need the same divergence, and an OPTIONS reply with an empty body and an Allow header is ignored. Each of the path, header and method sweeps stops at its first hit, and the bypass request, both similarity scores and the response stay with the finding as evidence. A finding moves from Possible to Confirmed only after this check. Reported as CWE-284 · SARIF 2.1.0.
How SelfSec proves this one
Module
Access Control Bypass (403)
How it probes
Runs on crawled pages that answered 403 Forbidden, skipping static assets, once per path per run. It first requests up to sixteen spellings of the same path (trailing slash or dot, ; and ..;/ path parameters, doubled slashes, a /. prefix, single- and double-encoded characters, an overlong UTF-8 slash and an upper-cased last segment). With header attacks enabled, it then requests the root path with X-Original-URL or X-Rewrite-URL naming the forbidden path, and the forbidden path with ten client-IP and forwarding headers such as X-Forwarded-For, X-Real-IP, True-Client-IP and X-Custom-IP-Authorization set to 127.0.0.1, with X-Forwarded-Host and X-Forwarded-Server also tried as localhost. Last, it sends HEAD, OPTIONS, POST, PUT and PATCH, plus a POST carrying X-HTTP-Method-Override and X-Method-Override set to GET.
How it confirms
The forbidden URL must still answer 403 on a fresh request, and a random sibling path serves as a not-found control. A variant counts only with a 200, 201, 202, 203 or 206 that is not a WAF block and whose body is under 90 percent similar to both the 403 page and the control. Path and rewrite-header variants also need a body of at least 64 characters that is less than half similar to the 403 page, client-IP and forwarding headers need the same divergence, and an OPTIONS reply with an empty body and an Allow header is ignored. Each of the path, header and method sweeps stops at its first hit, and the bypass request, both similarity scores and the response stay with the finding as evidence.
Reported as CWE-284 · SARIF 2.1.0

A reflected response alone is not enough to promote a Access Control finding. The classical engine has to confirm the behavior before it reaches the report. See the detection engine

SelfSec scanner

Find Access Control in your own app

Reading about the class is one thing. SelfSec tests for it against an application you are authorized to assess, and only calls it real when independent evidence agrees.

Find it Confirmed, not guessed 21 vulnerability modules, with the classical engine owning the verdict. Prove it Evidence you can re-read Every finding keeps the raw request, the response and the baseline it was measured against.
Join the launch list