Reverse proxy

How do I run an app under a subpath (/app/) behind a reverse proxy?

Strip /app/ at the proxy and send X-Forwarded-Prefix, or keep it and set a base path. Which frameworks read the header, and the redirects and cookies they miss.

· 16 min read · How we verify this

Key points

  • There are two working designs. Strip /app at the proxy and tell the application with X-Forwarded-Prefix: /app, or keep /app in the forwarded path and configure the application's base path. Stripping without telling the application is the broken third option, and it is what produces redirects to /login instead of /app/login.
  • Werkzeug, Spring and ASP.NET Core all read X-Forwarded-Prefix, and none of them does so by default. Werkzeug's ProxyFix has x_prefix=0 by default, Spring Boot's server.forward-headers-strategy defaults to NONE outside cloud platforms, and ASP.NET Core's ForwardedHeaders defaults to None. Each needs one explicit switch.
  • Of the proxies, only Traefik's stripPrefix sets the header for you. nginx and Caddy's handle_path strip the path without recording it: you write proxy_set_header X-Forwarded-Prefix /app; or header_up X-Forwarded-Prefix /app.
  • The header is as spoofable as any X-Forwarded-*. In testing, nginx and Caddy 2.11.7 both passed a client's X-Forwarded-Prefix: /evil to the application when the configuration did not set the header itself.
  • sub_filter is the last resort for hard-coded links, and it does nothing to a gzip-compressed upstream body. In testing, it rewrote the links only after proxy_set_header Accept-Encoding "";.

Strip /app at the proxy and send X-Forwarded-Prefix: /app to an application that has been told to read it, or forward the path with /app intact and set the application's base path to /app. Both work. What does not work is the configuration most people start with: the proxy strips the prefix and the application is never told, so it believes it lives at / and every redirect, cookie path and generated link it emits points at the wrong place.

The rest of this page is the procedure for each design, the table of which frameworks read X-Forwarded-Prefix and which proxies send it (none of the frameworks that support it reads it by default, and only one proxy sends it unasked), and the three things a proxy cannot fix for you. Behaviour marked as tested was checked on nginx 1.30.4, Traefik 3.7.14, Caddy 2.11.7 and Werkzeug 3.1.9.

The symptom, and why it happens#

The browser asks for https://example.com/app/login. nginx, configured with location /app/ { proxy_pass http://u/; }, forwards GET /login. The application handles the login and redirects to /dashboard. The browser follows it to https://example.com/dashboard, which nginx sends to a different backend or answers with a 404. The same thing happens to <script src="/static/app.js">, to the session cookie set with Path=/, and to the OAuth callback URL the application registers.

The application is not wrong. From where it sits, the request really was for /login, and nothing in the request says otherwise. The trailing slash rule that does the stripping is covered in nginx proxy_pass and the trailing slash, and the proxy_pass simulator shows exactly what path your own location and proxy_pass pair forwards. This page is about the other half: getting the application to put /app back.

Decide first: strip the prefix or keep it#

Strip at the proxy, send X-Forwarded-PrefixKeep the prefix, configure a base path
Path the application receives/login/app/login
How the application learns /appPer request, from the headerOnce, from its own configuration
Application configurationEnable the framework's forwarded-header handling, including the prefixSet the base path or mount point to /app
Proxy configurationStrip, then set the headerForward unchanged: proxy_pass http://u; with no URI part
Same build reachable at / and at /app/Yes, the header differs per routeNo, the base path is baked in
Runs on its own without the proxyYes, at /Only at /app/
Security exposureThe header is client-spoofable unless the edge sets itNone from headers
Works for apps that know nothing about prefixesNoNo

Pick keep when the application has a base path setting and will only ever be served at one prefix. It is the design with nothing to trust: the application knows where it lives, and no header can tell it otherwise. Pick strip plus header when the same container is mounted at different prefixes in different environments, or sits behind Traefik, which sends the header anyway.

A third pattern sits between the two and is the one many self-hosted applications document: the proxy strips, and the application learns its public URL from configuration rather than a header. Gitea's reverse proxy guide does this. It sets [server] ROOT_URL = https://common.example.com/gitea/ in app.ini and uses an nginx rewrite that removes /gitea before proxy_pass. If your application has a "public URL" or "root URL" setting, that setting is the answer and the header is irrelevant.

If the application has neither a base path setting nor support for the header, nothing on the proxy side fixes it cleanly. That case is the last section before the FAQ.

