Skip to content

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 email claims safe to use — services can rely on email_verified when 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.

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.

curl -s -X PUT "https://auth.example.com/admin/realms/acme/users/$USER_ID/execute-actions-email?lifespan=3600" \ -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '["UPDATE_PASSWORD"]' -o /dev/null -w "%{http_code}\n"204# the user now has a one-hour, single-purpose link in their mailbox

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 HttpOnly cookie (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

curl -s https://auth.example.com/realms/acme/.well-known/openid-configuration | jq '.acr_values_supported'[
"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.