Authentik's forward auth documentation assumes NGINX and Authentik share a Docker network. Real infrastructure — separate servers, Cloudflare Tunnels, multiple domains, WordPress on bare metal — breaks every assumption in that snippet. The embedded outpost has a confirmed bug. Cookie domains contaminate each other when outposts are shared. Callback hostnames must match cookie domains or sessions silently fail. This is the complete implementation guide for the setup the docs never cover, including the error cheat sheet for every status code you'll hit along the way.
Question: 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, multiple domains — 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 Architecture That Actually Works
After extensive trial and error, this is the architecture that eliminates all the routing, Host header, SNI, and cookie domain conflicts:
Browser → Cloudflare CDN → NGINX (web server)
├── serves site normally
├── auth subrequest → Tailscale → outpost container (on Authentik server)
└── redirect to auth.example.com for login
Authentik server (Docker):
├── authentik-server (port 9000)
├── authentik-worker
├── postgresql / redis
├── outpost-single (port 9001) — for standalone domains
├── outpost-domain-a (port 9002) — for *.domain-a.com subdomains
└── outpost-domain-b (port 9003) — for *.domain-b.com subdomains (and so on)
Key principles:
- NGINX talks to outpost containers directly via Tailscale — no Cloudflare Tunnel in the auth path. This eliminates all Host header and SNI conflicts.
- Each cookie domain gets its own outpost container on its own port. Mixing providers with different cookie domains on one outpost causes cookie contamination.
- Each cookie domain gets its own Cloudflare Tunnel hostname under that domain (e.g.,
auth.domain-a.com) for browser callbacks. Cookies don’t cross domains. - Standalone domains (single-app forward auth) share one outpost. Domain-level forward auth domains each get their own.
Replace these placeholders throughout:
| Placeholder | Description |
|---|---|
app.example.com | Any application domain you’re protecting |
auth.example.com | Your main Authentik domain |
TAILSCALE_IP | Tailscale IP of the Authentik server |
Part 1: 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.
Do not use the embedded outpost for forward auth. Always deploy separate proxy outpost containers.
Part 2: Protecting a Standalone Domain (Single-App Forward Auth)
Use this for any domain that doesn’t share a cookie with other subdomains — e.g., familie.daus.pro, client-site.com, or any one-off site.
2a: Authentik Provider
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. Leave Authentication flow and Invalidation flow empty under Advanced.
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.
2b: 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 |
2c: Outpost Container
All standalone domains share one outpost. On the Authentik server’s docker-compose.yml:
outpost-single:
image: ghcr.io/goauthentik/proxy:2026.2.0
ports:
- "9001:9000"
environment:
AUTHENTIK_HOST: http://server:9000
AUTHENTIK_HOST_BROWSER: https://auth.example.com
AUTHENTIK_INSECURE: "true"
AUTHENTIK_TOKEN: "<token>"
restart: unless-stopped
In Authentik, go to Applications → Outposts → Create:
| Setting | Value |
|---|---|
| Name | Outpost Single Apps |
| Type | Proxy |
| Applications | Select all standalone domain applications |
After saving, find the token under System → Tokens and App passwords (named ak-outpost-outpost-single-apps-api). Copy it into the AUTHENTIK_TOKEN value.
AUTHENTIK_HOST is the Docker-internal address for API calls. AUTHENTIK_HOST_BROWSER is what appears in browser redirects — without it, the outpost redirects browsers to http://server:9000, a Docker-internal hostname the browser cannot resolve.
Adding more standalone domains later: Create the provider and application, add the application to this same outpost, restart the container. No new containers needed.
2d: NGINX Configuration
On the web server, create two snippet files:
/etc/nginx/snippets/authentik-forward-auth.conf:
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;
proxy_buffers 8 16k;
proxy_buffer_size 32k;
Why 401 403: The 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 login.
/etc/nginx/snippets/authentik-outpost-single.conf:
# Auth subrequest — used by auth_request internally
location = /outpost.goauthentik.io/auth/nginx {
proxy_pass http://TAILSCALE_IP:9001;
proxy_set_header Host $host;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
}
# Browser-facing outpost paths — /start, /callback, etc.
location ^~ /outpost.goauthentik.io {
proxy_pass http://TAILSCALE_IP:9001;
proxy_set_header Host $host;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
}
location @goauthentik_proxy_signin {
internal;
add_header Set-Cookie $auth_cookie;
return 302 /outpost.goauthentik.io/start?rd=$scheme://$http_host$request_uri;
}
Why ^~ on the outpost location: NGINX regex location blocks take priority over prefix matches. A common WordPress security rule like location ~* /\.(?!well-known\/) { deny all; } 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.
Why $host works here: Because NGINX talks to the outpost over Tailscale, not through Cloudflare. No intermediate proxy rewrites the Host header. The outpost sees Host: app.example.com and matches it to the correct provider.
Include in the server block:
server {
server_name app.example.com;
# ... SSL, root, etc.
include snippets/authentik-forward-auth.conf;
include snippets/authentik-outpost-single.conf;
location / {
try_files $uri $uri/ /index.php?$args;
}
# Static assets — skip auth to avoid latency
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";
}
# ... PHP block, other locations
}
2e: Passing Auth Headers to PHP-FPM
Since this is a WordPress/PHP-FPM setup (not a reverse proxy), pass authenticated user information via fastcgi_param. 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;
Part 3: Protecting Multiple Subdomains Under One Domain (Domain-Level Forward Auth)
Use this when you have multiple subdomains under one domain (e.g., alpha.example.dev, beta.example.dev) and want one login to cover all of them.
3a: Authentik Provider
| Setting | Value |
|---|---|
| Type | Proxy Provider |
| Name | example-dev-domain-forward-auth |
| Authorization flow | default-provider-authorization-explicit-consent |
| Mode | Forward auth (domain level) |
| External host | https://example.dev |
| Authentication URL | https://auth.example.dev |
| Cookie domain | example.dev |
The Authentication URL must be under the same domain as the cookie. If the cookie domain is example.dev, the Authentication URL must be auth.example.dev (not auth.otherdomain.com or any other domain). The outpost sets its session cookie on .example.dev during /start, and the browser only sends that cookie back to *.example.dev hosts. If the callback goes to a different domain, the session cookie is missing and you get “mismatched session ID” errors.
3b: Authentik Applications
Create one application per subdomain for per-subdomain access control:
| Name | Slug | Provider | Launch URL |
|---|---|---|---|
Alpha Dev | alpha-example-dev | example-dev-domain-forward-auth | https://alpha.example.dev |
Beta Dev | beta-example-dev | example-dev-domain-forward-auth | https://beta.example.dev |
All applications share the same provider.
3c: Outpost Container
Each cookie domain gets its own outpost container. Do not mix domain-level and single-app providers on the same outpost — the outpost applies the domain-level cookie to all responses, breaking single-app sessions.
outpost-example-dev:
image: ghcr.io/goauthentik/proxy:2026.2.0
ports:
- "9002:9000"
environment:
AUTHENTIK_HOST: http://server:9000
AUTHENTIK_HOST_BROWSER: https://auth.example.com
AUTHENTIK_INSECURE: "true"
AUTHENTIK_TOKEN: "<token>"
restart: unless-stopped
Create a corresponding outpost in Authentik with all the subdomain applications selected.
3d: Cloudflare Tunnel Hostname for Callbacks
The outpost needs a browser-reachable hostname under the cookie domain for OAuth callbacks. In Cloudflare, add a tunnel hostname:
| Setting | Value |
|---|---|
| Subdomain | auth |
| Domain | example.dev |
| Service | http://localhost:9002 or http://outpost-example-dev:9000 |
Do not enable any Access policy on this hostname. Cloudflare recently started auto-enabling Access policies when adding tunnel hostnames.
This hostname (auth.example.dev) is the Authentication URL set on the provider. The browser hits it directly during the callback — it doesn’t go through any NGINX server.
3e: NGINX Configuration
On each web server hosting a subdomain, create the snippet files:
/etc/nginx/snippets/authentik-forward-auth.conf — same as Part 2.
/etc/nginx/snippets/authentik-outpost-domain-example.conf:
location = /outpost.goauthentik.io/auth/nginx {
proxy_pass http://TAILSCALE_IP:9002;
proxy_set_header Host $host;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
}
location ^~ /outpost.goauthentik.io {
proxy_pass http://TAILSCALE_IP:9002;
proxy_set_header Host $host;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
}
location @goauthentik_proxy_signin {
internal;
add_header Set-Cookie $auth_cookie;
return 302 /outpost.goauthentik.io/start?rd=$scheme://$http_host$request_uri;
}
The only difference from the single-app snippet is the port number (9002 vs 9001).
Include in each subdomain’s server block:
server {
server_name alpha.example.dev;
include snippets/authentik-forward-auth.conf;
include snippets/authentik-outpost-domain-example.conf;
# ... rest of config
}
3f: How Domain-Level Auth Flow Works
- User visits
alpha.example.dev - NGINX auth subrequest → outpost on port 9002 via Tailscale → returns 401
- NGINX redirects to
/outpost.goauthentik.io/start?rd=https://alpha.example.dev/ - Outpost redirects browser to
auth.example.com/application/o/authorize/withredirect_uri=https://auth.example.dev/outpost.goauthentik.io/callback - User logs in at
auth.example.com - Authentik redirects browser to
https://auth.example.dev/outpost.goauthentik.io/callback - Browser hits
auth.example.dev→ Cloudflare Tunnel → outpost container - Outpost processes callback, sets cookie on
.example.dev, redirects toalpha.example.dev - User visits
beta.example.dev→ cookie already valid → no login required
Part 4: Protecting Subdirectories Only
Instead of putting auth_request at server level (via the snippet), put it in a specific location:
server {
server_name app.example.com;
# Outpost locations — always included
include snippets/authentik-outpost-single.conf;
# Public — no auth
location / {
try_files $uri $uri/ /index.php?$args;
}
# Protected subdirectory
location /admin {
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;
try_files $uri $uri/ /index.php?$args;
}
}
Note: authentik-forward-auth.conf is not included at server level here — the auth_request directives are only in the /admin location. The outpost locations snippet is still included because NGINX needs the outpost proxy and redirect locations regardless.
No changes needed in Authentik — the provider’s external host matches the domain, and the outpost handles auth for any path.
Part 5: Managing Users and Access Control
Creating External Users
Go to Directory → Users → Create:
| Setting | Value |
|---|---|
| Username | client-a |
| Name | Client A |
[email protected] | |
| Is active | Yes |
Set a password, or use Stages → Invitations to create an invitation link the user can use to set their own password.
Creating Groups
Go to Directory → Groups → Create:
| Setting | Value |
|---|---|
| Name | client-alpha |
| Members | Add relevant users |
Restricting Application Access
On each Application, go to Policy / Group / User Bindings → Create Binding:
| Setting | Value |
|---|---|
| Group | client-alpha |
| Enabled | Yes |
| Order | 0 |
Now only users in client-alpha can access that application. Other authenticated users see a “Permission denied” page.
Example Access Matrix
| User | Groups | Can access |
|---|---|---|
| admin | admins | Everything |
| [email protected] | client-alpha | alpha.example.dev only |
| [email protected] | client-alpha, client-beta | alpha.example.dev, beta.example.dev |
Part 6: Adding New Sites (Quick Reference)
New subdomain under an existing domain (~5 minutes)
- Authentik: Create application → bind to existing domain-level provider
- Authentik: Add application to the domain’s outpost
- Authentik server:
docker compose restart outpost-domain-name - Web server NGINX: Include the two snippet files in the server block, reload
New standalone domain (~10 minutes)
- Authentik: Create provider (Forward auth, single application) + application
- Authentik: Add application to the single-apps outpost
- Authentik server:
docker compose restart outpost-single - Web server NGINX: Include the two snippet files (port 9001), reload
Entirely new domain with multiple subdomains (~20 minutes)
- Authentik: Create provider (Forward auth, domain level) — set Authentication URL to
auth.newdomain.com, cookie domain tonewdomain.com - Authentik: Create applications for each subdomain, bind to provider
- Authentik: Create new outpost, select all applications
- Authentik server: Add new outpost container on next port (9003, 9004, etc.) to
docker-compose.yml, bring stack up - Cloudflare: Add tunnel hostname
auth.newdomain.com→http://localhost:PORT - Web server NGINX: Create snippet pointing to the new port, include in server blocks, reload
New user (~2 minutes)
- Directory → Users → Create — set username, email, password
- Directory → Groups — add user to appropriate group(s)
Restrict access to an application (~2 minutes)
- Applications → [App] → Policy / Group / User Bindings → Create Binding
- Select group, enable, save
The Error Code Cheat Sheet
| Error | Cause | Fix |
|---|---|---|
500, log says unexpected status: 301 | NGINX sends wrong Host to outpost via Cloudflare; tunnel redirects | Use Tailscale instead of Cloudflare Tunnel for auth path |
500, log says unexpected status: 404 | Embedded outpost bug — endpoint not registered | Deploy separate proxy outpost containers |
| 403 from NGINX, no error log entry | Dotfile deny rule matches .goauthentik in path | Use ^~ modifier on outpost location |
| 403 from NGINX, correct location matches | error_page only catches 401, not 403 | Change to error_page 401 403 = @goauthentik_proxy_signin |
| 403 from Cloudflare | Access policy on tunnel hostname, or missing SNI when routing through Cloudflare | Use Tailscale instead; or add proxy_ssl_server_name on |
| 400 on callback, “mismatched session ID” | Session cookie set on wrong domain (cookie domain contamination) | Separate outpost containers per cookie domain |
| 400 on callback, “mismatched session ID” should=”” | Session cookie not sent back (callback on different domain than cookie) | Authentication URL must be under same domain as cookie domain |
| 400 on callback, “invalid state” | Stale cookies from previous attempts | Clear browser cookies and retry |
302 redirects to http://server:9000/... | Outpost uses Docker-internal hostname for browser redirects | Set AUTHENTIK_HOST_BROWSER on outpost container |
| 404 from outpost on localhost | Outpost hasn’t loaded application config | Restart outpost; verify application is selected in outpost |
| “no app for hostname” in outpost logs | Outpost receives Host header it can’t match | Informational for /auth/nginx (uses X-Original-URL); blocking for /start and /callback (must match registered host) |
| Site worked, then stopped after Authentik changes | Outpost cached old configuration | docker compose restart outpost-name |
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?
- Am I routing auth traffic through Cloudflare when I could go direct via Tailscale?
- Am I mixing different cookie domains on the same outpost?
- Does my callback hostname live under the same domain as my cookie?
- 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?
Where Most People Get Stuck
“The docs say to use the embedded outpost.” It’s broken for forward auth. Don’t waste time debugging it — deploy a separate proxy outpost container and move on. The issue tracker has over a dozen reports spanning multiple versions.
“I put everything on one outpost to keep it simple.” Mixing single-app and domain-level providers on one outpost causes cookie domain contamination. The domain-level provider’s cookie domain gets applied to all responses, breaking single-app sessions. One outpost per cookie domain.
“I routed auth through Cloudflare Tunnel.” Every problem — Host header mismatches, SNI failures, 301/403 from Cloudflare — stems from this. Use Tailscale for the auth path. Cloudflare stays in front of your sites for CDN and WAF; it just shouldn’t sit between NGINX and the outpost.
“The Authentication URL and cookie domain don’t matter that much.” They must be under the same domain. The outpost sets a session cookie during /start scoped to the cookie domain. The callback must arrive at a hostname within that same domain, or the browser won’t send the cookie back. Different domain = empty session = 400.
“I can reuse names and slugs freely in Authentik.” The outpost caches provider configuration. Any change to providers, applications, or outpost bindings requires an outpost restart (docker compose restart outpost-name). Without it, the outpost runs on stale config.
Strategic Opposition Principle: The documentation describes the happy path. Real infrastructure never takes the happy path. Map the failure modes before you start building.
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]