Skip to content

Separate password and OTP brute force protection to prevent OTP bypass attacks by default#46168

Open
pskopek wants to merge 1 commit intokeycloak:mainfrom
pskopek:issues/otpAbsolutLockout
Open

Separate password and OTP brute force protection to prevent OTP bypass attacks by default#46168
pskopek wants to merge 1 commit intokeycloak:mainfrom
pskopek:issues/otpAbsolutLockout

Conversation

@pskopek
Copy link
Contributor

@pskopek pskopek commented Feb 10, 2026

Separate password and OTP brute force protection to prevent OTP bypass attacks by default

Closes #46164

@mposolda mposolda self-assigned this Feb 10, 2026
@pskopek pskopek force-pushed the issues/otpAbsolutLockout branch from 3a51e50 to c695a03 Compare February 11, 2026 12:12
…s attacks by default

Closes keycloak#46164

Signed-off-by: Peter Skopek <peter.skopek@ibm.com>

Update model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/loginfailures/LoginFailuresUpdater.java

Co-authored-by: Pedro Ruivo <pruivo@users.noreply.github.com>
Signed-off-by: Peter Skopek <peter.skopek@ibm.com>

Add recovery codes to the list of brute force checked authenticators.

Closes keycloak#46164
Signed-off-by: Peter Skopek <peter.skopek@ibm.com>
@pskopek pskopek force-pushed the issues/otpAbsolutLockout branch from c695a03 to 6e32ba3 Compare February 12, 2026 05:52
@pskopek
Copy link
Contributor Author

pskopek commented Feb 12, 2026

Fixed the DCO issue (no other changes).

Comment on lines +1750 to +1753
if (realm.getRequiredCredentialsStream().anyMatch(r -> Objects.equals(r.getType(), RequiredCredentialModel.PASSWORD.getType()))) {
return RequiredCredentialModel.PASSWORD.getType();
} else if (realm.getRequiredCredentialsStream().anyMatch(r -> Objects.equals(r.getType(), RequiredCredentialModel.TOTP.getType()))) {
return RequiredCredentialModel.TOTP.getType();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible the realm requires both a password and TOTP? If so, this method will never return the TOTP type.

}

@Test
public void testGrantPermamentOtpAbsolut() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo

Suggested change
public void testGrantPermamentOtpAbsolut() {
public void testGrantPermanentOtpAbsolut() {

AccessTokenResponse response = getTestToken(getPassword("test-user@localhost"), totpSecret);
assertTokenNull(response);
Assert.assertNotNull(response.getError());
Assert.assertEquals(response.getError(), "invalid_grant");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parameters in the wrong order:

Suggested change
Assert.assertEquals(response.getError(), "invalid_grant");
Assert.assertEquals("invalid_grant", response.getError());

events.clear();
}
}
{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why you need this block

Comment on lines +413 to +414
Assert.assertEquals(response.getError(), "invalid_grant");
Assert.assertEquals(response.getErrorDescription(), "Invalid user credentials");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Assert.assertEquals(response.getError(), "invalid_grant");
Assert.assertEquals(response.getErrorDescription(), "Invalid user credentials");
Assert.assertEquals("invalid_grant", response.getError());
Assert.assertEquals("Invalid user credentials", response.getErrorDescription());

@mposolda mposolda requested a review from rmartinc February 12, 2026 14:48
Copy link
Contributor

@rmartinc rmartinc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @pskopek!

I think the test is not testing the feature and, checking the test, I detected the category is not correctly detected in direct access grants. I did a quick fix in branch https://github.com/rmartinc/keycloak/tree/pr-46168. But probably something will fail. 😄

);

public static final String OTP_CATEGORY = OTPCredentialModel.TYPE;
public static final int MAX_OTP_FAILURES = 100;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would have allowed this as configurable in the brute force, but we can do this later if it is requested.

Assert.assertNotNull(response.getError());
Assert.assertEquals(response.getError(), "invalid_grant");
Assert.assertEquals("Invalid user credentials", response.getErrorDescription());
assertUserDisabledEvent(Errors.USER_TEMPORARILY_DISABLED);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pskopek I think that this test is not testing that the the user is permanently locked after 100 failed OTP attempts. Probably you need to set in the realm failure factor to more than 100 and quick login check millis to 0. This way the user is not temporarily locked out and you can check the OTP is really permanently locking the user.

: AuthenticationProcessor.LAST_PROCESSED_EXECUTION
)
);
if (execution == null) return null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Playing with your test, I think that direct access grants return null execution here so OTP is not detected here for that grant. Maybe we have to pass the authenticator on failure and use the credentials used in success. I did a quick tets in branch: https://github.com/rmartinc/keycloak/tree/pr-46168

Copy link
Contributor

@mposolda mposolda left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not checked the details in the codebase as it is already reviewed by others. Just some high level thoughts:

  • It seems to me that the behavior of automatically doing permanent lockout for OTP is not backwards compatible. Existing deployments may not expect this behavior as there is not permanent lockout as of now. I suppose we may need a switch on the brute-force protector (disabled by default) and make this behavior enabled just with the switch?

  • It will be good to document this also in the brute-force protector documentation (probably together with the new switch added from above if we add this). As upgrading guide is something, which people read just when updating to the particular release.

  • I think we should also document (in both brute-force docs and upgrading guide) the behavior around custom authenticators (x509, passkeys, IDP login etc) and about the fact that they do not count anymore to brute-force and successful login with those do not reset brute-force


[NOTE]
====
OTP type authenticators will cause permanent user account lockout even in case of temporary lockout brute force mode is set. This will mitigate probabilistic attack. The OTP oauthenticator type threshold is hardcoded to 100. This counter is incremented only in case of using OTP type authenticator.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
OTP type authenticators will cause permanent user account lockout even in case of temporary lockout brute force mode is set. This will mitigate probabilistic attack. The OTP oauthenticator type threshold is hardcoded to 100. This counter is incremented only in case of using OTP type authenticator.
OTP type authenticators will cause permanent user account lockout even in case of temporary lockout brute force mode is set. This will mitigate probabilistic attack. The OTP authenticator type threshold is hardcoded to 100. This counter is incremented only in case of using OTP type authenticator.

}

@Override
public int getNumOtpFailures() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to rename this method (and other related methods and constants) to something more generic, so that brute-force protector API do not reference "OTP" as concrete authentication method in those methods? Perhaps something like getNumSecondaryAuthFailures()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Separate password and OTP brute force protection to prevent OTP bypass attacks by default

5 participants