Authentik Forward Auth on Standalone NGINX Is a Minefield Nobody Maps

Implementation

Infrastructure

Security

LittleBig.Co

What if the Authentik docs for NGINX forward auth are fundamentally incomplete for real-world deployments?

Full transparency: Links marked with (*) are affiliate links. Yes, we might earn a commission if you buy. No, that doesn’t mean we’re shilling garbage. We recommend what we’d actually use ourselves. Read our full policy.

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:

PlaceholderDescription
app.example.comYour application domain
auth.example.comYour Authentik domain
outpost.example.comThe outpost’s Cloudflare Tunnel hostname
example.comYour 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:

SettingValue
TypeProxy Provider
Nameapp-example-com-forward-auth
Authorization flowdefault-provider-authorization-explicit-consent
ModeForward auth (single application)
External hosthttps://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:

SettingValue
NameApp Example
Slugapp-example-com
Providerapp-example-com-forward-auth
Launch URLhttps://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:

  1. In Authentik, go to Applications → Outposts
  2. Create a new outpost with type Proxy (do not use the embedded one)
  3. Select your application under the outpost’s applications
  4. After saving, find the auto-generated token under System → Tokens and App passwords — it’s named ak-outpost-<your-outpost-name>-api
  5. 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:

SettingValue
Subdomainoutpost
Domainexample.com
Servicehttp://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:

  1. ^~ modifier — Without this, NGINX regex location blocks take priority. If you have a common WordPress security rule like location ~* /\.(?!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.
  2. set $outpost_upstream as a variable — When the upstream hostname is in a variable, NGINX uses the resolver directive 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.
  3. 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 when proxy_pass uses a variable. Without these directives, Cloudflare receives a TLS connection with no SNI and returns 403.
  4. proxy_set_header Host outpost.example.com — Not $host. Cloudflare Tunnel routes based on the Host header. If you pass $host (which resolves to app.example.com), Cloudflare doesn’t match it to any tunnel hostname and returns a 301 redirect, which NGINX reports as auth request unexpected status: 301.
  5. proxy_pass $outpost_upstream without a trailing path — If you write proxy_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_upstream and 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

ErrorCauseFix
500, log says unexpected status: 301NGINX sends Host: $host to outpost; Cloudflare redirects because it doesn’t recognize the hostnameSet proxy_set_header Host outpost.example.com
500, log says unexpected status: 404Embedded outpost bug — endpoint not registeredDeploy a separate proxy outpost container
403 from NGINX, no error log entryDotfile deny rule matches .goauthentik in the pathUse ^~ modifier on the outpost location
403 from NGINX, outpost location matchesServer-level error_page only catches 401, not 403Change to error_page 401 403 = @goauthentik_proxy_signin
403 from CloudflareCloudflare Access policy on the outpost hostname, or missing SNIRemove 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 redirectsSet AUTHENTIK_HOST_BROWSER=https://auth.example.com on the outpost container
404 from outpost on localhostOutpost hasn’t loaded application configRestart Authentik stack; verify outpost has the application selected
502 via Cloudflare Tunnelcloudflared can’t reach the outpost containerFix tunnel service to match the outpost’s reachable address and port
Doubled path in proxyproxy_pass includes a path that gets appended to the URIUse proxy_pass $outpost_upstream without a trailing path
DNS resolution failsNGINX caches DNS from config load timeAdd 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