tutorials8 min read

One Login for Everything: Caddy Forward Auth in Front of Every Self-Hosted App

Put Authentik's forward-auth check behind a single Caddy reverse proxy and every app on your lab gets login + MFA — even the ones with no SSO support at all.

CVE-2026-40172
The short version

Caddy's forward_auth directive asks Authentik (via its Embedded Outpost) on every request whether the visitor is logged in; if not, you get bounced to a central login page before the app ever sees the request. One ~10-line Caddy snippet + one Proxy Provider per app = single sign-on with MFA for apps that don't even know auth exists. Verified against Caddy v2.11.x and authentik 2026.8.x docs.

Yesterday we covered three CVSS-10 unauthenticated flaws in UniFi gear and the fact that our readers' SSO box had its own CVE too. The natural follow-up question is: so what do I actually put in front of my apps? This is the pattern that makes unauthenticated web UIs survivable — one auth check at the reverse proxy, applied to everything.

Before you start//

  • Caddy 2.x — any current release works; I verified the forward_auth directive syntax against the live Caddy docs (current stable at write time: v2.11.4).
  • Authentik server, latest stable — current release is 2026.8.1 (released Sep 1, 2026). If you're still on a pre-fix version for CVE-2026-40172, upgrade first: fixes shipped in 2025.12.5 / 2026.2.3 per our earlier post.
  • One or more self-hosted apps behind the proxy that you want to protect (obviously).
  • Skill floor: comfortable editing a Caddyfile and running Docker Compose. No Kubernetes required — this works on a single box.
  • Time cost: ~2–4 hours for the first app (installing authentik, wiring TLS, debugging your first login loop is normal), then ~10 minutes per additional app. Weekend-feasible.
  • Blast radius: you're putting all of these apps behind one reverse proxy and one identity provider. If Caddy or authentik goes down, the protected apps go with them — so keep at least one escape hatch (a management interface on a different port/hostname that isn't forward-authed). You don't need to snapshot anything for this change itself: the Caddyfile edit is reversible by re-adding reverse_proxy without the snippet.

1. Get authentik running behind Caddy with TLS//

If you don't have authentik yet, the official Docker Compose install is the reference path (requirements: 2 CPU cores / 2 GB RAM minimum):

SHELL
wget https://docs.goauthentik.io/compose.yml
echo "PG_PASS=$(openssl rand -base64 36 | tr -d '\n')" >> .env
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 60 | tr -d '\n')" >> .env
docker compose pull
docker compose up -d

Then point a subdomain — say auth.example.com — at it through Caddy:

CADDYFILE
auth.example.com {
    reverse_proxy authentik-server:9000
}

Why the dedicated route matters: authentik's login and callback flow happens under /outpost.goauthentik.io/ on the app's own domain (more on that in step 4). The auth box itself just needs TLS and reachability so you can do initial setup, create users, and manage Providers.

2. Create a Proxy Provider in authentik//