Step by step: strip at the proxy and send the header#

  1. Choose the exact prefix, with no trailing slash: /app. Werkzeug accepted /app/ and produced the same script_root='/app' in testing, but the framework documentation examples all use the form without the slash.
  2. Make the proxy strip it and set the header. On nginx, both slashes matter and the exact-match sibling stops /app returning a 301:
    nginx
    location = /app { return 301 /app/; }
    location /app/ {
        proxy_pass http://u/;
        proxy_set_header Host               $host;
        proxy_set_header X-Forwarded-Proto  $scheme;
        proxy_set_header X-Forwarded-Prefix /app;
    }

    proxy_set_header replaces whatever the client sent under that name, which is what makes the header trustworthy. The inheritance trap applies: a single proxy_set_header inside the location cancels every one inherited from server, so repeat Host and X-Forwarded-Proto here.

  3. Turn on the framework's prefix handling. It is off by default in all three frameworks covered below.
  4. Restrict who may send it. Configure the framework's trusted proxy setting so the header is honoured only from your proxy's address, and make sure the application port is not reachable from anywhere else.
  5. Test the three outputs the prefix affects. With the framework switched on, a test application behind the configuration above, building its URLs from Werkzeug's request.script_root, answered a login request with:
    text
    HTTP/1.1 302 FOUND
    Location: http://localhost/app/dashboard
    Set-Cookie: sid=x; Path=/app/

    The same application without the header answered Location: http://localhost/dashboard and Path=/. Check a page's HTML for asset URLs too: anything the framework generates follows the prefix, anything written by hand does not. And check cookies separately, because a framework can get the links right and the cookie wrong (Flask does, below).

  6. Check X-Forwarded-Proto at the same time. The Location above says http because the test had no TLS. Behind a TLS-terminating proxy the scheme comes from X-Forwarded-Proto, and getting it wrong produces the redirect loop described in X-Real-IP, X-Forwarded-Proto, Host and Port.

Who reads X-Forwarded-Prefix#

X-Forwarded-Prefix is not in any RFC. RFC 7239's Forwarded header has no parameter for a path prefix, so there is no standard alternative to switch to. Support is per framework, and the defaults all point the same way.

FrameworkReads itDefaultWhat it changesHeader afterwards
Werkzeug (Flask) ProxyFixYesOff: x_prefix=0SCRIPT_NAME, which Werkzeug exposes as request.script_root; Flask's session cookie path is separateOriginal values kept in the environ as werkzeug.proxy_fix.orig
Spring ForwardedHeaderFilterYes, with a separate property to turn prefix handling on and offBoot's server.forward-headers-strategy is NONE, or NATIVE on supported cloud platforms; the prefix needs FRAMEWORKThe server's own path prefix is overridden, so links and 302 locations use the original oneRemoved from the request
ASP.NET Core forwarded headers middlewareYesOff: ForwardedHeaders.None, trusted proxies loopback onlyHttpContext.Request.PathBaseConsumed value removed; old PathBase saved in X-Original-Prefix

Werkzeug and Flask#

ProxyFix(app, x_for=1, x_proto=1, x_host=0, x_port=0, x_prefix=0) is the signature, and each number is how many proxies set that header. With the default 0, X-Forwarded-Prefix is ignored. The documentation's one-line description of the mapping is "X-Forwarded-Prefix sets SCRIPT_NAME":

python
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_prefix=1)
app.config["SESSION_COOKIE_PATH"] = "/app/"

Set the count to the number of proxies that add the header, which for nginx alone is 1. The documentation is blunt about getting this wrong: "It is a security issue to trust values that came from the client rather than a proxy." Prefix support arrived in Werkzeug 0.15, so anything current has it.

The last line is the part most guides leave out. Tested on Flask 3.1.3 with X-Forwarded-Prefix: /app, redirect(url_for("dash")) answered Location: /app/dashboard and url_for("static", ...) rendered /app/static/app.js, but the session cookie still came back as Set-Cookie: session=...; HttpOnly; Path=/. The prefix moved the URLs and not the cookie. With SESSION_COOKIE_PATH = "/app/" the same request returned Path=/app/. A cookie scoped to / still works, which is why this goes unnoticed, but it is sent to every other application on the host and can be overwritten by one of them using the same cookie name.

Spring#

Spring Boot does not read the header until you choose FRAMEWORK. Its how-to says NATIVE "is enough" for X-Forwarded-For and X-Forwarded-Proto; for anything beyond that, setting server.forward-headers-strategy=framework installs Spring Framework's ForwardedHeaderFilter on the servlet stack, or ForwardedHeaderTransformer on WebFlux. The default is NONE unless Boot detects a supported cloud platform, where it is NATIVE, and the documented route to X-Forwarded-Prefix is FRAMEWORK in both cases.

