[Bug]: `state` is double URL-encoded in the redirect when `response_mode=fragment`
Preflight Checklist
- I could not find a solution in the documentation, the existing issues or discussions
- I have joined the ZITADEL chat
Environment
Self-hosted
Version
4.17.1 (also reproduced on 4.16.0)
Database
PostgreSQL
Database Version
17
Describe the problem caused by this bug
When the authorization response is returned via response_mode=fragment, the state parameter is
percent-encoded a second time. A state value containing characters that require encoding comes
back with % itself encoded, so decoding the fragment once yields a different string from the one
the client sent.
The same request without response_mode=fragment (Zitadel then defaults to query) returns
state correctly, so the defect appears to be confined to fragment assembly.
This breaks any client that validates state against a cached value — which is every conformant
OAuth client, since state is the CSRF defence. The failure is silent: the client cannot match the
response to its pending request, so it discards it without an error, and applications typically
appear to "redirect-loop" with no diagnostic.
Base64-encoded state values are the common case in the wild (many libraries encode a JSON object
and rely on = padding), so this is reached easily.
To reproduce
Any OIDC application (reproduced with a User Agent / PKCE app). Send a state whose value contains
a character requiring encoding — here PADTEST==, sent as PADTEST%3D%3D:
1. With response_mode=fragment — incorrect
GET /oauth/v2/authorize
?client_id=<client-id>
&response_type=code
&scope=openid
&redirect_uri=http%3A%2F%2Flocalhost%3A3001%2F
&code_challenge=<S256 challenge>
&code_challenge_method=S256
&response_mode=fragment
&state=PADTEST%3D%3DRedirects to:
http://localhost:3001/#code=...&state=PADTEST%253D%253D2. Identical request, response_mode omitted (defaults to query) — correct
http://localhost:3001/?code=...&state=PADTEST%3D%3DExpected behavior
The state returned in the fragment should decode to exactly the value the client sent:
#code=...&state=PADTEST%3D%3D → decodes to PADTEST==Actual:
#code=...&state=PADTEST%253D%253D → decodes to PADTEST%3D%3DScreenshots
n/a — reproducible from the address bar.
Additional Context
- Reproduced on v4.16.0 and v4.17.1, self-hosted via the official
docker-composestack with the Let's Encrypt overlay, Login v2 enabled. - Clients that hardcode fragment for browser flows cannot work around this. For example
@azure/msal-browserdeliberately omitsresponseModefrom its public request types (RedirectRequest,PopupRequest,SsoSilentRequestallOmit<..., "responseMode" | ...>), because a fragment is not sent to the server and so keeps the authorization code out of server logs andRefererheaders. For such clients there is no client-side mitigation. - Possibly related, both touching parameter encoding / response-mode handling in Login v2:
- #12120 —
response_mode=form_postignored, token delivered via fragment instead (open) - #12514 — URI encoded
login_hintis not being decoded (closed)
- #12120 —
Source: zitadel/zitadel