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

LayerProblemFix
Spring Bootforward-headers-strategy only in the Postgres profileMoved to the main application.yml
TraefikOverwrites X-Forwarded-Proto from CaddyConfigured trustedIPs
kube-proxySNAT masks Caddy’s source IPAdded 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.