When all you get after a Keycloak login is “Invalid parameter: redirect_uri”, the problem rarely lies with Keycloak itself. In my case, the investigation led through three proxy layers down to a SNAT rule in the Kubernetes network. This post documents the debugging path from error message to solution.
Starting Point
The application is a Spring Boot 3 web project connected to a self-hosted Keycloak via OAuth2/OIDC. The infrastructure looks like this:
Browser
│ HTTPS
▼
Caddy (Reverse Proxy, TLS Termination)
│ HTTP
▼
Traefik (k3s Ingress Controller)
│ HTTP
▼
Spring Boot Pod (Port 8080)
When accessing https://myapp.fritz.box, I was correctly redirected to Keycloak – but only an error page appeared:
Invalid parameter: redirect_uri
What Does This Error Mean?
Keycloak validates the redirect_uri parameter against the Valid Redirect URIs configured in the client for every authorization request. If the URI does not match – even if only the scheme differs (http vs. https) – the request is rejected.
Step 1: Making the redirect_uri Visible
A curl -v -L against the application shows the entire redirect chain in plain text:
$ curl -v -L https://myapp.fritz.box
The location header of the second redirect contained the crucial clue:
location: https://keycloak.fritz.box/.../auth?...&redirect_uri=http://myapp.fritz.box/login/oauth2/code/keycloak
The redirect_uri uses http:// instead of https://. Since only https://myapp.fritz.box/* is configured as a valid redirect URI in the Keycloak client, validation fails.
Step 2: Why http Instead of https?
The Spring Boot configuration uses {baseUrl} as a placeholder for the redirect_uri:
# application.yml
spring:
security:
oauth2:
client:
registration:
keycloak:
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
Spring resolves {baseUrl} based on the incoming request. Since the app listens internally on port 8080 via HTTP, it defaults to http:// without further configuration. For Spring to recognize the original client scheme, it needs to evaluate the X-Forwarded-Proto headers from the upstream reverse proxy.
The relevant setting for this is:
server:
forward-headers-strategy: framework
However, this was only set in the application-postgres.yml profile, not in the main application.yml. A quick fix – moving the setting to the main YAML – and redeploying the app. But the error persisted.
Step 3: Does the App Even Work with the Headers?
A direct test inside the pod proved that Spring evaluates the headers correctly:
$ kubectl exec <pod> -- wget -q -S \
--header="X-Forwarded-Proto: https" \
--header="X-Forwarded-Host: myapp.fritz.box" \
-O /dev/null http://localhost:8080/
Location: https://myapp.fritz.box/oauth2/authorization/keycloak
With X-Forwarded-Proto: https explicitly set, Spring builds the correct https:// URL. So the problem was not in the app, but in the header being lost on its way to the pod.
Step 4: Traefik and the X-Forwarded Headers
Caddy automatically sets X-Forwarded-Proto, X-Forwarded-For, and X-Forwarded-Host when using reverse_proxy. Traefik, on the other hand, overwrites incoming X-Forwarded-* headers by default with its own values – and since traffic arrives at the web entrypoint (HTTP), Traefik sets X-Forwarded-Proto: http.
Traefik can be instructed to preserve existing headers from trusted sources:
--entryPoints.web.forwardedHeaders.trustedIPs=192.168.178.71
So I configured Caddy’s IP as trusted, restarted Traefik – and the error still persisted.
Step 5: The Invisible SNAT Rule
The final puzzle piece was hidden in the Kubernetes network. The Traefik service was configured as a LoadBalancer with externalTrafficPolicy: Cluster:
spec:
type: LoadBalancer
externalTrafficPolicy: Cluster
With externalTrafficPolicy: Cluster, kube-proxy forwards traffic via SNAT (Source Network Address Translation). This replaces the original source IP from Caddy (192.168.178.71) with a cluster-internal IP (e.g., 10.42.x.x). So Traefik does not see Caddy’s IP but an internal address – which is not in the trustedIPs list. Consequently, Traefik discards the incoming X-Forwarded-* headers and overwrites them.
The Solution
Since externalTrafficPolicy: Local would work in a single-node k3s setup but can cause issues in multi-node scenarios, the pragmatic solution was to trust the entire private network ranges.
In k3s, the Traefik configuration can be cleanly adjusted via a HelmChartConfig resource without directly editing the deployment:
# helm/traefik-config.yaml
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik
namespace: kube-system
spec:
valuesContent: |-
ports:
web:
forwardedHeaders:
trustedIPs:
- "10.0.0.0/8"
- "192.168.0.0/16"
kubectl apply -f helm/traefik-config.yaml
k3s detects the changed config and automatically rolls out Traefik with the new parameter:
--entryPoints.web.forwardedHeaders.trustedIPs=10.0.0.0/8,192.168.0.0/16
Verification
After the rollout, another curl test confirms that both redirects now use https://:
$ curl -s -D- -o /dev/null https://myapp.fritz.box/oauth2/authorization/keycloak \
| grep location:
location: https://keycloak.fritz.box/.../auth?...&redirect_uri=https://myapp.fritz.box/login/oauth2/code/keycloak
The Keycloak login works.
Summary
| Layer | Problem | Fix |
|---|---|---|
| Spring Boot | forward-headers-strategy only in the Postgres profile | Moved to the main application.yml |
| Traefik | Overwrites X-Forwarded-Proto from Caddy | Configured trustedIPs |
| kube-proxy | SNAT masks Caddy’s source IP | Added private network ranges as trusted |
The real lesson: with multiple proxy layers, it is not enough to configure just the first and last layer. Every hop along the way can modify or discard headers – and Kubernetes network policies like externalTrafficPolicy can silently rewrite the source IP, causing IP-based trust rules to fail.