#12645·zitadel

[Bug]: `state` is double URL-encoded in the redirect when `response_mode=fragment`

Author: jhartman-mviCreated Aug 26, 2026Updated Sep 17, 2026

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%3D

Redirects to:

http://localhost:3001/#code=...&state=PADTEST%253D%253D

2. Identical request, response_mode omitted (defaults to query) — correct

http://localhost:3001/?code=...&state=PADTEST%3D%3D

Expected 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%3D

Screenshots

n/a — reproducible from the address bar.

Additional Context

  • Reproduced on v4.16.0 and v4.17.1, self-hosted via the official docker-compose stack with the Let's Encrypt overlay, Login v2 enabled.
  • Clients that hardcode fragment for browser flows cannot work around this. For example @azure/msal-browser deliberately omits responseMode from its public request types (RedirectRequest, PopupRequest, SsoSilentRequest all Omit<..., "responseMode" | ...>), because a fragment is not sent to the server and so keeps the authorization code out of server logs and Referer headers. 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_post ignored, token delivered via fragment instead (open)
    • #12514 — URI encoded login_hint is not being decoded (closed)