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.
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.
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.
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.
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.
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:
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:
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.
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.
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).
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.
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.