Skip to content

필사 모드: Passkeys Enterprise Rollout Guide — From WebAuthn/FIDO2 to Keycloak Integration

English
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

Introduction — The End of Passwords, 2026

As of 2026, the password has officially become a "legacy authentication mechanism." According to the FIDO Alliance, passkey support across major consumer services (Google, Apple, Microsoft, Amazon, TikTok, and others) is now ubiquitous, and billions of accounts have already enrolled passkeys. As the Verizon DBIR report points out year after year, the majority of breaches still start with stolen credentials and phishing. As long as passwords exist, phishing will not go away.

The calculus in the enterprise is somewhat different from the consumer world. It is not simply that "login gets easier" — three drivers dominate:

1. **Phishing resistance**: Since the US OMB Zero Trust strategy (M-22-09) mandated phishing-resistant MFA for federal agencies, regulations in finance, healthcare, and the public sector have been converging in the same direction. It has been empirically demonstrated that OTP and push-notification MFA are powerless against adversary-in-the-middle phishing kits (Evilginx and friends).

2. **Helpdesk cost**: Password resets are the classic cost item, accounting for 20 to 50 percent of helpdesk tickets. Going passwordless structurally eliminates this cost.

3. **User experience and conversion**: Higher login success rates and shorter login times directly impact both employee productivity and customer conversion.

