This document covers the security model of the push MFA extension and the obligations for mobile app implementations.
- Signed artifacts end-to-end: Enrollment and confirm tokens are JWTs signed by realm keys, and device responses are signed with the user key pair. Every hop is authenticated and tamper-evident.
- Challenge binding: Enrollment tokens embed a nonce plus enrollment id, and login approvals reference the opaque challenge id (
cid), so replaying a response for a different user or challenge fails. - Limited data exposure: Confirm tokens carry only the credential id and challenge id, preventing the push channel from learning the user's identity or whether a login succeeded; the app fetches username/client metadata via
/login/pendingbefore showing the approval UI. - Short-lived state: Challenge lifetime equals every token's
exp, so an attacker has at most a few minutes to replay data even if transport is intercepted. - Key continuity: The stored
cnf.jwkcouples future approvals to the same hardware-backed key, giving Keycloak a stable signal that a response truly came from the enrolled device. - Hardware-bound authentication: Every REST call is authenticated with a JWT signed by that device's private key, which is far more secure than distributing an easily reverse-engineered client secret inside the mobile app. Stealing the client binary is no longer enough; the attacker must compromise the device's key material as well.
- DPoP-bound access tokens: Each access token carries a
cnf.jktthumbprint that must match the enrolled device's JWK. The server recomputes the thumbprint from the stored credential and rejects any DPoP proof or access token that doesn't match, so only the key pair used during enrollment can successfully invoke the APIs.
Keycloak's brute force protector locks user accounts after a configurable number of LOGIN_ERROR events. Because context.failureChallenge() in the authentication SPI fires a LOGIN_ERROR event, MFA authenticators must be careful not to report non-credential errors as failures — otherwise benign scenarios (challenge timeouts, rate-limit hits, internal errors) can lock users out.
The authenticator uses context.challenge() (not failureChallenge()) for situations that are not credential failures:
| Scenario | Reason |
|---|---|
| Challenge expired | The user simply did not respond in time. Not a wrong credential. |
| Internal / system error | Server-side problem, not a user mistake. |
| Creation lock contention | Concurrent request race condition. |
| Pending challenge limit exceeded | Rate limiting, not a credential failure. |
These errors still display an appropriate error page but do not increment Keycloak's brute force failure counter.
Only the explicit challenge denied flow — where the user (or their device) actively rejects the push challenge — calls context.failureChallenge(INVALID_CREDENTIALS) and increments the failure counter. This is intentional: repeated denials may indicate that an unauthorized party is attempting to log in and the legitimate user is rejecting the prompts.
Additionally, the following events fire LOGIN_ERROR via the Keycloak Event Bridge (if enabled). These are device-side REST API errors and are independent of the authenticator's brute force interaction:
| Event | Error Code |
|---|---|
ChallengeResponseInvalidEvent |
push_mfa_invalid_response |
DpopAuthenticationFailedEvent |
push_mfa_dpop_auth_failed |
UserLockedOutEvent |
push_mfa_user_locked_out |
- Keep the brute force protector enabled. The authenticator is designed to be compatible with it.
- Set a reasonable failure threshold. Since only explicit denials count, the threshold can be lower than you might expect for a traditional MFA authenticator.
- Use wait challenge rate limiting as a complementary measure (see SPI Reference — Wait Challenge Rate Limiting). It throttles challenge creation with exponential backoff, independent of the brute force protector.
Check issuer, audience, signature, and exp on enrollment and confirm tokens before acting. Fetch the realm JWKS over HTTPS and cache it defensively.
Generate it with high-entropy sources, store the private key in Secure Enclave/Keystore/KeyChain, and never export it. Rotate/re-enroll immediately if compromise is suspected.
When a confirm token arrives, compare the cid and credId against locally stored state and discard anything unexpected or expired.
Call the Keycloak endpoints only over TLS, validate certificates (no user-controlled CA overrides), and pin if your threat model requires it.
Keep the credential id ↔ real user mapping, push provider identifiers/types, and enrollment metadata in encrypted storage with OS-level protection.
Treat 4xx responses (expired, invalid signature, nonce mismatch) as security events, notifying the user and requiring a fresh enrollment or login attempt rather than silently retrying.
The browser-side SSE endpoints are not DPoP-protected and are not bound to the Keycloak browser session. They are protected by a per-challenge random watchSecret carried in the watcher URL.
- Treat the full SSE URL, including
?secret=..., as a bearer secret. - Anyone who learns that URL can observe the matching challenge status until it resolves or expires.
- Observing SSE status does not let the watcher approve or deny the challenge; those actions still require the enrolled device key and DPoP-bound API calls.
- Avoid logging full query strings for
/realms/<realm>/push-mfa/*/events. - Keep the login and enrollment pages free of third-party JavaScript.
- Use a restrictive Content Security Policy so injected scripts cannot read and exfiltrate the SSE URL from the DOM.
- Keep XSS protections on the Keycloak theme pages high, because XSS on those pages can steal the SSE capability URL.