The filter's prefix handling is the most flexible of the three, because the header value replaces the server's own context path rather than being added to it. The Spring reference gives three cases:

Public URLApplication seesProxy sendsEffect
https://example.com/api/{path}http://localhost:8080/app1/{path}X-Forwarded-Prefix: /apiOverride: /api replaces /app1 in generated links
https://app1.example.com/{path}http://localhost:8080/app1/{path}X-Forwarded-Prefix: (empty)Remove: links have no prefix
https://example.com/api/app1/{path}http://localhost:8080/app1/{path}X-Forwarded-Prefix: /api/app1Insert: /api is added in front

The filter then removes the forwarded headers "to eliminate further impact", so application code that logs X-Forwarded-Prefix sees nothing. A removeOnly mode removes them without using them, which is the right setting for a service that must never trust them. The filter must be ordered ahead of other filters such as RequestContextFilter.

ASP.NET Core#

The forwarded headers middleware does nothing until you list the headers to process. The enum value is ForwardedHeaders.XForwardedPrefix (value 8; ForwardedHeaders.All includes it):

csharp
builder.Services.Configure<ForwardedHeadersOptions>(o =>
{
    o.ForwardedHeaders = ForwardedHeaders.XForwardedFor
                       | ForwardedHeaders.XForwardedProto
                       | ForwardedHeaders.XForwardedPrefix;
    o.KnownProxies.Add(IPAddress.Parse("10.0.0.100"));
});
// ...
app.UseForwardedHeaders();

The middleware sets HttpContext.Request.PathBase from the header, removes the consumed value, and keeps the previous PathBase in X-Original-Prefix (renameable through OriginalPrefixHeaderName). Two defaults matter here. Only loopback addresses are trusted, so a proxy on another host is ignored until you add it to KnownProxies or KnownNetworks, which is the usual reason "the header arrives but nothing changes". And ForwardLimit is 1, so only the rightmost value is processed. The ASPNETCORE_FORWARDEDHEADERS_ENABLED=true shortcut is documented as forwarding the scheme and comes with a warning that it does not restrict which IPs are trusted.

Who sends X-Forwarded-Prefix#

ProxyStrips withSets the headerClient's own X-Forwarded-Prefix (tested)
nginxproxy_pass with a URI part, or rewrite ... breakOnly if you write proxy_set_header X-Forwarded-Prefix /app;Passed through when you do not set it; replaced when you do
TraefikstripPrefix middlewareYes, automatically, with the stripped prefixRemoved by a default entryPoint; with forwardedHeaders.insecure, sent alongside Traefik's own value
Caddyhandle_pathNo. Add header_up X-Forwarded-Prefix /app inside reverse_proxyPassed through under handle_path; replaced by header_up

Traefik's documentation describes stripPrefix as removing the prefix and storing it in X-Forwarded-Prefix, and suggests a backend "can use the X-Forwarded-Prefix header to construct relative URLs". Tested on 3.7.14 with prefixes: [/app], a request for /app/x reached the backend as GET /x with X-Forwarded-Prefix: /app, and a bare /app arrived as GET /. One trap the Traefik guide does not spell out: PathPrefix and stripPrefix both work on characters, not segments, so with a PathPrefix rule for /app a request for /application matched and arrived as GET /lication. If the prefix must be a whole segment, match /app/ with PathPrefix and the bare /app with an exact Path rule:

text
PathPrefix(`/app/`) || Path(`/app`)

Caddy's handle_path is the equivalent of the nginx trailing slash, as the Caddy guide explains, and it strips without leaving a trace. On 2.11.7, /app/x reached the backend as GET /x with no prefix header at all, and a client-supplied X-Forwarded-Prefix: /evil went straight through. With header_up X-Forwarded-Prefix /set in the reverse_proxy block, the backend received /set regardless of what the client sent. Note that handle_path /app/* does not match a bare /app.

Keep the prefix instead#