In this post we first dissect the internals of the WebAuthn/FIDO2 protocol, then cover [Keycloak 26](https://www.keycloak.org/documentation) passkeys configuration, a gradual rollout strategy, and the account recovery and attestation policy problems every enterprise inevitably runs into.

WebAuthn/FIDO2 Architecture at a Glance

FIDO2 consists of two specifications:

- **WebAuthn (W3C)**: Defines the JavaScript API and data structures that browsers/platforms expose to web applications. The current version is [WebAuthn Level 3](https://www.w3.org/TR/webauthn-3/).

- **CTAP2 (FIDO Alliance)**: The communication protocol between the client (browser/OS) and external authenticators (security keys, smartphones).

The relationship between all the components looks like this:

+------------------+ +---------------------+ +------------------+

| Relying Party | | Client/Platform | | Authenticator |

| (web server, | <----> | (browser + OS) | <----> | (Touch ID, |

| Keycloak, ...) | HTTPS | | CTAP2 | YubiKey, |

| | | WebAuthn API | or | smartphone) |

| issues | | navigator. | built | |

| challenge, | | credentials.* | -in | holds the |

| verifies sigs | | | API | private key |

+------------------+ +---------------------+ +------------------+

The core idea is simple: **challenge-response authentication based on asymmetric key pairs**.

- During registration, the authenticator generates a key pair and only the public key is sent to the server (the Relying Party, RP).

- The private key never leaves the authenticator (Secure Enclave, TPM, or the secure element of a hardware key).

- At login, the server sends a random challenge; the authenticator signs it with the private key, and the server verifies with the public key.

Since the server stores only public keys, even a full database breach gives attackers nothing but "verification-only public keys." The threat model is fundamentally different from leaked password hashes.

The Registration Ceremony in Detail

The WebAuthn specification calls the enrollment process a ceremony, because the procedure is rigorously defined.

User Browser RP Server Authenticator

| | | |

| start enroll | | |

|--------------->| POST /webauthn/begin | |

| |----------------------->| |

| | PublicKeyCredential | |

| | CreationOptions | |

| | (challenge, rp.id, | |

| | user.id, pubKey | |

| | CredParams ...) | |

| |<-----------------------| |

| | navigator.credentials.create() |

| |-------------------------------------------->|

| biometric/PIN | | |

|<--------------------------------------------------------------|

| user consent | | |

|--------------------------------------------------------------->|

| | attestationObject + clientDataJSON |

| |<--------------------------------------------|

| | POST /webauthn/finish | |

| |----------------------->| |

| | verify, store pubkey | |

The actual shape of the options object the server sends down:

{

"challenge": "Y2hhbGxlbmdlLXJhbmRvbS0zMmJ5dGVz",

"rp": {

"id": "example.com",

"name": "Example Corp"

},

"user": {

"id": "dXNlci1pZC1vcGFxdWU",

"name": "jdoe@example.com",

"displayName": "Jane Doe"

},

"pubKeyCredParams": [

{ "type": "public-key", "alg": -7 },

{ "type": "public-key", "alg": -257 }

],

"authenticatorSelection": {

"residentKey": "required",

"userVerification": "required",

"authenticatorAttachment": "platform"

},

"attestation": "none",

"timeout": 60000,

"excludeCredentials": []

}

Let us walk through each field:

- **challenge**: A cryptographic random value of at least 16 bytes generated by the server. It is the core defense against replay attacks, and you must persist it in the server session and compare it during response verification.

- **rp.id**: The Relying Party identifier. It determines which domain the registered credential is bound to. It is the foundation of phishing resistance, covered in detail below.

- **user.id**: An opaque byte sequence identifying the user. Do not put PII like an email address in it — a random UUID is recommended. Once chosen, it is hard to change.

- **pubKeyCredParams**: The list of allowed algorithms, expressed as COSE algorithm identifiers. -7 is ES256 (ECDSA P-256), -257 is RS256. Putting ES256 first is standard practice.

- **residentKey**: Whether a discoverable credential (formerly resident key) is required. **If you want the passkey experience (login without typing a username), set this to required.**

- **userVerification**: Whether to require user verification (biometric/PIN). For single-factor passwordless login, required is mandatory.

- **excludeCredentials**: The list of already-registered credential IDs, preventing duplicate registration of the same authenticator.

Attestation — Proving Where the Authenticator Came From

Attestation is the mechanism that cryptographically proves "this public key was really generated by an authenticator of a specific make/model." The attestation statement arrives inside the attestationObject of the registration response.

There are four attestation conveyance levels:

| Value | Meaning | Recommended scenario |

| --- | --- | --- |

| none | No attestation needed (default) | Consumer services, general internal apps |

| indirect | Anonymized attestation allowed | Rarely used |

| direct | Manufacturer attestation required verbatim | Regulated industries, authenticator model control |

| enterprise | Individually identifying (pre-agreed environments) | Managed-device-only deployments |

One important reality: **synced passkeys (iCloud Keychain, Google Password Manager) generally do not provide attestation.** Forcing direct attestation effectively blocks platform passkeys, so factor this into policy design.

When verifying attestation, the server can compare the certificate chain in the attestation statement against metadata from the [FIDO Metadata Service (MDS)](https://fidoalliance.org/metadata/) to confirm the AAGUID (Authenticator Attestation GUID — the authenticator model identifier) and the security certification level (FIDO L1/L2, etc.).

The Authentication Ceremony in Detail

The login procedure mirrors registration.

User Browser RP Server Authenticator

| | | |

| start login | POST /webauthn/login/begin |

|--------------->|----------------------->| |

| | PublicKeyCredential | |

| | RequestOptions | |

| | (challenge, rpId, | |

| | allowCredentials) | |

| |<-----------------------| |

| | navigator.credentials.get() |

| |-------------------------------------------->|

| biometric/PIN | | |

|<------------------------------------------------------------|

| | authenticatorData + signature |

| | + clientDataJSON |

| |<--------------------------------------------|

| | POST /webauthn/login/finish |

| |----------------------->| |

| | verify sig → session | |

The items the server absolutely must check during verification:

1. **Challenge match**: The challenge inside clientDataJSON equals the value the server issued.

2. **Origin match**: The origin inside clientDataJSON equals the expected origin (e.g., the HTTPS origin of the production domain).

3. **rpIdHash match**: The first 32 bytes of authenticatorData equal the SHA-256 hash of rp.id.

4. **UP/UV flags**: The User Present and User Verified flags satisfy your policy.

5. **Signature verification**: The signature is valid under the public key stored at registration.

6. **signCount**: The signature counter increased compared to the previous value (clone detection). Note that synced passkeys often always report 0, so treat a non-increasing counter as "log an anomaly signal" rather than "block," which is the realistic posture.

Synced Passkey vs Device-bound — What Is the Difference

The term "passkey" loosely refers to discoverable WebAuthn credentials in general, but in practice the security characteristics diverge sharply based on whether the credential is synced.

| Aspect | Synced Passkey | Device-bound Credential |

| --- | --- | --- |

| Storage | Cloud keychain (iCloud, Google PM, 1Password, ...) | Secure hardware of a single device |

| Device lost | Recoverable from another device | Credential permanently lost |

| Attestation | Generally none | Manufacturer attestation possible |

| Cloneability | Depends on cloud account security | Hardware-impossible |

| Typical examples | iPhone/Android passkeys | YubiKey, TPM keys on managed devices |

| Best fit | General workforce, consumers | Privileged accounts, regulated workloads |

The other axis is the form factor of the authenticator:

| Aspect | Platform Authenticator | Roaming Authenticator |

| --- | --- | --- |

| Form | Built into the device (Touch ID, Windows Hello, Android) | External (USB/NFC/BLE security key) |

| CTAP transport | Internal OS API | CTAP2 over USB/NFC/BLE |

| Cross-device | Possible via hybrid transport (QR + BLE proximity) | Just plug the key in |

| Cost | Free (included with the device) | Key purchase cost |

The typical enterprise policy combination:

- **General employees**: Allow synced passkeys (prioritize UX and ease of recovery).

- **Admins/privileged accounts**: Only device-bound security keys or platform authenticators on managed devices (enforced via attestation + AAGUID policy).

Why Passkeys Resist Phishing — Origin Binding

The decisive difference between passkeys and OTP or push MFA is **origin binding**.

Picture the OTP phishing scenario. The attacker builds a pixel-perfect fake site and relays the password and OTP the victim types into the real site in real time. The user has no way to tell "which site this 6-digit code is for," so they get fooled.

In WebAuthn, this relay is blocked at the protocol level:

1. The credential is **bound to rp.id (the domain)** at registration. If evil-example.com requests a credential for example.com, the browser rejects it during rp.id validation. On the fake site, no signature is ever produced in the first place.

2. The signed payload **includes clientDataJSON**, which contains the origin recorded directly by the browser. Even if a signature were somehow obtained, the forged origin is exposed the moment the server validates the origin field.

3. The challenge is part of the signature, so replay is impossible too.

In other words, phishing is prevented not by "users being careful" but **mathematically, by the browser and the protocol**. This is the definition of phishing-resistant MFA, and the reason hardware-based phishing-resistant authenticators come up in the AAL3 discussion of NIST SP 800-63B.

Keycloak 26 Passkeys Integration

[Keycloak](https://www.keycloak.org/) has supported WebAuthn for a long time, but with 26.x the passkey experience became a first-class citizen. Passkeys were integrated into the login form starting with 26.4, and 26.6 (latest as of 2026: 26.6.2) ships a smooth conditional-UI-based flow out of the box.

Two UI Modes

- **Conditional UI (autofill)**: When the username field gains focus, the browser suggests passkeys registered for the site in the autofill dropdown. The user does not type a username or password — they just pick from the list. This uses the WebAuthn conditional mediation feature.

- **Modal UI**: An explicit flow where pressing a "Sign in with a passkey" button opens a modal dialog for authenticator selection.

Realm Configuration

In the Admin Console, the relevant paths are:

1. **Authentication → Policies → WebAuthn Passwordless Policy** for the passwordless policy. (The plain WebAuthn Policy is for second-factor use; the Passwordless Policy is the one for passkeys.)

2. **Realm Settings → Login** to enable the passkeys-related options.

Configuring the policy with the kcadm CLI:

Configure the WebAuthn Passwordless policy

/opt/keycloak/bin/kcadm.sh update realms/myrealm \

-s 'webAuthnPolicyPasswordlessRpEntityName=Example Corp' \

-s 'webAuthnPolicyPasswordlessRpId=example.com' \

-s 'webAuthnPolicyPasswordlessRequireResidentKey=Yes' \

-s 'webAuthnPolicyPasswordlessUserVerificationRequirement=required' \

-s 'webAuthnPolicyPasswordlessAttestationConveyancePreference=none' \

-s 'webAuthnPolicyPasswordlessSignatureAlgorithms=["ES256","RS256"]' \

-s 'webAuthnPolicyPasswordlessAuthenticatorAttachment=not specified' \

-s 'webAuthnPolicyPasswordlessCreateTimeout=60'

Three key points:

- **RequireResidentKey = Yes**: Discoverable credentials must be enforced for credentials to be discoverable by the conditional UI.

- **UserVerificationRequirement = required**: This is single-factor passwordless, so user verification is non-negotiable.

- **RpId**: Setting it to the apex domain (example.com) lets all subdomains (sso.example.com, app.example.com) share credentials. But once chosen, changing it invalidates every existing credential — decide carefully.

Authentication Flow Layout

To build a passkey-first login flow, duplicate the browser flow and structure it like this:

Browser Flow (copy: browser-passkeys)

├── Cookie [Alternative]

├── Identity Provider Redirector [Alternative]

└── Passkeys Forms (subflow) [Alternative]

├── Username/WebAuthn Form [Required]

│ └─ conditional UI suggests passkeys via autofill

└── Password + OTP (subflow) [Conditional - fallback]

├── Password Form [Required]

└── OTP Form [Conditional]

In 26.6 the built-in passkeys-integrated login form gives you a similar experience without custom work. See the [Keycloak release notes](https://www.keycloak.org/docs/latest/release_notes/index.html) for details.

User Enrollment Flow

To get existing users to register a passkey, use a Required Action:

Assign the passkey registration required action to a user

/opt/keycloak/bin/kcadm.sh update users/USER-ID -r myrealm \

-s 'requiredActions=["webauthn-register-passwordless"]'

Alternatively, users can self-enroll via the Account Console under **Signing in → Passkeys**.

Frontend Code Examples — If You Implement Your Own RP

With Keycloak you will rarely write the code below yourself, but you need it to implement your own RP or to understand the mechanics.

Registration

async function registerPasskey() {

// 1. Fetch options from the server

const resp = await fetch('/webauthn/register/begin', { method: 'POST' });

const options = await resp.json();

// 2. Convert base64url → ArrayBuffer

options.challenge = base64urlToBuffer(options.challenge);

options.user.id = base64urlToBuffer(options.user.id);

options.excludeCredentials = (options.excludeCredentials || []).map((c) => ({

...c,

id: base64urlToBuffer(c.id),

}));

// 3. Invoke the authenticator — the OS biometric UI appears

const credential = await navigator.credentials.create({ publicKey: options });

// 4. Send the result to the server

await fetch('/webauthn/register/finish', {

method: 'POST',

headers: { 'Content-Type': 'application/json' },

body: JSON.stringify({

id: credential.id,

rawId: bufferToBase64url(credential.rawId),

type: credential.type,

response: {

clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),

attestationObject: bufferToBase64url(credential.response.attestationObject),

},

}),

});

}

Conditional UI Login

async function conditionalLogin() {

// Check whether the browser supports conditional mediation

if (

!window.PublicKeyCredential ||

!(await PublicKeyCredential.isConditionalMediationAvailable())

) {

return; // fallback: regular form login

}

const resp = await fetch('/webauthn/login/begin', { method: 'POST' });

const options = await resp.json();

options.challenge = base64urlToBuffer(options.challenge);

// mediation: 'conditional' — suggest passkeys via autofill, not a modal

const assertion = await navigator.credentials.get({

publicKey: options,

mediation: 'conditional',

});

await fetch('/webauthn/login/finish', {

method: 'POST',

headers: { 'Content-Type': 'application/json' },

body: JSON.stringify({

id: assertion.id,

rawId: bufferToBase64url(assertion.rawId),

response: {

clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),

authenticatorData: bufferToBase64url(assertion.response.authenticatorData),

signature: bufferToBase64url(assertion.response.signature),

userHandle: assertion.response.userHandle

? bufferToBase64url(assertion.response.userHandle)

: null,

},

}),

});

}

Conditional UI requires an autocomplete hint on the HTML input:

Gradual Rollout Strategy — From MFA to Passwordless

A big-bang migration will fail, every time. The recommended phased strategy:

Phase 0 Phase 1 Phase 2 Phase 3

Pilot Second factor passkey-first passwordless

───────── ───────────── ────────────── ─────────────

IT/security password + conditional UI remove or

team and passkey (2FA), offers passkey disable

volunteers start replacing first, password passwords;

OTP as fallback fallback is

recovery only

- **Phase 0 (pilot, 2 to 4 weeks)**: Start with the IT/security team and volunteers. The goal is to find where things break across the browser/OS matrix (old Windows builds, virtual desktops, kiosk environments).

- **Phase 1 (coexistence as a second factor)**: Introduce passkeys as the second factor replacing OTP. Give users time to get comfortable while driving up the enrollment rate. The key metric in this phase is **passkey enrollment rate among active users**.

- **Phase 2 (passkey-first)**: Make passkeys the default path on the login screen, with the password as fallback. Keycloak conditional UI shines in this phase.

- **Phase 3 (passwordless)**: Once enrollment is high enough (empirically, 90 percent or more), disable password authentication. At this point the robustness of your recovery flow determines your overall security level.

Metrics to monitor at each phase: enrollment rate, passkey login success rate, fallback usage rate, helpdesk ticket trend.

Account Recovery — The Achilles Heel of Passwordless

Remove passwords and "I forgot my password" disappears — replaced by "I lost my device." If the recovery path is phishable, the security of the whole system degrades to the level of the recovery path. Attackers always go for the weakest link.

Recommended principles:

1. **Multiple credentials by default**: Make it policy to register at least two credentials at onboarding (e.g., laptop platform authenticator + smartphone, or passkey + backup security key). A synced passkey is itself a recovery mechanism.

2. **Recovery must be phishing-resistant too**: Allowing recovery via email magic links or SMS collapses the entire phishing-resistance story. In a corporate environment, in-person or video identity verification at the helpdesk plus a temporary enrollment token is the standard play.

3. **Stricter for high-value accounts**: Recommend four-eyes (two-person) approval for admin account recovery.

4. **Tie into offboarding**: Connect immediate credential revocation on departure/device return to your IGA (Identity Governance) processes.

In Keycloak, recovery scenarios can be automated via the API: delete the lost credential, then assign a re-enrollment required action.

Delete the credential of the lost device

/opt/keycloak/bin/kcadm.sh delete \

users/USER-ID/credentials/CREDENTIAL-ID -r myrealm

Assign the re-enrollment required action

/opt/keycloak/bin/kcadm.sh update users/USER-ID -r myrealm \

-s 'requiredActions=["webauthn-register-passwordless"]'

Enterprise Policy — Attestation Verification and AAGUID Allowlists

For regulated industries or privileged accounts, "any authenticator is fine" does not fly. This is where attestation verification and AAGUID filtering come in.

The AAGUID is a 128-bit identifier per authenticator model. You can distinguish a specific hardware key product line or a specific platform passkey provider by its AAGUID. Pull per-AAGUID metadata (certification level, known vulnerabilities) from the FIDO MDS and integrate it into your verification pipeline.

An example policy design:

| User group | attestation | Allowed authenticators | Notes |

| --- | --- | --- | --- |

| General employees | none | All passkeys | UX first |

| Developers (prod access) | direct | Company-issued keys + managed-device platform | AAGUID allowlist |

| Infrastructure admins | direct | FIDO L2 certified hardware keys only | MDS metadata verification |

In the Keycloak WebAuthn Passwordless Policy, register the allowlist under **Acceptable AAGUIDs**, and registration of any authenticator outside the list is rejected. Caveat: with attestation conveyance set to none, the AAGUID may arrive zeroed out, neutering the filter. To use AAGUID policy, raise attestation to direct alongside it.

Common Rollout Mistakes (Anti-patterns)

1. **Starting with a narrow rp.id**: If you start registration on sso.example.com and later want to expand to all of example.com, every credential must be re-registered. Evaluate the widest registrable effective domain from day one.

2. **Running passwordless with userVerification set to discouraged**: Allowing single-factor login with only UP (presence) means "whoever picks up the device" can log in. Passwordless requires required.

3. **Forcing direct attestation on synced passkeys**: Every platform passkey registration fails and the rollout itself capsizes. Split policy by user group.

4. **Hard-blocking on signCount mismatch**: Many synced passkey implementations always send a counter of 0. Log it as a risk signal and combine with other signals instead of blocking.

5. **Leaving recovery open via SMS/email**: Deploying phishing-resistant MFA while leaving recovery on phishable channels is locking the door and leaving the window open.

6. **Not setting excludeCredentials**: Users end up registering the same authenticator repeatedly, cluttering their credential list and creating confusion.

7. **Reusing challenges or skipping verification**: Skipping the comparison to go "stateless" leaves you defenseless against replay. Always compare against server-side state (session/cache).

8. **Ignoring iframe/cross-origin contexts**: WebAuthn calls can be blocked in embedded login widgets. Check your Permissions Policy (publickey-credentials-get).

9. **Entering Phase 3 without enrollment metrics**: Turning off passwords at 60 percent enrollment paralyzes the helpdesk. Decide the cutover point with data.

Closing Thoughts

Technically, a passkey rollout is "integrating the WebAuthn API" — in reality, it is a **policy design and change management project**. The protocol itself (origin binding, challenge-response, attestation) is fully mature, and in 2026, with open-source IdPs like Keycloak 26 shipping conditional UI out of the box, the technical barriers have all but disappeared.

The remaining work belongs to the organization: which authenticators to allow for which user groups, how to design a phishing-resistant recovery procedure, and how to drive enrollment. I hope the phased strategy and anti-pattern list in this post serve as a map for that journey.

In the next post, we move to the step after authentication: the evolution of authorization models — RBAC, ABAC, ReBAC, and OpenFGA.

References

- [W3C Web Authentication Level 3](https://www.w3.org/TR/webauthn-3/) — the official WebAuthn specification

- [FIDO Alliance — Passkeys](https://fidoalliance.org/passkeys/) — passkey overview and adoption resources

- [FIDO Alliance Metadata Service](https://fidoalliance.org/metadata/) — AAGUID metadata

- [Keycloak Documentation](https://www.keycloak.org/documentation) — official documentation

- [Keycloak Release Notes](https://www.keycloak.org/docs/latest/release_notes/index.html) — 26.x passkeys feature changes

- [Keycloak Server Administration Guide — WebAuthn](https://www.keycloak.org/docs/latest/server_admin/index.html) — WebAuthn policy configuration

- [NIST SP 800-63B — Digital Identity Guidelines](https://pages.nist.gov/800-63-3/sp800-63b.html) — AALs and authenticator requirements

- [OMB M-22-09 — Federal Zero Trust Strategy](https://www.whitehouse.gov/wp-content/uploads/2022/01/M-22-09.pdf) — the phishing-resistant MFA mandate

- [CTAP 2.1 — Client to Authenticator Protocol](https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-20210615.html) — the CTAP specification

- [passkeys.dev](https://passkeys.dev/) — developer implementation guides

현재 단락 (1/297)

As of 2026, the password has officially become a "legacy authentication mechanism." According to the...

작성 글자: 0원문 글자: 22,035작성 단락: 0/297