AI assistance disclosure
Per AI_POLICY.md:
Tool: Claude (Anthropic), driven agentically via Claude Code.
Extent: AI-assisted throughout. The agent did the investigation — reading the 2026.8.0 outpost source, fingerprinting the 400 body against the upstream's own error format, bisecting the failure against logs, and running the h2 reproduction — and drafted this report. Every result quoted below is real command output rather than model-generated text: the status codes and the ProtocolError came from executing those requests against the affected controller.
Human-in-the-loop: I have read and verified this report and take responsibility for its contents.
Describe the bug
After upgrading to 2026.8.0, every request to a proxy provider whose internal_host is https returns 400, passed through from the upstream. In my case the upstream is a UniFi (UDM) controller at https://192.168.1.1:443.
Every application path 400s, while the outpost's own endpoints still work:
| path |
result |
/ |
400 |
/manage |
400 |
/outpost.goauthentik.io/start |
302 (correct redirect, correct client_id) |
/outpost.goauthentik.io/ping |
204 |
Providers with plain-http:// internal hosts on the same outpost are unaffected — they still redirect to auth normally.
The 400 body is the upstream's error format, not authentik's — that same controller answers /api/self with {"error":{"code":401,"message":"Unauthorized"}}. So the request is reaching the backend and being rejected there.
How to reproduce
- Create a proxy provider whose
internal_host is https://… and whose backend negotiates HTTP/2 via ALPN (any nginx-fronted appliance; mine is a UniFi UDM on :443).
- Assign it to an outpost in
proxy mode and route an external hostname to it.
- Request any application path →
400, emitted by the upstream.
The rejection is reproducible against the backend directly, with no authentik in the path. Using Python's h2 with validate_outbound_headers=False so the malformed pair is actually transmitted:
h2, no host header (:authority only) -> 200
h2, host: 192.168.1.1 (agrees) -> 400
h2, host: unifi.example.com (mismatch) -> 400
The same server returns 200 for all of these over HTTP/1.1.
Note this upstream rejects any literal host header on h2, even one that agrees with :authority — so merely keeping the two consistent is not sufficient here; the header should not be sent on h2 at all.
h2 refuses to send the mismatched pair without that flag, citing the relevant rule:
h2.exceptions.ProtocolError: Request header block has mismatched
:authority and Host headers: b'192.168.1.1' / b'unifi.example.com'
Expected behavior
Proxying to an https backend should keep working as it did through 2026.5.x.
On HTTP/2 upstreams the outpost should either drop the inbound Host and let :authority carry the value, or derive :authority from the same value it places in Host — not emit both inconsistently.
Additional context
build_client() in src/outpost/proxy/upstream.rs enables HTTP/2 on the upstream connector and disables hyper's Host handling:
.https_or_http()
.enable_http1()
.enable_http2()
.build();
// Forward the request's own `Host` upstream instead of deriving it from the
// (internal) upstream URI authority. The proxy sets `Host` explicitly.
Ok(Client::builder(TokioExecutor::new())
.set_host(false)
.build(connector))
create_proxied_request() in reverse_proxy.rs strips only hop-by-hop headers, so the client's inbound Host is forwarded verbatim. For an https upstream that negotiates h2, the request then carries both :authority (from the rewritten URI) and a literal host header holding the external hostname.
Per RFC 9113 §8.3.1 that combination is malformed when the two disagree, and servers reject it. set_host(false) looks correct for HTTP/1.1 and wrong for HTTP/2.
Workarounds tried, none sufficient:
- Provider Host header override — still emits a literal
host header, still 400s on this upstream.
http:// internal_host — upstream 301s to https.
- A different upstream port — not serving the application.
There is no provider field or environment variable exposing upstream HTTP version or set_host, so this is not configurable around. I ended up routing past the outpost entirely at the ingress layer.
Deployment Method
Kubernetes
Version
2026.8.0 (regression — 2026.5.6 was unaffected)
Relevant log output
Worth flagging that authentik logs nothing anomalous — the outpost records the 400 as an ordinary request, with no error or warning emitted, which makes this hard to spot from the authentik side:
{"target":"authentik_axum::tracing","event":"/","runtime":"1","status":400,
"host":"unifi.example.com","method":"GET","path":"/","scheme":"https"}
Timeline, for the regression window: last successful request at 2026-08-18 22:xx, pods restarted onto 2026.8.0 at 23:20, first 400 at 2026-08-19 01:06, and no successful request since.
AI assistance disclosure
Per AI_POLICY.md:
Tool: Claude (Anthropic), driven agentically via Claude Code.
Extent: AI-assisted throughout. The agent did the investigation — reading the 2026.8.0 outpost source, fingerprinting the 400 body against the upstream's own error format, bisecting the failure against logs, and running the
h2reproduction — and drafted this report. Every result quoted below is real command output rather than model-generated text: the status codes and theProtocolErrorcame from executing those requests against the affected controller.Human-in-the-loop: I have read and verified this report and take responsibility for its contents.
Describe the bug
After upgrading to 2026.8.0, every request to a proxy provider whose
internal_hostis https returns400, passed through from the upstream. In my case the upstream is a UniFi (UDM) controller athttps://192.168.1.1:443.Every application path 400s, while the outpost's own endpoints still work:
//manage/outpost.goauthentik.io/startclient_id)/outpost.goauthentik.io/pingProviders with plain-
http://internal hosts on the same outpost are unaffected — they still redirect to auth normally.The 400 body is the upstream's error format, not authentik's — that same controller answers
/api/selfwith{"error":{"code":401,"message":"Unauthorized"}}. So the request is reaching the backend and being rejected there.How to reproduce
internal_hostishttps://…and whose backend negotiates HTTP/2 via ALPN (any nginx-fronted appliance; mine is a UniFi UDM on:443).proxymode and route an external hostname to it.400, emitted by the upstream.The rejection is reproducible against the backend directly, with no authentik in the path. Using Python's
h2withvalidate_outbound_headers=Falseso the malformed pair is actually transmitted:The same server returns 200 for all of these over HTTP/1.1.
Note this upstream rejects any literal
hostheader on h2, even one that agrees with:authority— so merely keeping the two consistent is not sufficient here; the header should not be sent on h2 at all.h2refuses to send the mismatched pair without that flag, citing the relevant rule:Expected behavior
Proxying to an https backend should keep working as it did through 2026.5.x.
On HTTP/2 upstreams the outpost should either drop the inbound
Hostand let:authoritycarry the value, or derive:authorityfrom the same value it places inHost— not emit both inconsistently.Additional context
build_client()insrc/outpost/proxy/upstream.rsenables HTTP/2 on the upstream connector and disables hyper'sHosthandling:create_proxied_request()inreverse_proxy.rsstrips only hop-by-hop headers, so the client's inboundHostis forwarded verbatim. For an https upstream that negotiates h2, the request then carries both:authority(from the rewritten URI) and a literalhostheader holding the external hostname.Per RFC 9113 §8.3.1 that combination is malformed when the two disagree, and servers reject it.
set_host(false)looks correct for HTTP/1.1 and wrong for HTTP/2.Workarounds tried, none sufficient:
hostheader, still 400s on this upstream.http://internal_host — upstream 301s to https.There is no provider field or environment variable exposing upstream HTTP version or
set_host, so this is not configurable around. I ended up routing past the outpost entirely at the ingress layer.Deployment Method
Kubernetes
Version
2026.8.0 (regression — 2026.5.6 was unaffected)
Relevant log output
Worth flagging that authentik logs nothing anomalous — the outpost records the 400 as an ordinary request, with no error or warning emitted, which makes this hard to spot from the authentik side:
{"target":"authentik_axum::tracing","event":"/","runtime":"1","status":400, "host":"unifi.example.com","method":"GET","path":"/","scheme":"https"}Timeline, for the regression window: last successful request at
2026-08-18 22:xx, pods restarted onto 2026.8.0 at23:20, first 400 at2026-08-19 01:06, and no successful request since.