Forward the path unchanged and the application has to own the prefix. On nginx that means proxy_pass with no URI part, location /app/ { proxy_pass http://u; }, which forwards /app/login as /app/login. Then the application must route /app/login as if it were /login while still generating /app/... URLs.

  • ASP.NET Core: app.UsePathBase("/foo"); before app.UseRouting();. Microsoft's example: for a request to /foo/api/1 it sets Request.PathBase to /foo and Request.Path to /api/1. The order is not optional; with WebApplication, routing called before UsePathBase matches routes against the unmodified path. If the proxy strips instead and you do not want the header, the same page shows setting context.Request.PathBase = new PathString("/foo") in a middleware, which is the "configure, do not trust" pattern.
  • Werkzeug and Flask: mount the application under the prefix with DispatcherMiddleware, which dispatches "based on the path it is mounted under". Mounted at '/app', a request for /app/x reached the application with script_root='/app' and path='/x' in testing, /app became path='/', and /x got the dispatcher's fallback. No proxy header is involved.
  • Spring: the "insert" case of ForwardedHeaderFilter above covers a proxy that forwards a partial path: the application keeps its own /app1 and the header adds the public /api in front.

This is also the design for applications that have a base URL setting but no header support. Set the setting, forward unchanged, and the only proxy work left is the usual Host and X-Forwarded-Proto.

What the proxy cannot fix#

Even with the prefix handled, three things can still leak the wrong path, and only one of them is fully fixable at the proxy.

Location headers: proxy_redirect#

proxy_redirect rewrites "the text that should be changed in the 'Location' and 'Refresh' header fields". Its default is proxy_redirect default;, which is built from the location and proxy_pass values, so for location /app/ { proxy_pass http://u/; } it equals proxy_redirect http://u/ /app/;. That default only matches a Location that names the upstream address. Once you send Host $host, the application builds redirects from the public host name instead, and the default never matches. In testing, with proxy_set_header Host $host and no prefix header, both an absolute Location: http://localhost/dashboard and a relative Location: /dashboard passed through unchanged. Two explicit lines fixed both:

nginx
proxy_redirect http://$host/ /app/;
proxy_redirect / /app/;

Both became Location: http://localhost/app/dashboard. The first matching directive wins, so put the absolute form first. proxy_redirect is plain text replacement, so it must not be combined with an application that already knows the prefix: a correct Location: /app/dashboard would become /app/app/dashboard. Use proxy_redirect or the header, not both.

An application that does not know about /app sets cookies with Path=/, which works, but leaks the cookie to every other application on the host and lets a cookie with the same name from a sibling application overwrite it. proxy_cookie_path (since nginx 1.1.15, default off) rewrites the path attribute of Set-Cookie:

nginx
proxy_cookie_path / /app/;

Tested, Set-Cookie: sid=x; Path=/ arrived as Path=/app/. The same "one or the other" rule applies: an application that already emits Path=/app/ would get /app/app/. proxy_cookie_flags (1.19.3) is the related directive for adding secure, httponly or samesite.

Links the framework generates follow SCRIPT_NAME or PathBase. Links someone typed do not: <script src="/static/app.js"> in a template, fetch("/api/items") in a bundle, a single-page application router with its base set to /. The proper fix is in the application: use the framework's URL builder, relative URLs, or the front-end build tool's base path option.

nginx can rewrite response bodies with sub_filter, and it is worth knowing its limits before reaching for it. The module "is not built by default" (it needs --with-http_sub_module), it only processes text/html unless sub_filter_types says otherwise, and sub_filter_once defaults to on, so only the first match is replaced. It also works on the bytes nginx receives. With an upstream that gzips its responses, this configuration did nothing in testing:

nginx
location /sub/ {
    proxy_pass http://u/;
    sub_filter 'href="/' 'href="/sub/';
    sub_filter_once off;
}

The response kept Content-Encoding: gzip and the original href="/static/app.js". Adding proxy_set_header Accept-Encoding "";, so the upstream sends plain text, made the same filter produce href="/sub/static/app.js". The cost is that compression has to happen at nginx instead, which compression through proxies covers. Even then, string replacement misses URLs built in JavaScript at runtime and can rewrite text that only looks like a URL. Use it to get an unmodifiable third-party application working, not as the design.

The security limit: strip it at the edge#

X-Forwarded-Prefix is a request header like any other, and every framework above trusts it once switched on. The Spring reference states the rule: forwarded headers are "intended to be set by trusted proxies and never allowed from the outside", and a proxy at the edge of trust "must remove forwarded headers". Two tests show how easy it is to get wrong:

  • An nginx location that strips the prefix but does not set X-Forwarded-Prefix, in front of ProxyFix(x_prefix=1): a client sending X-Forwarded-Prefix: /evil produced script_root='/evil'. The same request to the location with proxy_set_header X-Forwarded-Prefix /app; produced /app.
  • Traefik with forwardedHeaders.insecure=true on the entryPoint: the backend received two header lines, the client's /evil and Traefik's /app. ProxyFix(x_prefix=1) used the rightmost value, /app, as did a single comma-separated /evil, /app. A framework or a hand-written parser that takes the first value would use the client's.

What an attacker gets from a forged prefix is the same class of bug as a forged X-Forwarded-Host: generated links, redirects and cookie paths under their control, and a cache poisoning primitive if a shared cache stores the result. The fix is also the same. Every edge proxy sets the header unconditionally, or deletes it where no prefix applies (on nginx, proxy_set_header X-Forwarded-Prefix ""; drops it, because an empty value is not sent: in testing the client's /evil no longer arrived), the framework trusts only your proxy's addresses, and the application port is not reachable around the proxy. Configuring trusted proxies covers the trusted address settings for each stack, and client IP spoofing through proxies has the test method.

Where this stops applying#

The framework behaviour is from current documentation: Werkzeug's stable ProxyFix page, the Spring Framework reference and Spring Boot how-to, and Microsoft Learn for ASP.NET Core 10. The proxy behaviour was tested on nginx 1.30.4 (Alpine package), Traefik 3.7.14 and Caddy 2.11.7, with Werkzeug 3.1.9 as the application. Frameworks not in the table (Django, Express, Rails and others) have their own mechanisms for a script prefix or mount path; check whether they read X-Forwarded-Prefix before assuming they do. And %2F in paths behaves differently depending on whether nginx strips with a URI part or forwards the raw request, which is why Gitea's sub-path configuration rewrites from $request_uri to "keep %2F as-is".

Frequently asked questions#

What is X-Forwarded-Prefix?#

A non-standard request header a reverse proxy sends to say which path prefix it removed before forwarding, for example X-Forwarded-Prefix: /app when /app/login was forwarded as /login. The application uses it to put the prefix back into redirects, cookie paths and generated links. Werkzeug, Spring and ASP.NET Core all read it, and all three ignore it until you enable it.

Does nginx send X-Forwarded-Prefix?#

No. nginx sets no X-Forwarded-* header of any kind by default. Add proxy_set_header X-Forwarded-Prefix /app; in the location that strips the prefix. Without that line nginx forwards whatever value the client sent.

Does Traefik stripPrefix set X-Forwarded-Prefix?#

Yes. stripPrefix removes the matching prefix and adds the removed prefix as X-Forwarded-Prefix. A request for /app/x with prefixes: [/app] reaches the backend as /x with X-Forwarded-Prefix: /app.

Why does my Flask app redirect to / instead of /app/?#

Because the proxy stripped /app and Flask was not told. Wrap the application with ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_prefix=1) and have the proxy send X-Forwarded-Prefix: /app. x_prefix defaults to 0, so the header is ignored until you set it. Set SESSION_COOKIE_PATH as well: in testing on Flask 3.1.3 the prefix fixed redirects and url_for but left the session cookie at Path=/.

Should I use UsePathBase or X-Forwarded-Prefix in ASP.NET Core?#

Use UsePathBase("/app") before UseRouting() when the proxy forwards the full path and the prefix is fixed. Use ForwardedHeaders.XForwardedPrefix when the proxy strips the prefix and sends the header, and add the proxy to KnownProxies or KnownNetworks, because only loopback is trusted by default.

Partly, and only as a last resort. sub_filter needs --with-http_sub_module, replaces only the first match unless sub_filter_once off, only touches text/html by default, and does nothing to a compressed upstream response unless you send proxy_set_header Accept-Encoding "";. It cannot reach URLs built by JavaScript at runtime.

Do I need both proxy_redirect and X-Forwarded-Prefix?#

No, and using both breaks redirects. If the application knows the prefix, its Location headers already contain /app, and a proxy_redirect / /app/; turns them into /app/app/.... Use proxy_redirect and proxy_cookie_path only for applications that cannot be told the prefix.

Primary sources#

Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.

  1. Werkzeug, X-Forwarded-For Proxy Fix (ProxyFix)
  2. Werkzeug, Application Dispatcher (DispatcherMiddleware)
  3. Spring Framework reference, Filters (ForwardedHeaderFilter)
  4. Spring Boot how-to, Running behind a front-end proxy server
  5. Microsoft Learn, Configure ASP.NET Core to work with proxy servers and load balancers
  6. Microsoft Learn, ForwardedHeaders enum
  7. Traefik StripPrefix middleware (HTTP)
  8. nginx ngx_http_proxy_module (proxy_redirect, proxy_cookie_path)
  9. nginx ngx_http_sub_module
  10. Gitea, reverse proxies (using a sub-path)

Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.

More in reverse proxy configuration#