Passwords are not a security problem you can patch your way out of. You can enforce complexity rules, rotate credentials every 90 days, and train users until you’re blue in the face — phishing still works because credentials are still reusable secrets. FIDO2 breaks that assumption at the protocol level.
This isn’t a theoretical overview. We’re going to actually deploy FIDO2-based passwordless authentication in an enterprise context, cover the architecture decisions that matter, and call out every place where real deployments go sideways.
What FIDO2 Actually Is (And What It Isn’t)
FIDO2 is an umbrella term for two specs: WebAuthn (the browser/server protocol, a W3C standard) and CTAP2 (the protocol between the browser and an authenticator device). Together they let a hardware key, your phone, or a platform TPM perform a cryptographic challenge-response that proves you control a private key — without that key ever leaving the authenticator.
The critical property: every credential is origin-bound. A key registered on sso.yourcompany.com will refuse to sign a challenge from sso.yourcompany.com.evil.phish.net. Phishing is structurally impossible, not just "harder."
What FIDO2 is not: a complete identity solution. It handles the authentication ceremony. You still need an identity provider (IdP), directory services, session management, and an account recovery strategy. That last one will be your biggest headache.
Authenticator Types — Pick Your Battles
Roaming authenticators (CTAP2 hardware keys — YubiKey, Token2, Google Titan) are the gold standard for phishing resistance. They work across devices, are attestable to a known hardware model, and can be centrally provisioned. They also cost money, can be lost, and require a USB-A/USB-C or NFC connection.
Platform authenticators (Windows Hello, Touch ID, Android biometrics) are tied to a specific device. Much lower friction, zero hardware cost, but credential is lost with the device and attestation is less granular. Synced passkeys (iCloud Keychain, Google Password Manager) blur this line further — credentials roam but are now in a cloud sync chain outside your control.
For enterprise, the honest recommendation: hardware keys for privileged access and remote workers, platform authenticators for office workstations with good asset management. Don’t try to standardize on one type — the use cases genuinely differ.
Architecture Overview
A typical enterprise FIDO2 stack looks like this:
User device (browser/OS)
└─ WebAuthn API
└─ IdP / FIDO2 Server (registration + assertion)
└─ Directory (AD, LDAP, or SCIM-synced store)
└─ Applications (SAML/OIDC SP)
Your applications don’t need to speak FIDO2. They speak SAML or OIDC to the IdP, and the IdP handles the FIDO2 ceremony. This is the key architectural insight — centralize the FIDO2 server and federate everything through it.
Options for the IdP/FIDO2 server:
| Option | FIDO2 Support | Self-hosted? | Notes |
|---|---|---|---|
| Microsoft Entra ID | Excellent | No | Best if you’re already in the MS ecosystem |
| Okta | Excellent | No | Strong policy engine, good for mixed environments |
| Keycloak | Good | Yes | Open-source, requires operational investment |
| Authentik | Growing | Yes | Newer, simpler ops, passkey support maturing |
| Duo + custom IdP | Partial | Depends | FIDO2 via Duo, but awkward chaining |
For the rest of this guide we’ll use Keycloak as the self-hosted path and note where Entra ID diverges.
Deploying Keycloak with FIDO2
The official repo: https://github.com/keycloak/keycloak
Here’s a production-leaning Docker Compose setup. Not a dev quickstart — actual TLS termination, a real database, and a config structure you can version-control.
# docker-compose.yml
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
# Use Docker secrets or an external secret manager in prod
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- keycloak_db:/var/lib/postgresql/data
networks:
- internal
keycloak:
image: quay.io/keycloak/keycloak:25.0
restart: unless-stopped
command: start
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD_FILE: /run/secrets/db_password
KC_HOSTNAME: sso.yourcompany.com
KC_PROXY_HEADERS: xforwarded
KC_HTTP_ENABLED: "true" # TLS terminated at the reverse proxy
KC_HTTP_PORT: "8080"
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD_FILE: /run/secrets/admin_password
# Tune JVM for your host; 512m is tight, 1g is comfortable
JAVA_OPTS_APPEND: "-Xms512m -Xmx1g"
secrets:
- db_password
- admin_password
ports:
- "127.0.0.1:8080:8080" # Expose only to reverse proxy
networks:
- internal
depends_on:
- postgres
# Caddy as the TLS-terminating reverse proxy
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
networks:
- internal
volumes:
keycloak_db:
caddy_data:
secrets:
db_password:
file: ./secrets/db_password.txt
admin_password:
file: ./secrets/admin_password.txt
networks:
internal:
driver: bridge
# Caddyfile
sso.yourcompany.com {
reverse_proxy keycloak:8080 {
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
}
}
Bring it up:
mkdir -p secrets
# Generate strong random passwords — don't hardcode these
openssl rand -base64 32 > secrets/db_password.txt
openssl rand -base64 32 > secrets/admin_password.txt
chmod 600 secrets/*.txt
docker compose up -d
Configuring FIDO2 in Keycloak
Keycloak calls FIDO2 credentials "Passkeys" and "Security Keys" depending on the flow version. Here’s what to configure in the admin console — and the CLI equivalents for IaC:
1. Create your realm (or use an existing one). Don’t use the master realm for user auth — that’s admin territory only.
2. Enable the WebAuthn authenticator. Navigate to:
Authentication → Policies → WebAuthn Policy
Key settings:
Relying Party Entity Name: Your Company SSO
Relying Party ID: sso.yourcompany.com # Must match your domain exactly
Signature Algorithms: ES256, RS256 # ES256 preferred; RS256 for older keys
Attestation Conveyance Preference: direct # Get attestation for policy enforcement
Authenticator Attachment: cross-platform # For hardware keys; set "platform" for TPM-only
Require Resident Key: Yes # Required for usernameless flow
User Verification Requirement: required # Enforce PIN/biometric on the key itself
3. Build a passwordless authentication flow.
In Authentication → Flows, create a copy of Browser and modify it:
Browser flow (copy)
├── Cookie [ALTERNATIVE]
├── Identity Provider Redirector [ALTERNATIVE]
└── Browser Forms
├── Username Form [REQUIRED] ← collect username first
└── WebAuthn Passwordless Auth [REQUIRED] ← replaces password
Bind this flow to Browser Flow in realm settings.
4. Set the binding in realm settings:
Authentication → Bindings → Browser Flow → [your new flow]
Keycloak-specific gotcha: if you have existing users with passwords and you switch the browser flow, they will be locked out of password auth. Stage this — bind the new flow to a dedicated client first, not realm-wide, until you’ve migrated everyone.
Terraform / Keycloak IaC (Optional but Smart)
Managing Keycloak config by clicking around the admin console is how you create undocumented drift. Use the mrparkers/keycloak Terraform provider:
resource "keycloak_realm" "corp" {
realm = "corp"
enabled = true
web_authn_policy {
relying_party_entity_name = "Corp SSO"
relying_party_id = "sso.yourcompany.com"
signature_algorithms = ["ES256"]
attestation_conveyance_preference = "direct"
authenticator_attachment = "cross-platform"
require_resident_key = "Yes"
user_verification_requirement = "required"
}
}
Enforcing Attestation — Where Enterprise Gets Serious
Anonymous attestation is fine for consumer passkeys. In enterprise, you want to know that the key is a YubiKey 5 NFC and not a software authenticator someone compiled from GitHub. That’s what attestation metadata is for.
The FIDO Alliance maintains the Metadata Service (MDS3) at https://mds.fidoalliance.org/. Keycloak fetches this automatically and uses it to validate attestation statements.
In practice:
- Configure
Attestation Conveyance Preference: directin the WebAuthn policy. - After registration, the server verifies the attestation certificate chain against MDS3.
- If attestation fails or the device AAGUID isn’t in your allowlist, registration is rejected.
To restrict to specific hardware models, you’ll need an AAGUID allowlist. YubiKey AAGUIDs are published by Yubico. For Keycloak, this requires a custom authenticator SPI or a script policy — it’s not in the UI by default.
Gotcha: many older authenticators use none or indirect attestation. If you enforce direct, you’ll break enrollment for cheap/old keys and some platform authenticators (older Android). Know your device fleet before enabling this in production.
Entra ID Path (For the Microsoft-Aligned)
If you’re in the Microsoft ecosystem, Entra ID (formerly Azure AD) has the most mature enterprise FIDO2 implementation. The relevant docs section is Authentication methods policy.
Enable FIDO2 security keys via the portal or via Graph API:
# Patch the FIDO2 authentication method policy via MS Graph
curl -X PATCH \
'https://graph.microsoft.com/v1.0/policies/authenticationMethodsPolicy/authenticationMethodConfigurations/FIDO2' \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"state": "enabled",
"isAttestationEnforced": true,
"keyRestrictions": {
"isEnforced": true,
"enforcementType": "allow",
"aaGuids": [
"fa2b99dc-9e39-4257-8f92-4a30d23c4118", // YubiKey 5 NFC
"2fc0579f-8113-47ea-b116-bb5a8db9202a" // YubiKey 5Ci
]
}
}'
Entra ID handles attestation validation against MDS3 automatically. Combined with Conditional Access policies (require phishing-resistant MFA → FIDO2 or Windows Hello for Business), this is a fairly tight setup.
Linux SSH — Because Not Everything Is a Browser
Most FIDO2 articles stop at web apps. SSH is a different ceremony, and it’s where sysadmins actually live.
OpenSSH 8.2+ supports FIDO2 hardware keys for authentication. The key types are ecdsa-sk and ed25519-sk.
Generate a resident credential (stored on the key, not the filesystem):
# -O resident stores the key handle on the hardware key itself
# -O verify-required enforces PIN before each use
ssh-keygen -t ed25519-sk -O resident -O verify-required -f ~/.ssh/id_ed25519_sk_corp
Distribute the public key to ~/.ssh/authorized_keys on servers, or better — let your Ansible/Puppet drop it via your directory.
To load resident keys from a plugged-in YubiKey:
ssh-add -K # Imports resident keys from plugged FIDO2 authenticator
Server-side config (/etc/ssh/sshd_config):
PubkeyAuthentication yes
# Enforce only SK key types — reject plain RSA/ECDSA
PubkeyAcceptedAlgorithms [email protected],[email protected]
PasswordAuthentication no
AuthenticationMethods publickey
Gotcha: resident keys require the key to be plugged in at SSH connection time. Non-resident (-O no-touch-required skips the touch, but it also means the private key file sits on disk — that defeats the point). Decide whether you want true hardware-bound or just "key file that requires YubiKey to decrypt."
Rollout Strategy — Don’t Flip the Switch
Ripping out passwords org-wide in one shot is how you spend a weekend on the phone with panicking users. Phased rollout:
Phase 1 — Pilot (2-4 weeks)
- Enroll IT staff and security team.
- Run in parallel: FIDO2 works, password still works.
- Identify the gaps (legacy apps, VPN clients, printers that auth to LDAP).
Phase 2 — Early Adopters (4-8 weeks)
- Opt-in enrollment for motivated users.
- Require FIDO2 for high-risk groups (finance, exec, devops).
- Document and solve the edge cases that surface.
Phase 3 — Forced Enrollment
- Set a deadline. Give hardware keys to users who need them.
- Enable a forced enrollment flow: user logs in with password, is immediately redirected to register a FIDO2 credential, password login disabled after registration.
- Keep a break-glass password procedure for account recovery.
Phase 4 — Kill Passwords
- Remove password auth from the authentication flow.
- Disable password reset by email (phishable).
- Recovery is now: physical presence + IT helpdesk + hardware backup key.
Gotchas
Account recovery is your biggest risk. If a user loses their only hardware key and has no backup, they’re locked out. You need either a second enrolled credential (backup key), an IT-initiated recovery token flow, or both. Design this before you kill passwords, not after.
Legacy protocols will haunt you. IMAP, SMTP AUTH, old VPN clients, printers, CI/CD tokens — none of these speak FIDO2. You need app passwords or service accounts with MFA exemptions and tight IP ACLs. Audit your LDAP/RADIUS consumers before you start.
Attestation metadata has expiry. MDS3 blobs have a nextUpdate field. If your FIDO2 server caches a stale blob, it may reject legitimate authenticators or fail to flag revoked ones. Ensure your server refreshes MDS3 on schedule.
Browser support isn’t uniform. Safari on macOS has lagged behind Chromium on certain CTAP2 features (particularly hybrid/cross-device flows). Test your specific key models against your user’s browsers before committing to the enrollment flow.
NFC in corporate environments. Some offices have NFC-blocking cases on badges. Sounds obscure, until someone can’t register their NFC-only key at their desk.
Windows Hello and domain join. Windows Hello for Business has different enrollment paths depending on whether you’re in a cloud-only, hybrid, or on-prem domain setup. The hybrid path (ADFS + Entra ID) is the most complex and has specific certificate requirements. Read the prerequisites list in full before starting.
Production-Ready Checklist
- FIDO2 server behind TLS with HSTS; HTTPS is not optional — the WebAuthn API refuses to run on plain HTTP (except
localhost) - RP ID matches your domain exactly and is pinned in config (a change breaks all existing credentials)
- MDS3 metadata refreshed at least weekly
- Attestation enforcement enabled for privileged roles at minimum
- Backup hardware key enrolled per user before primary-only policy enforced
- Break-glass recovery procedure documented and tested
- Legacy app audit complete; app password or exemption policy in place
- Monitoring on WebAuthn registration and assertion failure rates (spike = UX problem or attack)
- SSH servers migrated to SK key types with password auth disabled
Deploying FIDO2 properly is a 2-3 month project for a mid-sized org, not an afternoon task. But it’s the only authentication scheme that’s actually phishing-proof by design, and once the hardware is in users’ hands, the day-to-day experience is genuinely better — tap the key, you’re in, no password manager gymnastics. The operational investment pays off faster than you’d expect, especially after your first phishing campaign comes back with zero credential harvests.