Email Verification & Multi-Factor Authentication
A perfect token pipeline is worthless if the wrong person can obtain a token. Wittgenstein therefore pairs its token-handling libraries with strong identity assurance at the identity provider: every account is bound to a verified email address, and sensitive access requires a second factor.
None of this lives in application code. It is enforced by Keycloak before a token is ever issued, so every application on the platform inherits it — and cannot accidentally weaken it.
The assurance ladder
| Level | What Keycloak proves | Enforced through |
|---|---|---|
| 1 — Verified identity | The user controls the mailbox on file | VERIFY_EMAIL required action |
| 2 — Verified identity + possession | …and holds a registered second factor | TOTP authenticator or WebAuthn / passkey |
| Recovery | A lost factor or password can be reset without an administrator handling a secret | Signed, expiring email action links |
Applications read the achieved level from the token (acr claim) and decide what each route requires.
Email verification
Accounts are created unverified. Until the user proves ownership of the address, Keycloak will not complete the login — the VERIFY_EMAIL required action interrupts the flow and sends a signed link.
sequenceDiagram
participant U as User
participant KC as Keycloak
participant SMTP as Mail relay
participant M as Mailbox
U->>KC: First login (password)
KC->>KC: Required action: VERIFY_EMAIL
KC->>SMTP: Verification email (signed, expiring link)
SMTP->>M: Deliver over authenticated TLS
U->>M: Open message, click link
M->>KC: GET action-token
KC->>KC: emailVerified = true
KC-->>U: Continue login → token issued
Why it matters:
- Blocks typo and squatting accounts — nobody can register an address they do not own.
- Makes email a trustworthy recovery channel — password and factor resets go to a mailbox that has been proven, not merely typed.
- Makes
emailclaims safe to use — services can rely onemail_verifiedwhen linking or provisioning identities.
Delivery is handled by the platform's own authenticated SMTP relay, so verification mail does not depend on a third party.
Multi-factor authentication
Keycloak's browser flow is extended with a second-factor step. Supported factors:
| Factor | Type | Notes |
|---|---|---|
| TOTP | Authenticator app (any RFC 6238 app) | Enrolled through the CONFIGURE_TOTP required action |
| WebAuthn / passkeys | Hardware key, platform authenticator | Phishing-resistant: the credential is bound to the site origin |
| Recovery codes | One-time codes | Offline fallback when the device is lost |
MFA can be applied as policy rather than as a per-user setting:
- Always — every user of the realm.
- By role or group — for example, anyone holding an administrative role is challenged; read-only users are not (Keycloak conditional flows).
- Step-up — a user is signed in at level 1, and is asked for the second factor only when they reach something that needs level 2.
Enforcing step-up in your API
Keycloak records the authentication level in the acr claim. Because OidcMiddleware already parsed the token for the request, a route can require the stronger level in a few lines:
from fastapi import Depends, HTTPException
from wittgenstein_oidc_backend import get_current_payload
def require_mfa(payload: dict = Depends(get_current_payload)) -> dict:
if int(payload.get("acr", "0")) < 2: # 2 = password + second factor
raise HTTPException(
status_code=401,
detail="step-up required",
headers={"WWW-Authenticate": 'Bearer error="insufficient_user_authentication"'},
)
return payload
@app.delete("/tenants/{tenant_id}", dependencies=[Depends(require_mfa)])
async def delete_tenant(tenant_id: str): ...
The SPA reacts to that response by redirecting to Keycloak with acr_values=2; the user completes the second factor and returns with a token that satisfies the route.
Email action links
Sensitive account changes never pass through the application. To change a password, the app asks Keycloak to email the user a required-action link; the user picks the new password on Keycloak's own page, where the password policy and brute-force protection apply. The application never sees, stores or transmits the password.
The same mechanism can request CONFIGURE_TOTP (re-enrol a factor), VERIFY_EMAIL (re-verify) or UPDATE_PROFILE. Links are signed, single-purpose and short-lived.
Least privilege for the caller
The service that triggers these emails uses a dedicated service-account client that holds only the realm-management roles it needs (view-users, manage-users), never a full administrator credential.
Session and lockout hardening
Assurance is only as strong as the session that follows it:
- Short-lived access tokens (minutes), with sliding idle timeout and an absolute session maximum.
- Refresh-token rotation, with the token held in an
HttpOnlycookie (see OIDC & Token Security). - Brute-force detection temporarily locks an account after repeated failures, so a second factor cannot be ground down by guessing.
- Password policy (length, history, deny-list) evaluated by Keycloak, in one place.
Verify the wiring
"0",
"1",
"2"
]curl -s -H "Authorization: Bearer $TOKEN" https://app.example.com/api/tenants/t-1 -X DELETE -o /dev/null -w "%{http_code}\n"401# a level-1 token is refused on a level-2 route until the user completes MFA
Rolling MFA out safely
Start with the administrative roles, confirm recovery codes and the email links work end-to-end, then widen the policy. Because enforcement lives in the IdP, widening it never requires a deploy of any application.