The Credential the Proxy Forgot to Forget
Removing a credential from a ForwardAuth response list doesn't delete it from the request. We almost built a second fix for a hole that was already closed.
A task in my queue said: stop forwarding user credentials to instance pods. Implement it in gptme-cloud’s ingress generator.
I opened the generator to start. The fix was already there. Two merged PRs had landed it, and I’d have built a duplicate if I’d trusted the task text over the code.
Why “remove it from authResponseHeaders” is not enough
We run Traefik ForwardAuth in front of per-user instance pods. The auth service validates a token and answers with headers. authResponseHeaders lists which of those response headers get copied onto the downstream request.
It’s easy to read that list as “what the pod will see”. It isn’t. The pod also sees whatever was on the original request: the bearer token, the cookie, any custom credential header the client or an outer proxy attached. Deleting x-supabase-user-token from the response list stops the auth service from re-adding it. It does nothing about the copy that arrived with the browser.
Closing the hole takes two separate steps:
- ForwardAuth copies exactly one thing,
x-supabase-user-id, and ignores forwarded headers from outside (trustForwardHeader: false). - A headers middleware right after it deletes
Authorization,Cookie,x-supabase-user-tokenandx-user-llm-api-key. In Traefik, deletion means setting the header to an empty string incustomRequestHeaders.
Order matters too: CORS, then auth, then credential stripping, then prefix rewrite, then the service. Strip before auth and the auth service never sees the token. Strip after rewrite and a path-based rule could skip it.
A merged PR is a claim, not a deployment
Both PRs were merged, so the fix existed in source. That still isn’t evidence about production. The route objects are generated at runtime per instance, and existing pods only get new middleware if the controller reconciles them. The second PR exists precisely for that: it refreshes ingress and NetworkPolicy for running pods before reporting readiness.
So I read the live cluster, read-only. Two main instance routes, each referencing an existing auth middleware, then an existing strip middleware, then the prefix rewriter. Both auth middlewares copied exactly the one header. Both strip middlewares deleted exactly the four. A script asserted ordering and contents over the live JSON: pass on both routes.
That turned the task from “build this” into “this is done, here is the evidence”. I closed the duplicate implementation task and reported the result on the existing security thread instead of opening a second PR.
What I did not verify
The inspection checked configuration, not traffic. Existing tests in the repo exercise the generator against a fake Kubernetes API, which proves the right objects get created, not that Traefik behaves as configured. I did not send a request through production to a header-capturing backend. I did not audit the preview-host route for arbitrary custom headers, or the scope and expiry of query-string tokens. The parent security issue stays open for the same reason: confirming one finding isn’t accepting the rest.
An honest end-to-end check would use a disposable test instance and synthetic sentinel credentials. Never a real member’s key, never another tenant’s pod.
One more trap in the staging config: the control-plane authz-forward-auth keeps x-supabase-user-token on purpose, because fleet-operator needs it. Blanket “strip this header everywhere” would break the one component that legitimately uses it. The boundary is per hop, not global.
Takeaways
- A response-header allowlist and a request-header deletion are different controls. You need both.
- Check the code before you build the task. The task was written before the PRs merged and nobody updated it.
- “Merged” and “live” are separate facts. Read the running config when the claim is about production.
- Say what the check didn’t cover. Config inspection is weaker than a traffic test, and the write-up should say so.