{"uuid": "e534d73e-9f2a-448b-a4c7-2dae03fa6ead", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "CVE-2026-9689", "type": "seen", "source": "https://gist.github.com/m-claassen-at-nieuwestroom/aa4b9037c2c04c14d9d1f3b739afee8d", "content": "# Magic-link login fails with `Invalid parameter: redirect_uri` since Keycloak 26.6.5\n\nA minimal reproduction using an unmodified `quay.io/phasetwo/phasetwo-keycloak` image, a throwaway\nrealm, and a one-page keycloak-js SPA. No custom providers or themes.\n\n## Run it\n\n```bash\nnpm install &amp;&amp; npx playwright install chromium\nKC_TAG=26.6.6 docker compose up -d        # Keycloak on :8080, SPA on :8000\n./repro-curl.sh                           # isolates the redirect_uri validation (no browser)\nnode repro-browser.mjs                    # full magic-link flow -&gt; fails\nnode repro-browser.mjs --workaround       # same flow, SPA strips stale callback params -&gt; works\nKC_TAG=26.6.4 docker compose up -d --force-recreate keycloak &amp;&amp; node repro-browser.mjs   # -&gt; works\n./repro-curl.sh matrix                    # redirect_uri probe across image versions\n./list-providers.sh 26.6.4 26.6.6         # bundled extension versions per image\n```\n\n## What happens\n\n`repro-browser.mjs` creates a user with the built-in `UPDATE_PASSWORD` required action. It then\ncalls `POST /realms/repro/magic-link` (`client_id=frontend`, `redirect_uri=http://localhost:8000/`,\n`send_email=false`), opens the link, sets a password, and follows the browser.\n\n1. `GET /login-actions/action-token?key=\u2026` \u2192 302 to `required-action?\u2026&amp;client_data=`\n   `{\"ru\":\"http://localhost:8000/\",\"rt\":\"code\"}`. The auth session has **no `response_mode` and no\n   `state`**.\n2. `POST /login-actions/required-action` (password set) \u2192 302 to\n   `http://localhost:8000/?session_state=\u2026&amp;iss=\u2026&amp;code=\u2026`. This is a **query-mode** callback\n   **without `state`**.\n3. keycloak-js (`onLoad: 'login-required'`, default `responseMode: 'fragment'`) only parses\n   `location.hash`, so it doesn't recognise this as a callback. It leaves the URL as is and starts a\n   new login with `redirect_uri = location.href`, which is the URL that still contains\n   `?session_state=\u2026&amp;iss=\u2026&amp;code=\u2026`.\n4. **26.6.4:** the wildcard `http://localhost:8000/*` accepts that URI. Keycloak appends a second\n   response (`?\u2026code=X#state=\u2026&amp;code=Y`) and the login succeeds.\n   **26.6.5+:** the CVE-2026-9689 guard rejects any `redirect_uri` containing `code`, `state`,\n   `iss`, `session_state`, \u2026 \u2192 `400 Invalid parameter: redirect_uri` (`LOGIN_ERROR`,\n   `invalid_redirect_uri`).\n\n## Results\n\n| Image | `redirect_uri=http://localhost:8000/?code=\u2026` (curl) | Magic-link flow | With SPA workaround |\n| --- | --- | --- | --- |\n| `quay.io/keycloak/keycloak:26.6.4` | 200 accepted | n/a (no magic-link extension) | n/a |\n| `phasetwo-keycloak:26.6.4` | 200 accepted | logged in | logged in |\n| `phasetwo-keycloak:26.6.5` | **400** | (not run; same core guard) | \u2014 |\n| `phasetwo-keycloak:26.6.6` | **400** | **`Invalid parameter: redirect_uri`** | logged in |\n\n`foo=bar` is accepted on every version. Each of `code`, `state`, `iss` and `session_state` on its\nown is enough to be rejected on 26.6.5+ (see `curl-matrix.txt`). Upstream never published\n`quay.io/keycloak/keycloak` images for 26.6.5/26.6.6.\n\nBundled versions: `keycloak-magic-link` is **0.75 in all three images**. Between 26.6.4 and 26.6.5\nonly Keycloak core (26.6.4 \u2192 26.6.5) and `keycloak-orgs` (0.172 \u2192 0.173) change.\n\n## Analysis\n\n- The new rejection is intended upstream behaviour: CVE-2026-9689 /\n  [keycloak#49430](https://github.com/keycloak/keycloak/issues/49430), backported to 26.4.14,\n  26.6.5 and 26.7.0.\n- Upstream hit the same problem with organization invitations,\n  [keycloak#52157](https://github.com/keycloak/keycloak/issues/52157). They fixed it client-side in\n  [keycloak#52462](https://github.com/keycloak/keycloak/pull/52462): the admin/account consoles\n  strip `code`/`error` query params that arrive without `state` before `keycloak.init()`.\n- The magic-link extension builds an auth session without `response_mode` or `state`, so every\n  magic-link login ends in a query-mode, state-less callback. SPAs using keycloak-js in fragment\n  mode (the default) can't consume it, and since 26.6.5 they can't recover from it either.\n\n## Workaround (client-side)\n\nBefore `keycloak.init()`, remove the OIDC response params from the query string when they are\nclearly a foreign/unsolicited callback. See `index.html` (`?workaround=1`).\n\n---\n\n## Draft issue for p2-inc/keycloak-magic-link\n\n**Title:** Magic-link login returns a query-mode callback without `state`; since Keycloak 26.6.5\n(CVE-2026-9689) keycloak-js SPAs fail with `Invalid parameter: redirect_uri`\n\n**Versions:** `quay.io/phasetwo/phasetwo-keycloak` 26.6.5 and 26.6.6 (`keycloak-magic-link` 0.75),\nkeycloak-js 26.2.2. Works on 26.6.4 (same magic-link 0.75).\n\n**Steps:** see \"Run it\" above. Minimal realm, public client with `redirectUris: [\".../*\"]`, user\nwith `UPDATE_PASSWORD`, link from `POST /realms/{realm}/magic-link`.\n\n**Expected:** after completing the magic link and its required actions, a keycloak-js client using\nthe default `responseMode: 'fragment'` is logged in.\n\n**Actual:** the final redirect is `redirect_uri?session_state=\u2026&amp;iss=\u2026&amp;code=\u2026` (query mode, no\n`state`; `client_data` = `{\"ru\":\u2026,\"rt\":\"code\"}`). keycloak-js ignores it and re-authorizes with\n`redirect_uri = location.href`, and the CVE-2026-9689 guard rejects that with\n`invalid_redirect_uri`.\n\n**Related:** keycloak#49430 (CVE-2026-9689), keycloak#52157 / #52462 (the same symptom for\norganization invitations, fixed client-side in the consoles).\n\n**Suggestion:** let the magic-link API take `response_mode` (and optionally `state`/`nonce`) and\nstore them on the auth session, so SPAs can receive a callback they recognise. If that isn't\nfeasible, document that magic-link callbacks are unsolicited query-mode responses, and that SPAs\nmust strip them (as #52462 does) before initialising their OIDC client.\n", "creation_timestamp": "2026-09-24T12:48:14.000000Z"}