nginx auth_request vs Traefik ForwardAuth vs Caddy forward_auth: what actually differs?
Where nginx auth_request, Traefik ForwardAuth and Caddy forward_auth differ with Authelia, authentik or oauth2-proxy: redirects, headers, bodies and spoofing.
Key points
- nginx
auth_requestunderstands three answers: 2xx allows, 401 or 403 denies, and anything else is an error. A 302 from the auth service becomes a500withauth request unexpected status: 302in the error log, so nginx needserror_page 401plusauth_request_set $x $upstream_http_locationto build the login redirect itself. - Traefik ForwardAuth and Caddy
forward_authreturn the auth service's non-2xx response to the client as it is, status,Locationand body included, so a 302 to the login portal works with no extra configuration. - That single difference is why Authelia has
/api/authz/auth-requestfor nginx and/api/authz/forward-authfor Traefik and Caddy, and why authentik ships/outpost.goauthentik.io/auth/nginx,/auth/traefikand/auth/caddy. Point the proxy at the wrong one and logins break. - Traefik and Caddy send
X-Forwarded-MethodandX-Forwarded-Urifor you. nginx sends only what you write withproxy_set_header; its own documentation usesX-Original-URI, while Authelia and authentik readX-Original-URL, a full URL with scheme and host. - Traefik's
trustForwardHeaderis deprecated. The documented setup isforwardedHeaders.trustedIPson the entryPoint plustrustForwardHeader: true; withforwardedHeaders.insecureon the entryPoint a client-suppliedX-Forwarded-Urireached the auth service unchanged in testing.
The difference that decides everything is what each proxy does with an answer that is not "yes". nginx auth_request accepts exactly three outcomes from the auth service: a 2xx allows the request, a 401 or 403 denies it, and every other status is an error. The 302 that Authelia or authentik sends to push a browser to the login page is "every other status", so nginx turns it into a 500 Internal Server Error. Traefik ForwardAuth and Caddy forward_auth do the opposite: any non-2xx response from the auth service, redirect included, goes back to the client as it is. That is why the same Authelia or authentik setup needs an extra error_page block on nginx and none on Traefik or Caddy, and why both projects ship a separate endpoint for nginx.
The rest of the differences, which headers reach the auth service, how the user's identity comes back, what happens to the request body, and how a client can lie about the URL, are smaller but each one breaks a real deployment. They are compared below, with behaviour checked on nginx 1.27.5, Traefik 3.7.14 and Caddy 2.11.7.
The comparison in one table#
nginx auth_request | Traefik ForwardAuth | Caddy forward_auth | |
|---|---|---|---|
| Allows on | 2xx | 2xx | 2xx |
| 401 from auth service | 401 to client, nginx's own error page, WWW-Authenticate passed through, Location dropped | Auth service's response returned as is (status, headers, body) | Auth service's response returned as is |
| 403 from auth service | 403 to client, nginx's own error page | Returned as is | Returned as is |
| 302 or 303 from auth service | 500, logged as auth request unexpected status: 302 | Returned as is, so the redirect works | Returned as is, so the redirect works |
| Request method to auth service | GET, even for a client POST (tested) | GET unless preserveRequestMethod: true | Always GET |
| Original method and URI sent as | Nothing, unless you proxy_set_header them | X-Forwarded-Method, X-Forwarded-Uri (plus -Proto, -Host, -For) | X-Forwarded-Method, X-Forwarded-Uri, plus reverse_proxy's usual X-Forwarded-* |
| Identity headers back to the app | auth_request_set $v $upstream_http_<name>; then proxy_set_header | authResponseHeaders list or authResponseHeadersRegex | copy_headers, with Old>New renaming |
| Request body to auth service | Sent unless you turn it off; the documentation's example uses proxy_pass_request_body off | Not sent: forwardBody defaults to false | Not sent: the request is a GET "so that the incoming request's body is not consumed" |
| Available by default | No: needs --with-http_auth_request_module | Yes | Yes |
Why a 302 breaks nginx and not the others#
The nginx module documentation is short and precise: "If the subrequest returns a 2xx response code, the access is allowed. If it returns 401 or 403, the access is denied with the corresponding error code. Any other response code returned by the subrequest is considered an error." For the 401 case only, "the client also receives the 'WWW-Authenticate' header from the subrequest response."
Tested on nginx 1.27.5 with an auth location that returned each status in turn:
| Auth service returned | Client received | Notes |
|---|---|---|
200 with Remote-User: alice | 200 from the application | Remote-User reached the app only because of auth_request_set plus proxy_set_header |
302 Location: https://auth.example.com/login | 500 Internal Server Error | Error log: auth request unexpected status: 302 while sending to client |
401 with WWW-Authenticate and Location | 401 with nginx's default error page | WWW-Authenticate passed through, Location was not, the auth service's body was not |
403 with a body | 403 with nginx's default error page | The auth service's body was replaced |
So on nginx the auth service must answer 401, and nginx must build the redirect itself. Two directives do that: auth_request_set captures a header from the subrequest's response into a variable, and error_page 401 sends the browser somewhere using it. Authelia's NGINX snippet does it in two lines:
auth_request /internal/authelia/authz;
auth_request_set $redirection_url $upstream_http_location;
error_page 401 =302 $redirection_url;authentik's template does the same with a named location, which also lets it set a cookie on the way out:
auth_request /outpost.goauthentik.io/auth/nginx;
error_page 401 = @goauthentik_proxy_signin;
auth_request_set $auth_cookie $upstream_http_set_cookie;
add_header Set-Cookie $auth_cookie;
location @goauthentik_proxy_signin {
internal;
add_header Set-Cookie $auth_cookie;
return 302 /outpost.goauthentik.io/start?rd=$scheme://$ak_http_host$request_uri;
}Neither form is optional decoration. Leave out error_page and users who are not logged in get a bare 401 page with no way to reach the portal. Point nginx at an endpoint that redirects instead of answering 401 and every unauthenticated request is a 500.
Traefik's documentation states its rule in one sentence: "If the service answers with a 2XX code, access is granted, and the original request is performed. Otherwise, the response from the authentication server is returned." Caddy's says the same: on any non-2xx status "the upstream's response is copied back to the client. This response should typically involve a redirect to login page of the authentication gateway." In the tests, a 302 from the auth service reached the client from both with its Location intact, and a 401 reached the client from Traefik with the auth service's own Location, WWW-Authenticate and body.
Why Authelia and authentik ship one endpoint per proxy#
Because of that table, the auth service has to answer differently depending on which proxy asked. Authelia exposes four default authorization endpoints, and its reference guide maps them to proxies:
| Authelia endpoint | Implementation | Used by | Reads the original request from |
|---|---|---|---|
/api/authz/forward-auth | ForwardAuth | Traefik, Caddy, HAProxy (auth-request Lua plugin), Skipper | X-Forwarded-Method, -Proto, -Host, -URI, -For |
/api/authz/auth-request | AuthRequest | nginx auth_request | X-Original-Method, X-Original-URL, X-Forwarded-For |
/api/authz/ext-authz | ExtAuthz | Envoy | Start line, Host, X-Forwarded-Proto, X-Forwarded-For |
/api/verify | Legacy | Older setups | Either set, in order of precedence |
On the AuthRequest endpoint Authelia states plainly that it "does not support automatic redirection", because there is "no support on NGINX's side to achieve this with ngx_http_auth_request_module and the redirection must be performed within the NGINX configuration". It still returns the portal URL in Location, which is what $upstream_http_location picks up. On the ForwardAuth endpoint, Authelia's general rules apply: a 302 for GET and OPTIONS requests, a 303 for other methods, a 401 when it detects an XMLHttpRequest, and a 403 when policy denies the user.
authentik follows the same split with per-proxy paths on the outpost: /outpost.goauthentik.io/auth/nginx, /outpost.goauthentik.io/auth/traefik, /outpost.goauthentik.io/auth/caddy and /outpost.goauthentik.io/auth/envoy. Its HAProxy guide uses "the outpost's nginx-compatible forward-auth endpoint", because HAProxy's auth-request plugin is in the same position as nginx. The paths are not interchangeable: each one is shaped for one proxy's handling of the reply.
What the auth service sees#
The auth service has to know which URL the user asked for, because its access rules are per host and per path. The three proxies supply that very differently.
Traefik sends five headers, listed in its documentation: X-Forwarded-Method, X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Uri and X-Forwarded-For. It also copies the client's request headers to the auth service (authRequestHeaders filters them; "If not set or empty, then all request headers are passed"), which is how the session cookie arrives. The method is GET unless preserveRequestMethod: true. A POST /a/x?q=1 reached the test auth service as:
GET /hdrs X-Forwarded-Method: POST X-Forwarded-Uri: /a/x?q=1 X-Forwarded-Proto: http X-Forwarded-Host: 10.201.4.4 X-Forwarded-For: 10.201.4.1Caddy is a reverse_proxy with a preset. Its documentation gives the expansion: method GET, rewrite <to>, header_up X-Forwarded-Method {method} and header_up X-Forwarded-Uri {uri}, "in addition to other X-Forwarded-* headers already set by reverse_proxy". Because it is a normal reverse proxy, the defaults described in the Caddy guide apply: X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host are set, and the trusted proxy rules decide whether incoming values are kept. The same POST reached the test auth service as GET /hdrs?q=1: the rewrite to uri kept the original query string, and X-Forwarded-Uri carried /a/x?q=1.
nginx sends whatever you configure on the internal location and nothing more. The module documentation's own example uses proxy_set_header X-Original-URI $request_uri;, which is a path only. Authelia and authentik do not read that header. Authelia's AuthRequest implementation takes scheme, host and path from X-Original-URL, and its snippet sets it as a full URL:
location /internal/authelia/authz {
internal;
proxy_pass $upstream_authelia;
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Original-URL $scheme://$host$request_uri;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header Content-Length "";
proxy_pass_request_body off;
}authentik's template sets X-Original-URL $scheme://$ak_http_host$request_uri on its outpost location, with a map that falls back from $http_host to $host because HTTP/3 clients are not required to send a Host header. Copying the nginx documentation's X-Original-URI into an Authelia or authentik setup is a common way to end up with policy matching on the wrong value. The header name is a convention between your nginx configuration and your auth service, not something nginx defines.
How the user's identity reaches the application#
After a 2xx, the auth service's response headers carry who the user is. Authelia returns Remote-User, Remote-Groups, Remote-Name and Remote-Email. authentik returns X-authentik-username, X-authentik-groups, X-authentik-email, X-authentik-name, X-authentik-uid and more. oauth2-proxy returns X-Auth-Request-User, X-Auth-Request-Groups, X-Auth-Request-Email and X-Auth-Request-Preferred-Username when --set-xauthrequest is on, an option its documentation describes as "useful in Nginx auth_request mode". None of the three proxies forwards these to the application unless told to.
| How you name the headers | Renaming | Client sends the header, auth service omits it | |
|---|---|---|---|
| nginx | auth_request_set $user $upstream_http_remote_user; then proxy_set_header Remote-User $user;, one pair per header | Free: the proxy_set_header name is yours | Stripped: the variable is empty and an empty proxy_set_header value is not sent (tested) |
| Traefik | authResponseHeaders: [Remote-User, ...], or authResponseHeadersRegex | Not available | Stripped (tested); the documentation says listed headers replace "any existing conflicting headers" |
| Caddy | copy_headers Remote-User Remote-Groups ... | Remote-User>X-Auth-User | Stripped (tested) |
The test sent Remote-User: mallory from the client through each proxy to an auth service that answered 200 with no Remote-User header at all. On all three, the application saw an empty Remote-User. That protection exists only for the headers you have listed. A header the application trusts but your proxy configuration does not mention is passed from the client untouched, which is the general problem covered in client IP spoofing through proxies.
Two details that cost people an afternoon. nginx's $upstream_http_ variables use the header name lower-cased with dashes turned into underscores, so X-authentik-username is $upstream_http_x_authentik_username. And authentik's Caddy template warns "capitalization of the headers is important, otherwise they will be empty", writing them as X-Authentik-Username in copy_headers.
Identity headers and session cookies can also get large. authentik's nginx template raises proxy_buffers 8 16k and proxy_buffer_size 32k for the upstream sent too big header error, which is explained in header and body size limits.
Request bodies#
No proxy should send a 50 MB upload to the auth service, and by default none of the documented setups does, but for three different reasons.
- nginx. The subrequest is a GET, but it carries the request body unless you stop it: in the test, an auth location without the two lines below received the client's 11 byte POST body. The module documentation's example sets
proxy_pass_request_body off;andproxy_set_header Content-Length "";on the auth location, and both Authelia's and authentik's templates copy those two lines. Remove them and the auth service receives the body. - Traefik.
forwardBodydefaults tofalse. Setting it totruesends the body, and the documentation warns that "As body is read inside Traefik before forwarding, this breaks streaming."maxBodySizethen limits it; the default is-1, unlimited, and a body over the limit gets a 401. Its siblingmaxResponseBodySizelimits the auth service's reply and is also unlimited by default; Traefik 3.7.14 logs a warning at startup for every ForwardAuth middleware without it, and authentik's Traefik template setsmaxResponseBodySize: 4194304. - Caddy. The auth request is always GET, with the comment in the documentation's expansion: "Always GET, so that the incoming request's body is not consumed". In the test, a POST with an 11 byte body reached the application with its
Content-Length: 11, and the auth request had no body.
If you need the auth decision to depend on the body (signature checks, for example), only Traefik's forwardBody does it without writing your own expanded configuration, and it gives up streaming to do so.
The security edge: who is allowed to set X-Forwarded-Uri#
Authelia's ForwardAuth implementation makes its policy decision from X-Forwarded-Host and X-Forwarded-URI. If a client can set those, it can ask "may I see /public?" while actually requesting /admin. The proxy must therefore overwrite them, or accept them only from proxies you trust.
On Traefik this is the job of the entryPoint, and the middleware option for it is on its way out. The ForwardAuth documentation says trustForwardHeader "is deprecated and will be removed in the next major version", and gives the replacement: "Configure the trusted IPs at the EntryPoint level using forwardedHeaders.trustedIPs, and set trustForwardHeader to true on this middleware." The entryPoint "strips any such headers sent by untrusted clients and only preserves those coming from trusted upstream proxies". If you set neither, Traefik logs a warning at startup and uses a legacy behaviour in which some forwarded headers are removed "but others (e.g. X-Forwarded-Prefix) are forwarded untouched".
Tested on Traefik 3.7.14, with the client adding X-Forwarded-Uri: /spoofed to its request:
| EntryPoint | trustForwardHeader | Auth service received |
|---|---|---|
Default, no forwardedHeaders | false | The real URI |
Default, no forwardedHeaders | true | The real URI (the entryPoint discarded the client's value) |
forwardedHeaders.insecure=true | true | /spoofed |
authentik's Traefik templates set trustForwardHeader: true. That is safe exactly as long as the entryPoint does not trust the client, which is the default, and unsafe on an entryPoint with forwardedHeaders.insecure or with trustedIPs wide enough to include clients. The Traefik guide covers the entryPoint settings, and configuring trusted proxies explains how to size trustedIPs.
Caddy's forward_auth sets X-Forwarded-Uri itself from {uri}, so a client value is overwritten; in the test the auth service received the real URI. X-Forwarded-Host and X-Forwarded-For follow Caddy's trusted_proxies setting. authentik's Caddy template adds trusted_proxies private_ranges inside forward_auth with the comment that it "should probably be set to the outposts IP"; trusting every private range also trusts any client on your LAN.
On nginx the risk sits in your own proxy_set_header lines. X-Original-URL $scheme://$host$request_uri is built from the request nginx actually received, so it is safe. Passing a client header through, for example proxy_set_header X-Forwarded-Host $http_x_forwarded_host;, hands the decision to the client. Authelia's snippet sets X-Forwarded-For $remote_addr and relies on the realip module's set_real_ip_from to decide which upstream proxies may change $remote_addr; its documentation asks you to "only include the specific IP address ranges of the trusted proxies within your architecture". The mechanics of that header are in the X-Forwarded-For guide.
Check that nginx has the module#
auth_request is not part of a default nginx build. The documentation says: "This module is not built by default, it should be enabled with the --with-http_auth_request_module configuration parameter." It has existed since 1.5.4. Check before debugging anything else:
nginx -V 2>&1 | tr ' ' '\n' | grep auth_requestNo output means the binary does not have it, and nginx will reject the auth_request directive as unknown. The official nginx:1.27-alpine image prints --with-http_auth_request_module. Authelia's NGINX requirements also list the http_realip module, and http_set_misc only for its legacy redirect method. Distribution packages and minimal custom builds are where it goes missing; Traefik and Caddy include their forward auth features in every build.
Which one to pick#
The forward auth mechanism should not decide your proxy; the reverse proxy comparison covers the axes that should. Once the proxy is chosen:
- nginx. Use the auth service's nginx endpoint (Authelia
/api/authz/auth-request, authentik/outpost.goauthentik.io/auth/nginx, oauth2-proxy/oauth2/auth), setX-Original-URLandX-Original-Methodyourself, keepproxy_pass_request_body off, adderror_page 401with theLocationcaptured byauth_request_set, and write oneauth_request_setplusproxy_set_headerpair per identity header. It is the most configuration, and every piece is visible. The nginx reverse proxy guide covers the surroundingproxy_set_headerrules. - Traefik. Use the ForwardAuth endpoint, list identity headers in
authResponseHeaders, settrustForwardHeaderexplicitly, keep the entryPoint'sforwardedHeaderstight, and setmaxResponseBodySize. AddauthSigninURLonly for auth services that answer 401 instead of redirecting. - Caddy. Use the ForwardAuth endpoint with
forward_authandcopy_headers; it is the shortest configuration of the three. Drop to the expandedreverse_proxyform only when the auth service needs a redirect built for it.
Where this stops applying#
The nginx behaviour described here is the open source ngx_http_auth_request_module. ingress-nginx on Kubernetes configures it through auth-url, auth-signin and auth-response-headers annotations instead of directives; authentik's nginx page shows that form. Traefik option names and defaults are from the current reference documentation and were tested on 3.7.14; trustForwardHeader will be removed in the next major version, so check the reference for your release before copying an older example. Caddy behaviour was tested on 2.11.7. Envoy uses a different interface, ext_authz, which Authelia serves from its own endpoint and which is not compared here.
Frequently asked questions#
Why does nginx return 500 with auth_request when Authelia redirects?#
Because the auth service answered with a 302, and auth_request treats any status other than 2xx, 401 or 403 as an error. The error log shows auth request unexpected status: 302. Point nginx at the AuthRequest endpoint (/api/authz/auth-request), which answers 401 with the portal URL in Location, and add auth_request_set $redirection_url $upstream_http_location; and error_page 401 =302 $redirection_url;.
Can I use the same Authelia or authentik endpoint for nginx and Traefik?#
No. The Traefik and Caddy endpoints redirect unauthenticated users with a 302 or 303, which nginx turns into a 500. The nginx endpoints answer 401 and expect nginx to redirect. Authelia uses /api/authz/forward-auth for Traefik and Caddy and /api/authz/auth-request for nginx; authentik uses /outpost.goauthentik.io/auth/traefik, /auth/caddy and /auth/nginx.
Does Traefik ForwardAuth send the request body to the auth server?#
Not by default. forwardBody defaults to false. When set to true, Traefik reads the body before forwarding, which breaks streaming, and maxBodySize (default -1, unlimited) caps it, returning a 401 for anything larger.
Is Traefik trustForwardHeader deprecated?#
Yes. The documentation says it will be removed in the next major version and recommends configuring forwardedHeaders.trustedIPs on the entryPoint and setting trustForwardHeader to true on the middleware, so the entryPoint strips forwarded headers from untrusted clients before ForwardAuth runs. Leaving it unset logs a warning and uses an inconsistent legacy behaviour.
How do I rename Remote-User in Caddy forward_auth?#
Use > in copy_headers: copy_headers Remote-User>X-Auth-User. Caddy copies the header from the auth service's 2xx response onto the original request under the new name. In the test, a Remote-User sent by the client did not reach the application when the auth service omitted it.
Why does oauth2-proxy show a 401 page instead of a login redirect behind Traefik?#
Because /oauth2/auth only ever returns 202 or 401, and Traefik returns the 401 as it is. Set authSigninURL on the ForwardAuth middleware to redirect 401s to the sign-in URL, or use nginx's error_page 401 pattern if nginx is the proxy.
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.
- nginx ngx_http_auth_request_module
- Traefik ForwardAuth middleware (HTTP)
- Caddy documentation, forward_auth directive
- Authelia, proxy integration introduction (response statuses and headers)
- Authelia, proxy authorization reference (ForwardAuth, AuthRequest, ExtAuthz implementations)
- Authelia, NGINX integration (authelia-location.conf, authelia-authrequest.conf)
- authentik, proxy provider and headers sent upstream
- authentik, forward auth modes
- authentik, nginx forward auth template
- authentik, Traefik forward auth template
- authentik, Caddy forward auth template
- OAuth2 Proxy endpoints (/oauth2/auth)
- OAuth2 Proxy configuration overview (set_xauthrequest)
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.