A Keycloak extension that adds push-based multi-factor authentication, similar to passkey primitives.
Add the dependency to your project:
<dependency>
<groupId>de.arbeitsagentur.opdt</groupId>
<artifactId>keycloak-push-mfa-extension</artifactId>
<!-- Check the badge above or Maven Central for the latest version -->
<version>1.5.0</version>
</dependency>Copy the JAR to Keycloak's providers/ directory and restart Keycloak.
# Build the provider
mvn -DskipTests package
# Run Keycloak with the demo realm
docker compose up- Keycloak Admin Console: http://localhost:8080 (login:
admin/admin) - Demo Realm:
demowith test usertest/test - Demo Configuration: See
config/demo-realm.jsonfor a working example
This project extends Keycloak with a push-style second factor that mimics passkey primitives. After initial enrollment, the mobile app never receives the real user identifier from Keycloak; instead, it works with a credential id that only the app can map back to the real user. Everything is implemented with standard Keycloak SPIs plus a small JAX-RS resource exposed under /realms/<realm>/push-mfa.
sequenceDiagram
autonumber
participant Browser as User / Browser
participant Keycloak as Keycloak Server
participant Provider as Push Provider (FCM/APNs)
participant Mobile as Mobile App
Note over Browser, Mobile: **Phase 1: Enrollment (Register Push MFA Device)**
Browser->>Keycloak: Login & Trigger Enrollment
Keycloak-->>Browser: Render QR Code & Start SSE Listener
par Parallel Actions
Browser->>Keycloak: SSE Request (Read Current Status)
Browser->>Mobile: Scan QR Code
end
Note over Mobile: Verify Token & Generate User Key Pair
Mobile->>Keycloak: POST /enroll/complete
Note right of Mobile: Payload: Device JWT + Public JWK<br/>Signed with new Device Private Key
Keycloak->>Keycloak: Verify Signature & Store Device Credential
Keycloak-->>Browser: SSE Event: { status: "APPROVED" }
Browser->>Keycloak: Auto-Submit Form (Enrollment Complete)
Note over Browser, Mobile: **Phase 2: Login (Push MFA Confirmation)**
Browser->>Keycloak: Login (Username/Password)
Keycloak->>Keycloak: Generate Challenge & ConfirmToken
par Parallel Actions
Keycloak-->>Browser: Render "Waiting for approval..." Page
Browser->>Keycloak: SSE Request (Read Current Challenge Status)
Keycloak->>Provider: Send Push Notification
Note right of Keycloak: Payload: ConfirmToken<br/>(Credential ID, ChallengeID)
end
Provider->>Mobile: Deliver Push Notification
Mobile->>Mobile: Decrypt Token & Resolve User ID
Mobile-->>Browser: (User Prompt: Approve?)
Browser-->>Mobile: User Taps "Approve"
Mobile->>Keycloak: POST /login/challenges/{cid}/respond
Note right of Mobile: Payload: LoginToken (Action: Approve)<br/>Auth: DPoP Header + Access Token<br/>Signed with Device Private Key
Keycloak->>Keycloak: Verify DPoP, Signature & Challenge ID
Keycloak-->>Browser: SSE Event: { status: "APPROVED" }
Browser->>Keycloak: Auto-Submit Form (Login Success)
The SSE endpoints keep a long-lived stream open per browser, but each Keycloak node uses a single node-local poller thread to watch the shared challenge store for all of its currently connected SSE clients. Cross-node delivery works because every node reads the same challenge state from shared storage; if a node dies, the browser's normal EventSource reconnect can land on another node and that node becomes responsible for the stream.
| Document | Description |
|---|---|
| Setup Guide | Step-by-step configuration instructions and Keycloak concepts |
| Flow Details | Technical details of enrollment, login, SSE, and DPoP authentication |
| API Reference | REST endpoints for mobile apps |
| Configuration | All configuration options reference |
| App Implementation | Guide for mobile app developers |
| SPI Reference | Push notification, event, and rate limiting SPIs |
| UI Customization | Theme and template customization |
| Security | Security model and mobile app obligations |
| Mobile Mock | Testing without a real mobile app |
| Troubleshooting | Common issues and solutions |