Proxy authentication

Truke KF carries no SAML, LDAP, Kerberos, or MFA code of its own. When your organisation authenticates with one of those, you put a reverse proxy in front of KF: the proxy terminates the enterprise protocol, verifies the user, and passes the verified identity to KF as a small set of HTTP headers. This is the header channel introduced on the authentication page; this page is the ops-facing reference — copyable configuration for the three proxies KF deployments front with: nginx, Caddy, and Apache httpd.

How the trust model works

On every request to KF the proxy must do four things:

  1. Authenticate the user upstream (OIDC / SAML / LDAP / MFA).
  2. Inject the four identity headers derived from the verified result.
  3. Inject the static secret header (X-Auth-Secret).
  4. Strip any client-supplied copy of all of the above.

The secret header is the security boundary — not the strip. KF rejects any request whose X-Auth-Secret is absent or wrong (returning 403), so a client that forges X-Authenticated-User still cannot get in: it does not know the secret. The strip in step 4 is defence in depth.

That separation matters because the strip is easy to get wrong. Caddy's forward_auth + copy_headers, for instance, does not strip a client-supplied identity header — the header is only overwritten when the auth service returns it, so a missing claim lets the client's value pass through (CVE-2026-30851, stable releases from Caddy v2.10.0). The configurations below strip explicitly and never rely on the auth flow to do it for them.

Two prerequisites for every proxy

  • Bind KF to loopback only (127.0.0.1:8080) — or to a private interface firewalled so the proxy is the only reachable path. If KF is reachable directly, the header channel is forgeable no matter how the proxy is configured.
  • Share one high-entropy secret between the proxy and KF:
  openssl rand -hex 32        # export as KF_HEADER_SECRET on both sides

KF reads it via shared_secret_env KF_HEADER_SECRET in its [auth] section; the proxy injects that value as X-Auth-Secret.

The header contract

The proxy injects five headers. Four carry the verified identity; the fifth is the shared secret:

Injected headerSource claim (OIDC example)Becomes in KF
X-Authenticated-Userpreferred_usernamethe uid
X-Authenticated-Emailemailthe email
X-Authenticated-Namenamethe display name
X-Authenticated-GroupsgroupsACL labels (only if the group map is on)
X-Auth-Secretthe shared secret (constant)

X-Authenticated-User carries the value KF derives the uid from — keep it stable and space-free. The header names above are the defaults; they are configurable in the KF [auth] header block (user_header, email_header, name_header, groups_header, secret_header).

nginx

Pairs with oauth2-proxy (OIDC / OAuth2) on 127.0.0.1:4180. For SAML, front nginx with a SAML-capable gateway (Authelia / Keycloak) via the same auth_request pattern, or use Apache instead — nginx open-source and oauth2-proxy do not speak SAML.

Strip mechanism: in nginx, proxy_set_header overwrites the client's header toward the upstream, so setting every trusted header explicitly is itself the strip. A header set to an empty value is omitted, never taken from the client.

# /etc/nginx/conf.d/kf.conf
# KF is on 127.0.0.1:8080 and firewalled; nginx is the only path to it.

server {
    listen 443 ssl;
    http2 on;
    server_name kf.example.com;

    ssl_certificate     /etc/ssl/kf/fullchain.pem;
    ssl_certificate_key /etc/ssl/kf/privkey.pem;

    # oauth2-proxy endpoints
    location /oauth2/ {
        proxy_pass         http://127.0.0.1:4180;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-Proto $scheme;
        proxy_set_header   X-Forwarded-Host  $host;
    }

    location = /oauth2/auth {
        internal;
        proxy_pass              http://127.0.0.1:4180;
        proxy_pass_request_body off;
        proxy_set_header        Content-Length "";
        proxy_set_header        Host              $host;
        proxy_set_header        X-Forwarded-Proto $scheme;
    }

    location / {
        auth_request /oauth2/auth;
        error_page 401 = @signin;

        # Capture verified identity from oauth2-proxy
        # (run oauth2-proxy with --set-xauthrequest).
        auth_request_set $kf_user   $upstream_http_x_auth_request_preferred_username;
        auth_request_set $kf_email  $upstream_http_x_auth_request_email;
        auth_request_set $kf_name   $upstream_http_x_auth_request_user;
        auth_request_set $kf_groups $upstream_http_x_auth_request_groups;

        # Secret. Keep this out of world-readable config; restrict file perms.
        set $kf_secret "REPLACE_WITH_SECRET";

        # Inject trusted headers. Each set OVERWRITES any client-supplied copy.
        proxy_set_header X-Authenticated-User   $kf_user;
        proxy_set_header X-Authenticated-Email  $kf_email;
        proxy_set_header X-Authenticated-Name   $kf_name;
        proxy_set_header X-Authenticated-Groups $kf_groups;
        proxy_set_header X-Auth-Secret          $kf_secret;

        proxy_pass       http://127.0.0.1:8080;
        proxy_set_header Host $host;
    }

    location @signin {
        return 302 /oauth2/start?rd=$scheme://$host$request_uri;
    }
}

Caddy

Pairs with oauth2-proxy via forward_auth. For SAML, point forward_auth at a SAML-capable gateway (Authelia / Keycloak) and adjust the copied header names.

Strip mechanism: because copy_headers cannot be trusted to strip (see the CVE note above), the client headers are deleted first with request_header -…, inside a route block that pins execution order (strip → authenticate → proxy).

