Skip to content

ForwardAuth

ForwardAuth protects applications that do not implement OIDC. A reverse proxy asks Autentico whether each request is authenticated before forwarding it to the application. All active local users may access an enabled protected application after satisfying the configured login policy. This feature does not add group-based application permissions.

Applications may share a site with Autentico (https://dashboard.example.com) or use an unrelated site (https://dashboard.otherdomain.net with https://auth.example.com). An authenticated administrator must explicitly register each exact application origin; a domain or wildcard allowlist is not used. Production applications must have a different hostname from Autentico: different ports do not isolate browser cookies. HTTPS is required except for literal loopback development origins. Keep the protected backend reachable only through the proxy.

For auth.example.com protecting dashboard.otherdomain.net, the browser first visits the dashboard’s /_autentico/{id}/start route. Autentico responds through that app proxy with a temporary, encrypted, host-scoped flow cookie and a redirect to central authorization. Existing login, eligible SSO and MFA run on the IdP hostname. A top-level GET redirect then returns a single-use authorization code and state to the exact dashboard callback. Autentico verifies the initiating flow cookie, state, nonce, application and PKCE, consumes the code atomically with app-session creation, and responds through the dashboard proxy with its own host-only session cookie and a redirect to the original allowed path/query.

The existing protected_apps, clients, auth_codes, idp_sessions and forward_auth_sessions tables are reused; no separate handoff table or migration is required. Only the temporary code/state visit the reserved callback. The central cookie stays on the IdP host, and no OAuth access/refresh credentials are issued to the application. App-local logout affects that app session; central logout/revocation invalidates linked app access on the next proxy check even though its cookie remains in the browser.

This flow uses first-party pages and top-level GET redirects, so SameSite=Lax flow and session cookies work with third-party cookies blocked. Sign-in inside cross-site iframes and cross-site background authentication fetches are outside this integration’s contract. Applications remain responsible for CSRF protection on their own state-changing endpoints. The protocol and threat boundaries are recorded in the cross-domain design.

Use Admin → Protected Apps → Add Application, or the admin-authenticated API:

Terminal window
curl -X POST https://auth.example.com/admin/api/protected-apps \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"Dashboard","origin":"https://dashboard.example.com","allowed_return_paths":["/"],"session_ttl":"8h"}'

The response’s data.id identifies the app in proxy configuration. Registration atomically creates an associated forward-auth-<id> public client with a fixed callback and S256 PKCE. Manage the app through Protected Apps rather than changing its associated client independently.

Field Meaning
name Plain text display name, 1–100 characters
origin Exact app origin, immutable after registration; one registration per origin
allowed_return_paths 1–32 canonical path prefixes; / allows all application paths
session_ttl Absolute app-session lifetime, 1s through 24h; default 8h
is_active Default true; disabling permanently revokes existing app sessions

Path prefixes match complete segments: /reports permits /reports and /reports/2026, but not /reports-other. The original query string is preserved. Absolute return URLs, scheme-relative URLs, dot segments, decoded semicolons/path parameters (including ..;), ambiguous encodings, backslashes and reserved /_autentico paths are rejected. Restrictions apply to every access check as well as login completion.

GET /admin/api/protected-apps lists apps, PUT /admin/api/protected-apps/{id} replaces name/paths/TTL and optionally active status, and DELETE /admin/api/protected-apps/{id} deletes the app and its sessions. Replacement requests must include name, allowed_return_paths and the existing, unchanged origin; omitting origin returns HTTP 400. The replacement also writes session_ttl: omitting it (or supplying an empty string) resets it to the default 8h, so include the existing TTL when changing only the name or paths. An omitted is_active on replacement preserves its value. To change origin, register a new application. Shortening TTL also limits existing sessions; increasing it does not extend their stored expiry.

Set AUTENTICO_TRUSTED_PROXIES to the addresses/CIDRs of the immediate network peers allowed to call ForwardAuth. The default trusts no proxy. Use the private Autentico listener for checks and app bootstrap routes. Never expose this listener directly to untrusted networks, and never trust a whole shared network merely for convenience.

Block /forward-auth, /forward-auth/, /_autentico and /_autentico/ prefixes on the IdP’s public hostname, independently of forwarded headers. App-host bootstrap routes must still reach the private listener. Do not rely on a public vhost merely overwriting headers: it shares the trusted network peer and a future proxy configuration error could relay attacker-supplied original-host metadata. Prefer a separate private listener/network when that boundary cannot be guaranteed.

For nginx, put these locations in the public IdP server block, before its normal catch-all proxy location:

location = /forward-auth { return 404; }
location ^~ /forward-auth/ { return 404; }
location = /_autentico { return 404; }
location ^~ /_autentico/ { return 404; }

The complete tested Caddy and nginx configurations include this public-vhost blocking; nginx’s fixture also exposes the public IdP on port 25446 to test it independently. With Traefik, exclude both private prefixes from the public IdP router and expose bootstrap routes only on registered app routers. Continue overwriting original-host/protocol headers on every permitted proxy route.

Call GET /forward-auth/{id}/check with the browser’s Cookie and these overwritten headers:

Header Trusted value
X-Forwarded-Host Exact registered app authority, including a nondefault port
X-Forwarded-Proto Registered app scheme
X-Forwarded-Uri Original path and query
X-Forwarded-Method Original request method, including when the subrequest itself uses GET

Repeated or mismatched origin headers fail closed. A browser-provided forwarding header must never override these values. Do not forward request bodies to the check endpoint, or cache its decisions.

Check result Proxy behavior
204 Forward the request with the returned identity and filtered cookie headers
401 Reject noninteractive requests; browser navigation may use X-Autentico-Login
403 Deny disabled/unknown destinations or disallowed paths; do not initiate login
5xx, timeout or connection failure Deny access; never fall through to the backend

redirect=1 makes an unauthenticated HTML GET/HEAD check return a 303 to its validated login-start URL. JSON/API and unsafe-method requests still receive 401. This mode is suitable for Caddy and Traefik. nginx auth_request requires status-only mode; its configuration separately converts browser 401 responses to login redirects.

On success, copy X-Auth-User (stable user ID), X-Auth-Email and Cookie from the check response to the upstream request, replacing incoming values. Strip client-provided identity headers, including unsupported group headers. The response Cookie is not Set-Cookie: it contains only the original application’s cookies with ForwardAuth credentials removed. When there are no application cookies, autentico_no_app_cookies=1 forces proxies to replace the original Cookie header rather than skip an empty value. Do not copy these headers to the browser response. Use X-Auth-User as the account key. X-Auth-Email is optional: Autentico emits it only for a nonempty, verified address, even when require_email_verification is off. The proxy must clear any client-supplied value when the response omits it, as all three tested configurations do. Use the stable user ID for account linking; verified email is mutable profile data.

Reserve these paths on each app’s origin and proxy them directly to the private Autentico listener, preserving the path/query and overwriting the origin headers above. They must bypass the auth check and never reach the application:

Method Path Purpose
GET /_autentico/{id}/start?return=<encoded-local-path> Bind the login transaction to this app and browser
GET /_autentico/{id}/callback Validate state/nonce/PKCE and complete login
POST /_autentico/{id}/logout Revoke this browser’s app session; exact matching Origin required

Login uses the existing authorization, primary authentication, email-verification and MFA flow. Eligible SSO may avoid another login prompt. Pending login state and PKCE verifier are encrypted/authenticated with purpose-separated keys derived from the persistent CSRF secret; temporary cookies expire after ten minutes and allow up to six simultaneous pending tabs per app. A new start evicts the oldest authenticated pending flow instead of locking out the browser; an evicted tab must restart login. Invalid/expired flow cookies are cleared. Complete encoded pending cookies (names, values and separators) have a combined 6 KiB budget, reserving 2 KiB below nginx’s default header-field limit for other cookies. Each new flow is limited to 3 KiB; an oversized return destination receives HTTP 400 without setting cookies. Long return URLs may therefore retain fewer than six pending flows, evicting the oldest first. Deployments with large application cookies must size their proxy header limits accordingly. Wrong-browser completion does not consume the owner’s code. Code redemption and app-session creation commit together, preventing concurrent replay across restarts. Stored code expiry is rechecked after claiming the writer transaction; an expired code is rolled back without issuing an app session.

The AUTENTICO_FORWARD_AUTH_COOKIE_PREFIX bootstrap setting defaults to autentico_fa_. An app session appends <id>; pending flows append <id>_flow_<state>. HTTPS always adds __Host- to that base. A custom prefix is also used for flow-cookie lookup, deletion and credential stripping; the previous default family is still withheld from backends without being accepted for authentication. See cookie configuration for namespace and restart behavior.

With defaults, the final cookie is __Host-autentico_fa_<id>, Secure, HttpOnly, SameSite=Lax, Path=/, with no Domain attribute. Loopback HTTP uses the same name without __Host-. Its random credential is stored only as a SHA-256 hash in forward_auth_sessions. No access or refresh tokens are issued to the app, and the central IdP cookie remains host-scoped to Autentico.

Every check verifies the app credential, active app/client, central session freshness and revocation, active account, current MFA evidence and required email verification. Successful access updates central activity without extending absolute age. Activity writes are coalesced for all idle limits, using a 30-second interval or one quarter of the effective positive idle timeout, whichever is smaller. Idle expiry can occur up to that persistence interval earlier than the last unpersisted access; batching never extends the configured idle or absolute lifetime. Every request still checks expiry, revocation and current policy. Central logout, user deactivation and administrative session revocation stop linked app access on the next check. App logout affects that app session only. Deleting an app removes its sessions; re-enabling a disabled app does not revive revoked sessions. Missing parents always fail closed. An IdP move to another unrelated hostname preserves safe registrations; a move onto an application hostname makes that registration unsafe and authentication fails closed, while stored metadata remains listable and obsolete registrations can still be deleted through the admin API. In admin lists, malformed or null path JSON appears as an empty path array requiring explicit repair; authentication never uses that tolerant decoding. Expired records are cleaned by the existing background cleanup.

Exclude callback query strings from proxy access/error logs and analytics; do not log cookies. The handlers return no-store and no-referrer headers, and successful callbacks redirect to the clean original URL.

The repository contains complete runnable TLS configurations used by the black-box suite:

  • Caddyfile: uses forward_auth, copy_headers and an explicit inner route to preserve header-stripping order.
  • nginx.conf: uses auth_request, auth_request_set, an internal check location and a separate browser-login error handler.
  • Traefik configuration: uses ForwardAuth, identity stripping, response-header replacement and a higher-priority bootstrap router.

For an unrelated-domain deployment, use the complete configurations above with these substitutions throughout the selected proxy configuration:

Value in fixture Deployment value (example)
auth.example.test:25443 or nginx’s public IdP alias auth.example.test:25446 Your public IdP authority, such as auth.example.com
Hostname-only auth.example.test (nginx server_name) Your public IdP hostname, such as auth.example.com, without a port
caddy.example.test:25443, nginx.example.test:25444, or traefik.example.test:25445 The exact registered app authority, such as dashboard.otherdomain.net or dashboard.otherdomain.net:8443
Hostname-only caddy.example.test, nginx.example.test, or traefik.example.test (nginx server_name and both Traefik Host(...) rules) The registered app hostname, such as dashboard.otherdomain.net, without a port
CADDY_APP_ID, NGINX_APP_ID, or TRAEFIK_APP_ID The new protected application’s data.id
127.0.0.1:25998 Autentico’s private address reachable by the proxy
127.0.0.1:25999 Your protected backend

Update hostname-only matches as well as authority values: both the normal app router and bootstrap router must match the new hostname. Keep any non-default port in original-host header overrides, while nginx server_name and Traefik Host(...) contain only the hostname. Adapt public listeners/entrypoints to the registered port (normally HTTPS 443).

Provide valid TLS certificates for both public hostnames. Keep the /_autentico/ bootstrap route ahead of app authentication, pass the app’s actual cookies into checks, and overwrite original-host/protocol with the app authority and https. Bootstrap responses must reach the browser with Set-Cookie and Location intact. Never forward bootstrap requests to the protected backend. The Caddy handle, nginx location ^~ /_autentico/, and higher-priority Traefik bootstrap router in those configurations implement this separation; nginx’s separate browser redirect handler remains necessary on unrelated domains too.

Replace the CADDY_APP_ID, NGINX_APP_ID or TRAEFIK_APP_ID marker with the registered app ID, and adapt origins, ports, private listener/backend addresses and TLS certificate paths. The fixtures bind loopback addresses; production listeners must use the intended public interface. Configure the proxy to overwrite original-host/protocol headers at its external boundary, especially when another proxy sits in front of it.

The nginx example preserves the original method before creating the GET auth subrequest. Do not replace its explicit browser redirect handler with a redirecting auth-check endpoint. The Caddy inner route is necessary: default directive sorting can otherwise remove the identity headers after they have been populated.

On Linux, install Docker, Node/pnpm and OpenSSL. Build the normal production binary and install Chromium:

Terminal window
make build
pnpm --dir tests/browser install --frozen-lockfile
pnpm --dir tests/browser exec playwright install chromium --with-deps
python3 tests/proxy/run.py --binary "$PWD/autentico" --site-mode same-site --logs /tmp/forward-auth-logs
python3 tests/proxy/run.py --binary "$PWD/autentico" --site-mode cross-site --logs /tmp/forward-auth-cross-site-logs

The suite launches the real Autentico executable, Caddy 2.10.2, nginx 1.28.0 and Traefik 3.5.0 in Docker with real TLS and distinct browser hostnames. The backend is a real HTTP server that records every forwarded request. Each proxy is tested for login/return navigation, MFA, cookie isolation, verified-email-only headers, forced same-site pending-flow flooding, identity spoofing, unsafe redirects and semicolon traversal, API/unsafe-method denial, replay, expiry, disable/re-enable, logout/revocation, restart and authentication-service outage. Real Caddy and nginx public IdP vhosts are also tested for private-route rejection despite forged forwarding headers. Cross-app credentials, pending callbacks across restart and parallel tabs are also exercised. Denial checks assert that the backend received no request. The proxy-e2e CI matrix runs the full suite against the production build in both same-site and cross-site layouts. The cross-site layout uses four unrelated registrable sites, explicitly blocks third-party cookies (including browser exceptions), and checks that a SameSite=None probe is withheld as a subresource while remaining available first-party. It also covers browser credential isolation, bounded redirects and changed policy before redemption.