In the authentik UI: Applications → New application — this creates an Application and its Provider together (the docs recommend this combined flow; there's a legacy Providers-only path if you need it). For the provider type pick Proxy, then set:

  • Mode: Forward auth (single application) for one app, or Forward auth (domain level) if you want a single login to cover many subdomains at once. In single-application mode each protected app gets its own Application + Provider pair; domain-level uses one provider for the whole parent domain but can't enforce per-app authorization rules.
  • External host: the public URL of the app being protected (e.g. https://app.example.com) — not authentik's own URL. Include the scheme and any non-standard port.
  • Cookie domain (domain-level mode): set this to your parent domain (example.com, not auth.example.com) so one session cookie is valid across all subdomains. Without it, every app demands its own login even though they share the same IDP.

Then assign the application to an outpost: Applications → Outposts, pick (or create) a Proxy outpost with Type Proxy, and select your application under its Applications list. For this setup that's the Embedded Outpost built into the server image — no separate container.

This is the object that decides who may access what — pair it with a policy binding to a group (e.g. "admin") and you've got your access control in one place instead of ten apps' user lists.

3. Write the reusable Caddy snippet//

Here's the exact template from authentik's current Caddy docs (verified live against the docs as of today — note the header list is longer than in older blog posts; use this one). The route {} wrapper matters: without it, Caddy re-sorts directives by its default order rather than the literal order stated in the snippet.

CADDYFILE
(authentik) {
    route {
        # always forward outpost path to actual outpost
        reverse_proxy /outpost.goauthentik.io/* authentik-server:9000

        # forward authentication to outpost
        forward_auth authentik-server:9000 {
            uri /outpost.goauthentik.io/auth/caddy

            # capitalization of the headers is important, otherwise they will be empty
            copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Entitlements X-Authentik-Email X-Authentik-Name X-Authentik-Uid X-Authentik-Jwt X-Authentik-Meta-Jwks X-Authentik-Meta-Outpost X-Authentik-Meta-Provider X-Authentik-Meta-App X-Authentik-Meta-Version

            # optional, in this config trust all private ranges,
            # should probably be set to the outpost's IP
            trusted_proxies private_ranges
        }
    }
}

What each piece does:

  • forward_auth (a Caddy directive) makes a GET request to authentik's Embedded Outpost on every incoming request. 2xx → access granted, and the copy_headers fields get copied onto the real request so your app can see who logged in (username, groups, email). Anything else → the response is passed back to the client, which is a redirect to the login page.
  • uri /outpost.goauthentik.io/auth/caddy — the outpost's verification endpoint. No separate outpost container needed; it's built into the server image.
  • The /outpost.goauthentik.io/* reverse_proxy line — this is the one everyone forgets, and it causes post-login 404s (see "When it breaks").
  • trusted_proxies private_ranges — Caddy only accepts forwarded client info from these ranges. The docs' own comment says you should probably narrow this to the outpost's actual IP; private_ranges is fine for a homelab where everything sits on your LAN.

4. Protect the first app//

CADDYFILE
app.example.com {
    import authentik
    reverse_proxy app:8080
}

That's it — the app container doesn't know auth exists; it thinks it's a dumb HTTP service on localhost. The only way anyone reaches it is through authentik's login screen. If your upstream speaks HTTPS, set the Host header per the docs' note:

CADDYFILE
reverse_proxy /outpost.goauthentik.io/* https://auth.example.com {
    header_up Host {http.reverse_proxy.upstream.host}
}

Reload and test: caddy reload --config Caddyfile. The /outpost.goauthentik.io/ route in the snippet is why login works at all: after you authenticate, authentik redirects your browser to a callback on the app's own domain (https://app.example.com/outpost.goauthentik.io/callback). Without that reverse_proxy line, the callback lands on the app itself and 404s — which only happens after a successful login, so it's easy to debug in exactly the wrong place.

5. Exclude non-browser clients (and don't leave them naked)//

Forward auth is built for browsers: an API client that gets redirected to an HTML login page can't do much with it. For endpoints hit by mobile apps, webhooks, monitoring probes, or scripts, use a named matcher so only the human-facing paths are forward-authed — inline the scoped forward_auth rather than importing the whole snippet (Caddy's matcher docs for syntax):

CADDYFILE
blog.example.com {
    @admin {
        path /admin*
        not path /api/*
    }

    forward_auth @admin authentik-server:9000 {
        uri /outpost.goauthentik.io/auth/caddy
    }

    reverse_proxy blog:2368
}

Path matching in Caddy is exact by default; /admin* matches /admin, /admin/, and everything under it. The excluded paths still need their own auth — a webhook validates its signature or shared secret, an API takes a token. "No forward auth" doesn't mean "no auth."

6. Keep authentik out of its own chain (and version your config)//

Two last things:

  1. Don't put auth.example.com behind the (authentik) snippet — that's a login loop by construction (the login page asks itself whether you're logged in). The dedicated site block from step 1, with no forward auth, is correct.
  2. Manage Providers via authentik Blueprints if you have more than a handful of apps: YAML definitions for the Provider + Application + policy binding that authentik reconciles at startup (docs). Put them in Gitea next to your compose files and "who can access what" becomes versioned, diffable config instead of UI state you'll forget changed.

Verify it worked//

Don't trust "the login page appeared." Run the checks:

  1. Unauthenticated request redirects:
    SHELL
    curl -I https://app.example.com/
    Expect a 302 with a Location: pointing at authentik's outpost path — not a 200 from the app, and not a 404.
  2. Authenticated request passes: log in via the browser, then repeat the curl with your session cookie:
    SHELL
    curl -I -H "Cookie: <your-session-cookie>" https://app.example.com/
    Expect 200 — and check the app's logs for a received X-Authentik-Username header, which proves the identity is actually flowing downstream (this also catches the empty-header capitalization bug).
  3. Authorization denies: move your test user out of the allowed group (or use a second account that isn't in it), log in again, and confirm you get denied — not just "logged in but seeing the app." Forward auth without policy bindings lets anyone through; this check proves the binding works.

When it breaks//

  • Login loop (you authenticate, get bounced to login, forever): almost always authentik sitting behind its own forward auth (step 6), or a second forward_auth double-wrapping the same site block.
  • Post-login 404 on /outpost.goauthentik.io/callback: the outpost route from step 3 is missing from that app's site block. Add the reverse_proxy /outpost.goauthentik.io/* authentik-server:9000 line and reload.
  • App logs show empty/missing X-Authentik-* headers: header capitalization in copy_headers — it must match exactly, or apps that trust those headers get nothing. (And if an app doesn't read the headers at all, it will still demand its own login after your authentik login — expected behavior, not a bug; forward auth gates access, it doesn't log you into every app.)
  • Header spoofing risk: any app that trusts X-Authentik-* must be reachable only through Caddy (bind to the Docker network / 127.0.0.1). If someone can hit port 8080 directly and set their own headers, they're "logged in" as whatever name they typed.
  • Rollback is trivial per app: remove import authentik from that site block, caddy reload, and the app is back to its native (or no) auth. Nothing about this change touches the apps themselves.

Filed under

Sources

5 cited · 0 primary
  1. docs.goauthentik.io/add-secure-apps/providers/proxy/server_caddyReporting
  2. caddyserver.com/docs/caddyfile/directives/forward_authReporting
  3. docs.goauthentik.io/install-config/install/docker-composeReporting
  4. systemebene.house-harkonnen.com/en/articles/authentik-forward-auth-caddyReporting
  5. blog.nzrv.dev/posts/reverse-proxy-authentik-oidc-cloudflareReporting