What if the Authentik docs for NGINX forward auth are fundamentally incomplete for real-world deployments?
The Assumption Everyone Makes
Authentik publishes a clean NGINX configuration example for forward auth. Copy the snippet, create a provider, assign an outpost, reload NGINX. The assumption is that a well-documented identity platform with 20,000 GitHub stars has a forward auth setup that works as described. Fifteen minutes, tops.
What Actually Happens
The documentation assumes a single-server Docker Compose deployment where NGINX and Authentik share a network. The moment you introduce real-world infrastructure — separate servers, Cloudflare Tunnels, CDN proxying, WordPress on bare metal — every assumption breaks. You’ll hit a cascade of failures, each with a different HTTP status code, each pointing to a different cause, none of them documented. The GitHub issue tracker has over a dozen open issues about this exact scenario, including a confirmed bug where the embedded outpost simply doesn’t register forward auth endpoints.
Why This Matters More Than You Think
This isn’t a configuration annoyance. Forward auth is the gateway to protecting any web application with centralized identity management. If you can’t get past the setup, you’re stuck with per-application authentication — WordPress plugins, basic auth, or nothing at all. Every hour spent debugging undocumented NGINX interactions is an hour your application sits unprotected or you’re tempted to skip the auth layer entirely.
The real cost is in the debugging. Each error looks like a different problem. You fix one, the next appears, and without a complete reference, you can’t tell whether you’re making progress or going in circles. What follows is the complete map of every obstacle and its fix, in the order you’ll encounter them.
The Complete Implementation Guide
Architecture
This guide covers the following setup:
- Web server: Standalone NGINX serving WordPress (or any PHP/static application) via PHP-FPM — not a reverse proxy
- Authentik server: Running on a separate machine, accessible via a Cloudflare Tunnel (e.g.,
auth.example.com) - DNS: Application domain (e.g.,
app.example.com) proxied through Cloudflare (orange cloud)
Replace these placeholders throughout:
| Placeholder | Description |
|---|---|
app.example.com | Your application domain |
auth.example.com | Your Authentik domain |
outpost.example.com | The outpost’s Cloudflare Tunnel hostname |
example.com | Your base domain |
Step 1: The map Directive (HTTP Context)
Add this outside any server block, in nginx.conf within the http {} context or in an included file at that level:
map $http_upgrade $connection_upgrade_keepalive {
default upgrade;
'' '';
}
Step 2: Authentik Provider
In Authentik, go to Applications → Providers → Create:
| Setting | Value |
|---|---|
| Type | Proxy Provider |
| Name | app-example-com-forward-auth |
| Authorization flow | default-provider-authorization-explicit-consent |
| Mode | Forward auth (single application) |
| External host | https://app.example.com (no trailing slash) |
Leave Cookie domain blank for single-application forward auth. Leave Authentication flow and Invalidation flow empty under Advanced — they use sensible tenant defaults.
Critical: The mode must be Forward auth (single application), not “Proxy.” You cannot change the mode after creation. If you chose wrong, delete the provider and start over.
Step 3: Authentik Application
Go to Applications → Applications → Create:
| Setting | Value |
|---|---|
| Name | App Example |
| Slug | app-example-com |
| Provider | app-example-com-forward-auth |
| Launch URL | https://app.example.com |
Step 4: The Embedded Outpost Bug
As of Authentik 2026.2.0 (and reported across versions going back to 2025.8.x), the embedded outpost has a confirmed bug where the /outpost.goauthentik.io/auth/nginx endpoint returns 404 even with correct configuration. This is tracked across multiple GitHub issues including #19839, #20458, and #20631.
Workaround: Deploy a separate proxy outpost container.
Add to your docker-compose.yml on the Authentik server:
services:
proxy-outpost:
image: ghcr.io/goauthentik/proxy:2026.2.0
ports:
- "9001:9000"
- "9444:9443"
environment:
AUTHENTIK_HOST: http://server:9000
AUTHENTIK_HOST_BROWSER: https://auth.example.com
AUTHENTIK_INSECURE: "true"
AUTHENTIK_TOKEN: "<outpost-api-token>"
restart: unless-stopped
To get the token:
- In Authentik, go to Applications → Outposts
- Create a new outpost with type Proxy (do not use the embedded one)
- Select your application under the outpost’s applications
- After saving, find the auto-generated token under System → Tokens and App passwords — it’s named
ak-outpost-<your-outpost-name>-api - Copy that token into
AUTHENTIK_TOKEN
AUTHENTIK_HOST is the Docker-internal address the outpost uses for API calls. AUTHENTIK_HOST_BROWSER is what appears in browser redirects. Without AUTHENTIK_HOST_BROWSER, the outpost redirects browsers to http://server:9000 — a Docker-internal hostname that the browser cannot resolve.
Verify the outpost works locally:
curl -sk -o /dev/null -w "%{http_code}" \
-H "X-Original-URL: https://app.example.com/" \
http://localhost:9001/outpost.goauthentik.io/auth/nginx
This must return 401. If it returns 404, the outpost hasn’t picked up the application config — restart with docker compose down && docker compose up -d and retry after 30 seconds.
Step 5: Expose the Outpost via Cloudflare Tunnel
In Cloudflare Zero Trust, go to Networks → Tunnels, edit your existing tunnel, and add a new public hostname:
| Setting | Value |
|---|---|
| Subdomain | outpost |
| Domain | example.com |
| Service | http://localhost:9001 (if cloudflared runs on the host) or http://proxy-outpost:9000 (if cloudflared runs in Docker on the same network) |
Important: Do not enable any Access policy on this hostname. Cloudflare recently started auto-enabling Access policies when adding tunnel hostnames. If the outpost hostname is protected by Cloudflare Access, the auth flow will receive a 403 from Cloudflare before it ever reaches Authentik.
Verify reachability:
curl -sk -o /dev/null -w "%{http_code}" \
-H "X-Original-URL: https://app.example.com/" \
https://outpost.example.com/outpost.goauthentik.io/auth/nginx
Must return 401.
Step 6: NGINX Server Block Configuration
Here is the complete set of additions to your existing server block. Each element addresses a specific failure mode discovered during implementation.
6a: Auth Request at Server Level
Add near the top of the server block:
# Authentik forward auth
auth_request /outpost.goauthentik.io/auth/nginx;
error_page 401 403 = @goauthentik_proxy_signin;
auth_request_set $auth_cookie $upstream_http_set_cookie;
add_header Set-Cookie $auth_cookie;
# Buffer sizes for Authentik's large headers
proxy_buffers 8 16k;
proxy_buffer_size 32k;
Why 401 403: The Authentik outpost returns 403 (not 401) for unauthenticated requests in some configurations. If you only catch 401, unauthenticated users see a raw NGINX 403 page instead of being redirected to the login screen.
6b: Outpost Location Block
location ^~ /outpost.goauthentik.io {
resolver 1.1.1.1 valid=300s;
set $outpost_upstream https://outpost.example.com;
proxy_ssl_server_name on;
proxy_ssl_name outpost.example.com;
proxy_pass $outpost_upstream;
proxy_set_header Host outpost.example.com;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
add_header Set-Cookie $auth_cookie;
auth_request_set $auth_cookie $upstream_http_set_cookie;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
auth_request off;
}
There are five specific fixes in this block, each addressing a different failure:
^~modifier — Without this, NGINX regex location blocks take priority. If you have a common WordPress security rule likelocation ~* /\.(?!well-known\/) { deny all; }, it matches/outpost.goauthentik.io(because of.goauthentik) and returns 403 before the outpost location is ever evaluated. The^~prefix tells NGINX to use this location and stop checking regex patterns.set $outpost_upstreamas a variable — When the upstream hostname is in a variable, NGINX uses theresolverdirective for DNS resolution at runtime instead of only at startup. Cloudflare’s edge IPs can change; without this, NGINX caches the IP from config load and eventually sends traffic to a stale address.proxy_ssl_server_name on+proxy_ssl_name— When NGINX connects to Cloudflare over TLS, it must send an SNI (Server Name Indication) header so Cloudflare knows which site to route to. By default, NGINX does not send SNI whenproxy_passuses a variable. Without these directives, Cloudflare receives a TLS connection with no SNI and returns 403.proxy_set_header Host outpost.example.com— Not$host. Cloudflare Tunnel routes based on the Host header. If you pass$host(which resolves toapp.example.com), Cloudflare doesn’t match it to any tunnel hostname and returns a 301 redirect, which NGINX reports asauth request unexpected status: 301.proxy_pass $outpost_upstreamwithout a trailing path — If you writeproxy_pass $outpost_upstream/outpost.goauthentik.io, NGINX appends the full request URI after the path, resulting in a doubled path like/outpost.goauthentik.io/outpost.goauthentik.io/start. The outpost doesn’t recognize this and returns 404. Use just$outpost_upstreamand let NGINX pass the original URI as-is.
And auth_request off — This location must not trigger auth_request itself, or you create an infinite loop: NGINX asks the outpost to authenticate, the outpost request triggers another auth check, and so on.
6c: Sign-in Redirect Location
location @goauthentik_proxy_signin {
internal;
add_header Set-Cookie $auth_cookie;
return 302 /outpost.goauthentik.io/start?rd=$scheme://$http_host$request_uri;
}
6d: Pass Authentik Headers to PHP-FPM
Since this is a WordPress/PHP-FPM setup (not a reverse proxy), you pass authenticated user information via fastcgi_param instead of proxy_set_header. Add these inside your location ~ \.php$ block:
auth_request_set $authentik_username $upstream_http_x_authentik_username;
auth_request_set $authentik_email $upstream_http_x_authentik_email;
auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
auth_request_set $authentik_uid $upstream_http_x_authentik_uid;
fastcgi_param HTTP_X_AUTHENTIK_USERNAME $authentik_username;
fastcgi_param HTTP_X_AUTHENTIK_EMAIL $authentik_email;
fastcgi_param HTTP_X_AUTHENTIK_GROUPS $authentik_groups;
fastcgi_param HTTP_X_AUTHENTIK_UID $authentik_uid;
6e: Disable Auth for Static Assets
Every static file request (CSS, JS, images) triggers an auth subrequest to Authentik through Cloudflare’s network and back. That adds real latency to every asset. Disable it:
location ~* ^.+\.(ogg|ogv|svg|svgz|eot|otf|woff|woff2|mp4|ttf|ttc|rss|atom|jpg|jpeg|gif|png|ico|webp|zip|tgz|gz|rar|bz2|doc|xls|exe|ppt|tar|mid|midi|wav|bmp|rtf|css|js)$ {
auth_request off;
access_log off;
log_not_found off;
expires max;
add_header Cache-Control "public";
}
Step 7: Verify and Reload
nginx -t && systemctl reload nginx
Visit https://app.example.com. You should be redirected to https://auth.example.com for login. After authenticating, you land back on your application.
The Error Code Cheat Sheet
| Error | Cause | Fix |
|---|---|---|
500, log says unexpected status: 301 | NGINX sends Host: $host to outpost; Cloudflare redirects because it doesn’t recognize the hostname | Set proxy_set_header Host outpost.example.com |
500, log says unexpected status: 404 | Embedded outpost bug — endpoint not registered | Deploy a separate proxy outpost container |
| 403 from NGINX, no error log entry | Dotfile deny rule matches .goauthentik in the path | Use ^~ modifier on the outpost location |
| 403 from NGINX, outpost location matches | Server-level error_page only catches 401, not 403 | Change to error_page 401 403 = @goauthentik_proxy_signin |
| 403 from Cloudflare | Cloudflare Access policy on the outpost hostname, or missing SNI | Remove Access policy; add proxy_ssl_server_name on and proxy_ssl_name |
302 redirects to http://server:9000/... | Outpost uses Docker-internal hostname for browser redirects | Set AUTHENTIK_HOST_BROWSER=https://auth.example.com on the outpost container |
| 404 from outpost on localhost | Outpost hasn’t loaded application config | Restart Authentik stack; verify outpost has the application selected |
| 502 via Cloudflare Tunnel | cloudflared can’t reach the outpost container | Fix tunnel service to match the outpost’s reachable address and port |
| Doubled path in proxy | proxy_pass includes a path that gets appended to the URI | Use proxy_pass $outpost_upstream without a trailing path |
| DNS resolution fails | NGINX caches DNS from config load time | Add resolver 1.1.1.1 valid=300s and use a variable in proxy_pass |
The Questions You Should Be Asking Instead
Instead of “How do I add forward auth to NGINX?” — ask these:
- Does my outpost actually serve the auth endpoint, or is it returning 404 silently?
- Is every proxy between my NGINX and Authentik (Cloudflare, tunnel, Docker network) passing the correct Host header and SNI?
- Which of my NGINX security rules fire before my outpost location block gets evaluated?
- Am I catching all the status codes the outpost returns, or just the ones the docs mention?
- Is the latency of routing every auth subrequest through a CDN acceptable, or should I colocate a local outpost?
Where Most People Get Stuck
“The docs say to use proxy_set_header Host $host in the outpost location.” That works when NGINX and Authentik share a network. When a Cloudflare Tunnel sits between them, $host resolves to your application domain, which Cloudflare doesn’t match to the outpost’s tunnel hostname. Hard-code the outpost hostname instead.
“I configured everything correctly but the outpost returns 404.” The embedded outpost has a confirmed bug. Don’t spend hours rechecking your configuration — deploy a separate proxy outpost container and move on. The issue tracker has over a dozen reports of this spanning multiple versions.
“I restarted NGINX and nothing changed.” When proxy_pass uses a variable, NGINX resolves DNS via the resolver directive. If you don’t have one, or it points to a stale cache, NGINX keeps connecting to the old IP. Always pair resolver with variable-based proxy_pass.
“I get 403 but there’s nothing in the error log.” NGINX’s deny all directives return 403 without logging to the error log. Use debug headers (add_header X-Debug-Location "name" always;) in each location block to identify which block handles the request.
Strategic Opposition Principle: The documentation describes the happy path. Real infrastructure never takes the happy path.
Related Field Notes: Your Page Builder Is Getting You Banned From Your Own API, Your All-in-One Security Plugin is a Single Point of Failure