TL;DR: an unauthenticated attacker who only knew a victim's username could reach the "set a new password" step of Keycloak's forgot-password flow **without ever clicking the emailed reset link. CVSS 9.1. Root cause: one missing equality check plus one un-scoped boolean flag. This post walks the actual patch diff, function by function.
What was possible
No prior credentials. No user interaction from the victim. Just a username. That's enough to land on the password-reset screen for someone else's account. That's why CVE-2026-18963 landed a 9.1 (Critical) on CVSS 3.1.
| CVE | CVE-2026-18963 |
| Component |
keycloak-services, reset-credentials auth flow |
| CVSS 3.1 | 9.1 — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N
|
| CWE | CWE-640 (Weak Password Recovery Mechanism) |
| Reported by | James Paremain |
| Disclosed | 2026-08-18 (Red Hat) / 2026-08-19 (keycloak#51833) |
| Fix commit |
dc2d4e524b4dae85aedc87ca28b9e4fa567d56c1 (PR #51844) |
| Fixed in | 26.7.2 / 26.6.6 / 26.4.15 (LTS) / 26.8.0 |
Below, I walk the attack in the order an attacker would actually hit it, dropping in the real source at each step.
A quick primer on Keycloak's Authenticator model
Every step of a Keycloak flow (login, forgot-password, etc.) is an Authenticator with two entry points:
-
authenticate(context)— called when the step is first shown. It can render a form, or callcontext.success()immediately if the step's condition is already satisfied. -
action(context)— called when the user submits that form (POST). It validates the submission and callscontext.success()orcontext.failure().
The reset-credentials flow chains two of these:
reset-credentials-choose-user → org.keycloak.authentication.authenticators.resetcred.ResetCredentialChooseUser
reset-credential-email → org.keycloak.authentication.authenticators.resetcred.ResetCredentialEmail
Clicking the link in the reset email re-enters the same flow through a separate Action Token path (ResetCredentialsActionTokenHandler), which stamps the auth session with DefaultActionTokenKey.ACTION_TOKEN_USER_ID. That value — "the link that was clicked was minted for this exact user ID" — is the only signal that email verification actually happened. Everything below comes down to one place where that signal stopped being checked.
Step 1 — Pin the target account
The attacker submits the victim's username to the first authenticator:
// ResetCredentialChooseUser.java — action()
UserModel user = context.getSession().users().getUserByUsername(realm, username);
...
context.getAuthenticationSession().setAuthNote(RESET_CREDENTIAL_USER_CHOSEN, "true");
context.setUser(user); // ← the session is now pinned to the victim
context.success();
Nothing has been emailed yet, nothing has been verified. This just sets a pointer: "this session's target is this user." That pointer gets trusted downstream without re-verification — which is the precondition for everything that follows.
Step 2 — The legitimate path stalls here
The flow moves to reset-credential-email. For a normal user, this is where it stops until they check their inbox:
// ResetCredentialEmail.java — authenticate()
String actionTokenUserId = authenticationSession.getAuthNote(DefaultActionTokenKey.ACTION_TOKEN_USER_ID);
if (actionTokenUserId != null && Objects.equals(user.getId(), actionTokenUserId)) {
context.success(); // re-entered via the emailed action token
return;
}
...
context.getSession().getProvider(EmailTemplateProvider.class)....sendPasswordReset(link, expirationInMinutes);
context.forkWithSuccessMessage(new FormMessage(Messages.EMAIL_SENT)); // wait screen, nothing more happens
At this point ACTION_TOKEN_USER_ID isn't in the session yet — the attacker can't read the victim's inbox, so authenticate() alone stops them cold. The interesting bug lives in the sibling method, action().
Step 3 — Tamper with the screen state to reroute past authenticate()
Keycloak decides which Authenticator handles an incoming request using an execution URL parameter. This is where the second bug comes in — the "try another way" screen-switch logic in DefaultAuthenticationFlow.java:
// pre-patch — DefaultAuthenticationFlow.java
if (inputData.containsKey("tryAnotherWay")) {
processor.getAuthenticationSession()
.setAuthNote(AuthenticationProcessor.AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED, "true"); // ← plain boolean
return createSelectAuthenticatorsScreen(model);
}
...
public Response processFlow() {
if (Boolean.parseBoolean(processor.getAuthenticationSession()
.getAuthNote(AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED))) {
String lastExecutionId = ...getAuthNote(CURRENT_AUTHENTICATION_EXECUTION);
if (lastExecutionId != null) {
// ← as long as the flag is "true", whichever execution is
// "current" gets trusted, regardless of which step actually set the flag
return createSelectAuthenticatorsScreen(executionModel);
}
}
}
AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED is a session-wide "true"/"false" string with zero information about which execution set it. CURRENT_AUTHENTICATION_EXECUTION, meanwhile, keeps getting overwritten as the flow progresses. Combining "a selector screen was shown at some point" with "whatever execution is current right now" assumes the two always refer to the same step — an assumption the code never actually enforced.
By tripping this screen switch, an attacker could get a request aimed at the reset-credential-email execution routed straight to its action() method, skipping authenticate() entirely.
Step 4 — The actual hole: ResetCredentialEmail.action()
Here's the whole pre-patch method:
// pre-patch — ResetCredentialEmail.java
@Override
public void action(AuthenticationFlowContext context) {
context.success();
}
One line. It doesn't check whether ACTION_TOKEN_USER_ID exists, and it doesn't check whether it matches the current user — the exact comparison authenticate() performs a few lines above it is simply absent from action(). However this method got invoked, it always said yes.
Combine this with step 3's rerouting, and the entire "wait for the email" gate becomes a formality.
Step 5 — Account takeover
The flow advances to the set-new-password screen. Since context.setUser(victim) from step 1 is still in effect, the attacker types in a new password for an account they never proved control of — game over.
What the patch actually changed
ResetCredentialEmail.java — put the missing check back where it belongs:
public void action(AuthenticationFlowContext context) {
- context.success();
+ UserModel user = context.getUser();
+ String actionTokenUserId = context.getAuthenticationSession().getAuthNote(DefaultActionTokenKey.ACTION_TOKEN_USER_ID);
+ if (user != null && user.getId().equals(actionTokenUserId)) {
+ context.success();
+ } else {
+ context.failure(AuthenticationFlowError.INVALID_USER);
+ }
}
AuthenticationProcessor.java / DefaultAuthenticationFlow.java — bind the screen-state flag to the execution that actually set it:
-// Boolean flag, which is true when authentication-selector screen should be rendered
+// Flag with the model id when authentication-selector screen should be rendered
public static final String AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED = "auth.selector.screen.rendered";
if (inputData.containsKey("tryAnotherWay")) {
- ...setAuthNote(AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED, "true");
+ ...setAuthNote(AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED, model.getId()); // store this execution's own ID
return createSelectAuthenticatorsScreen(model);
}
...
public Response processFlow() {
- if (Boolean.parseBoolean(...getAuthNote(AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED))) {
- String lastExecutionId = ...getAuthNote(CURRENT_AUTHENTICATION_EXECUTION);
- if (lastExecutionId != null) { ... return createSelectAuthenticatorsScreen(executionModel); }
+ String selector = ...getAuthNote(AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED);
+ if (selector != null) {
+ String lastExecutionId = ...getAuthNote(CURRENT_AUTHENTICATION_EXECUTION);
+ if (selector.equalsIgnoreCase(lastExecutionId)) { // must be the exact same execution
+ ... return createSelectAuthenticatorsScreen(executionModel);
+ } else {
+ ...removeAuthNote(AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED); // stale flag, drop it
+ }
+ }
}
Instead of a bare "true", the note now stores the exact execution ID that displayed the selector screen, and re-entry only re-renders it when that ID matches the current execution byte-for-byte. Screen state and execution step are now bound 1:1. Either fix alone would have closed most of the path; together they close it completely.
Affected versions
| Stream | Vulnerable | Fixed |
|---|---|---|
| 26.7.x | < 26.7.2 | 26.7.2 |
| 26.6.x | < 26.6.6 | 26.6.6 |
| 26.4.x (LTS) | < 26.4.15 | 26.4.15 |
| 26.8.x | < 26.8.0 | 26.8.0 |
26.5.x and earlier are out of support and won't get a backport — upgrading to a supported stream is the only real fix. If you can't patch immediately, disabling resetPasswordAllowed on the realm removes the entry point (step 1) at the cost of your users' self-service password reset.


Top comments (0)