# Caddyfile
# KF is on 127.0.0.1:8080 and firewalled; Caddy is the only path to it.
# Run Caddy with KF_HEADER_SECRET set in the environment.

kf.example.com {
    route {
        # 1. Strip client-supplied trusted headers, unconditionally.
        request_header -X-Authenticated-User
        request_header -X-Authenticated-Email
        request_header -X-Authenticated-Name
        request_header -X-Authenticated-Groups
        request_header -X-Auth-Secret

        # 2. Authenticate, then copy verified identity onto the clean request.
        forward_auth 127.0.0.1:4180 {
            uri /oauth2/auth
            copy_headers X-Auth-Request-Preferred-Username>X-Authenticated-User \
                         X-Auth-Request-Email>X-Authenticated-Email \
                         X-Auth-Request-User>X-Authenticated-Name \
                         X-Auth-Request-Groups>X-Authenticated-Groups
        }

        # 3. Inject the secret (overwrites) and proxy to KF.
        reverse_proxy 127.0.0.1:8080 {
            header_up X-Auth-Secret {env.KF_HEADER_SECRET}
        }
    }
}

Run oauth2-proxy with --set-xauthrequest so the identity headers are always present for authenticated requests. Because the strip runs first and the secret gate stands behind it, a placeholder or missing claim fails closed at KF rather than leaking a client value.

Apache httpd

Pairs with mod_auth_openidc (OIDC). For SAML, swap in mod_auth_mellon — its attributes surface as MELLON_ environment variables, so only the RequestHeader set … %{MELLON_}e lines change.

Strip mechanism: RequestHeader unset deletes the client header; RequestHeader set overwrites. Claims are exposed as environment variables and read with %{VAR}e.

# KF is on 127.0.0.1:8080 and firewalled; Apache is the only path to it.
# Modules: mod_proxy mod_proxy_http mod_headers mod_auth_openidc
# Make the OS secret visible to Apache:
PassEnv KF_HEADER_SECRET


    ServerName kf.example.com
    SSLEngine on
    SSLCertificateFile    /etc/ssl/kf/fullchain.pem
    SSLCertificateKeyFile /etc/ssl/kf/privkey.pem

    OIDCProviderMetadataURL https://idp.example.com/.well-known/openid-configuration
    OIDCClientID            kf-app
    OIDCClientSecret        "REPLACE"
    OIDCRedirectURI         https://kf.example.com/oauth2callback
    OIDCCryptoPassphrase    "REPLACE"
    OIDCPassClaimsAs        environment      # claims as OIDC_CLAIM_* env vars

    
        AuthType openid-connect
        Require  valid-user

        # 1. Strip client-supplied trusted headers.
        RequestHeader unset X-Authenticated-User
        RequestHeader unset X-Authenticated-Email
        RequestHeader unset X-Authenticated-Name
        RequestHeader unset X-Authenticated-Groups
        RequestHeader unset X-Auth-Secret

        # 2. Inject verified identity (set = overwrite).
        RequestHeader set X-Authenticated-User   "%{OIDC_CLAIM_preferred_username}e"
        RequestHeader set X-Authenticated-Email  "%{OIDC_CLAIM_email}e"
        RequestHeader set X-Authenticated-Name   "%{OIDC_CLAIM_name}e"
        RequestHeader set X-Authenticated-Groups "%{OIDC_CLAIM_groups}e"

        # 3. Inject the secret.
        RequestHeader set X-Auth-Secret "%{KF_HEADER_SECRET}e"

        ProxyPass        http://127.0.0.1:8080/
        ProxyPassReverse http://127.0.0.1:8080/
    

If a claim env var is unset when RequestHeader runs, set OIDCPassClaimsAs both and confirm the header lands; the env-var read is the cleaner path when it works.

Verify the deployment

Run these against a live deployment — they test the boundary, not the happy path:

# A. Forged identity, no secret -> never authenticated as the forged user.
#    Expect a redirect to login or 403; never a logged-in session for 'attacker'.
curl -is https://kf.example.com/ -H 'X-Authenticated-User: attacker' | head -n1

# B. Forged identity AND a wrong secret -> 403.
curl -is https://kf.example.com/ \
  -H 'X-Authenticated-User: attacker' \
  -H 'X-Auth-Secret: wrong' | head -n1

# C. Direct hit bypassing the proxy -> refused (KF binds to loopback).
#    From any host other than the KF host:
curl -is http://KF_HOST:8080/ -H 'X-Authenticated-User: attacker'

Pass criteria: A and B never produce a session for attacker, B returns 403, and C cannot connect. If C connects from another host, KF is not bound to loopback and the channel is unsafe — fix the bind before anything else.

Checklist

  • KF bound to 127.0.0.1 (or private + firewalled); not directly reachable.
  • Secret generated with openssl rand -hex 32, identical on both sides, and stored out of world-readable config.
  • All four identity headers plus X-Auth-Secret are stripped, then set.
  • X-Authenticated-User carries a stable, space-free value.
  • The auth terminator returns the identity headers for every authenticated request (--set-xauthrequest for oauth2-proxy; OIDCPassClaimsAs for mod_auth_openidc).
  • Verification A–C pass.
  • KF [auth] header block: enabled true, secret_header X-Auth-Secret, shared_secret_env KF_HEADER_SECRET, trusted_proxy set, and a jit policy chosen.

This page covers the trusted-header channel only. Native single sign-on (OIDC with no proxy) and bearer-token access for the REST API and the MCP endpoint need none of this — see authentication & access.