> For the complete documentation index, see [llms.txt](https://docs.openiam.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.openiam.com/administration-guide/security/fido2-and-webauthn.md).

# FIDO2 and WebAuthn

Roll out FIDO2 security keys and platform authenticators, attach WEB\_AUTH to a rule for passwordless or second-factor sign-in, and recover a user who lost their authenticator.

## What this is, and when you need it

A one-time password proves the user holds a shared secret. A FIDO2 assertion proves the user holds a private key that **the browser will only use on the site it was registered against**. That difference is the whole point: a TOTP code, an SMS code and a push approval can all be relayed to an attacker's proxy by a user who believes they are on your sign-in page, and a FIDO2 credential cannot, because the browser refuses to sign for the wrong origin.

That property is what the market means by **phishing-resistant** authentication, and it is the reason FIDO2 is worth the enrolment effort that TOTP is not.

OpenIAM ships it as the **Web authentication (FIDO)** authentication level, `WEB_AUTH`. Users register authenticators for themselves in SelfService; you attach `WEB_AUTH` to an authentication rule the same way you attach any other factor. There is no server to install, no library to enable and no licence flag — the feature arrives whole in **4.2.1.0**.

`WEB_AUTH` can be a *second* factor after a password, or the **only** factor — the shipped `DEF_WEB_AUTH_RULE` is the passwordless version and this page covers both. Which one you get is decided entirely by the rule you attach; see [Passwordless sign-in](/administration-guide/security/authentication-model.md#passwordless-sign-in).

## At a glance

|                          |                                                                                                                                                                                                                  |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **You will end up with** | A test identity holding two registered authenticators, required to present one after their password on a single protected URL, with a `LOGIN` audit record carrying **Authentication type** `WEB AUTHENTICATION` |
| **Where**                | **Self Service Center → FIDO Authenticators** (`/selfservice/security-keys`) for registration, **Policy → Authentication Rule** (`/webconsole/authentication-rule-list`) to demand it                            |
| **You need first**       | HTTPS, the single hostname users will reach OpenIAM on decided in advance, and a tested break-glass administrator on a route carrying only `DEF_PASSWD_AUTH_RULE`                                                |
| **Takes**                | An hour for one rule and one test identity. Enrolling a population is a separate exercise and is the part that determines whether the rollout succeeds                                                           |
| **Reversible**           | Partly — detaching the rule restores the previous sign-in within a sweep, but a credential cannot be moved, re-issued or recovered, only deleted and registered again                                            |

{% hint style="danger" %}
Attaching `WEB_AUTH` to a content provider is in the blast radius [Change configuration safely](/administration-guide/start/change-safely.md) calls unrecoverable. A user with no registered authenticator who reaches a `WEB_AUTH` step cannot complete it and cannot register one, because registration lives behind the sign-in they are trying to complete. Do [Keep a working way in](/administration-guide/security/authentication-providers-and-mfa.md#keep-a-working-way-in) first, and never make `WEB_AUTH` the only satisfiable path of a rule that protects the Webconsole.
{% endhint %}

## Before you begin

Two prerequisites need more than a line.

**HTTPS is not optional and `localhost` is the exception that misleads you.** Browsers only expose the WebAuthn API in a secure context, so the SelfService page refuses before it starts on plain HTTP, with *This feature is only available in secure contexts (HTTPS)* — or, in the newer SelfService shell, *Registering a security key requires a secure connection (HTTPS)*. Registration therefore works on a developer's laptop and fails on the first shared environment.

**Decide the hostname before anybody registers.** OpenIAM does not store a configured relying-party ID; it derives one per request from the host of the request that arrives at the IdP. Renaming the sign-in host after a rollout invalidates every registered credential in the estate, with no re-binding and no migration. See [Relying party, origin, and what breaks across domains](#relying-party-origin-and-what-breaks-across-domains) before step 1.

## Roll out FIDO2

{% stepper %}
{% step %}

### Confirm the relying-party ID is the hostname you intend

Sign in to SelfService as a test identity over HTTPS, open **Self Service Center → FIDO Authenticators** (`/selfservice/security-keys`), and begin a registration. The browser's own WebAuthn prompt names the site it is registering against — **that name is the relying-party ID OpenIAM sent**. The registration request in the browser's network trace carries both the relying-party ID and the origin.

If either is not the hostname your users type, stop here and fix the proxy. A proxy that rewrites `Host` to an internal name makes registration appear to succeed and authentication fail for everyone, in a way that looks like a broken credential rather than a broken header.
{% endstep %}

{% step %}

### Confirm the feature is present for the user

**Authenticators** must appear in the SelfService menu. If it is absent, the `SS_FIDO_KEYS` menu resource is either not there or not visible to that user's role — nothing else about FIDO needs switching on.
{% endstep %}

{% step %}

### Register the first authenticator

The older SelfService shell registers immediately and names the credential `Authenticator_1`, `Authenticator_2` and so on — the user renames it afterwards. The newer shell asks for the name first, on a **Register security key** dialog with the same default pre-filled and an *e.g. MacBook Touch ID* placeholder, then runs the ceremony. Either way the browser takes over, and the user has 60 seconds to touch the key or approve with Touch ID or Windows Hello.

Tell users to name the authenticator after the object, not the date: *YubiKey on keyring*, *Work laptop Touch ID*. That name is the only thing you and they will have to go on when one of them has to be revoked.

The credential appears in the list; the older shell reports *Authenticator saved successfully* and the newer one *Security key registered*.
{% endstep %}

{% step %}

### Register the second authenticator, in the same sitting

This is the step people skip and the reason rollouts generate help-desk tickets. A user who returns later to add a backup key needs to complete sign-in first, which is exactly what they cannot do once the only credential is gone.

At least one of the two should be a **roaming** authenticator — a USB or NFC key, or a phone over the browser's cross-device flow — if you want the factor to survive a device rebuild. See [Platform and roaming authenticators](#platform-and-roaming-authenticators).
{% endstep %}

{% step %}

### Prove nobody in scope is on a single credential

Run the adoption query under [What the administrator sees afterwards](#what-the-administrator-sees-afterwards) **before** you attach the rule. Every identity it returns is one lost key away from a lockout, and there is no Webconsole screen that answers this.
{% endstep %}

{% step %}

### Attach `WEB_AUTH` to one narrow URI pattern

**Decide first whether you are configuring passwordless sign-in or a second factor.** `DEF_WEB_AUTH_RULE` ships as `WEB_AUTH` → `AUTHORIZATION_ACCEPT`, which is FIDO *instead of* a password: the user types their login on `/idp/login`, goes straight to `/idp/auth-web`, and is never shown a password page. That is a working passwordless sign-in with no further configuration — but because user verification is not enforced it is a possession-only, single-factor sign-in, however phishing-resistant. For two factors, build `PASSWORD_AUTH` → `WEB_AUTH` → `AUTHORIZATION_ACCEPT` instead; chained steps mean *and*.

Either way the identity comes from the login collected on `/idp/login`, which `/idp/auth-web` reads from the request and uses to decide which credentials to allow. Reaching `/idp/auth-web` any other way — a bookmark, a hand-typed URL — leaves that empty and the page cannot proceed.

Attach the rule to a single test URI pattern at `/webconsole/editProviderPattern`, never to `DEFAULT_CP`. Take care not to put a pattern that demands a session in front of the two IdP paths the browser uses to *complete* a FIDO sign-in: those are deliberately reachable without one, because they are how a user authenticates, and a pattern that demands a session over them makes the factor unusable. The registration and management paths do require a session and should keep it.
{% endstep %}

{% step %}

### Wait a sweep, then sign in

`org.openiam.auth.provider.sweep.time` is `60000` ms. Expect the password page, then the browser's own security-key prompt at `/idp/auth-web`, then your destination — roughly two seconds of friction.
{% endstep %}
{% endstepper %}

## Verification

| What to check                                   | How                                                                                                                                                                                          |
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The relying-party ID is the hostname you intend | The browser's WebAuthn prompt names it during registration; the registration request in the network trace carries it and the origin                                                          |
| A credential was stored                         | A `FIDO_AUTHENTICATOR` row for the user. There is no audit record and no console screen — this is the only evidence                                                                          |
| Nobody is on a single credential                | The `HAVING COUNT(*) = 1` query below returns no identity you care about                                                                                                                     |
| The rule actually routes through FIDO           | A `LOGIN` audit record carrying the attribute **Authentication type** with the value `WEB AUTHENTICATION` for the test identity. That is how you tell a FIDO sign-in from a password sign-in |
| Failures are being counted                      | `INCREMENT_FAIL_AUTH_COUNT` for the identity after a deliberately cancelled assertion                                                                                                        |
| The rule is attached where you intended         | A row in `URI_RULE_XREF` for your rule, at the `EXEC_ORDER` you set; and `/webconsole/authenticationDebug` for how a given request was evaluated                                             |
| Only one FIDO property exists                   | Filter the `Authentication` category of **Administration → System Configuration** on `fido` — exactly one key, `org.openiam.idm.fido.auth.rp.name`                                           |

## When it goes wrong

| Symptom                                                                                           | Cause                                                                                                                                              | Fix                                                                                                                                                  |
| ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| *This feature is only available in secure contexts (HTTPS)* on the Authenticators page            | The page was reached over plain HTTP and the browser will not expose the WebAuthn API                                                              | Reach OpenIAM over HTTPS. `localhost` is the only exception, which is why it worked in development                                                   |
| *Registering a security key requires a secure connection (HTTPS)*                                 | The same cause, newer SelfService shell                                                                                                            | The same fix                                                                                                                                         |
| Credential registers, then will not authenticate                                                  | The relying-party ID is the request host and the expected origin is the request URL — a different hostname is a different relying party            | Reach OpenIAM on one hostname. Check the content provider's `DOMAIN_PATTERN` and that the proxy is not rewriting `Host`                              |
| Registration succeeds for everyone; authentication fails for everyone, right after a proxy change | The proxy now presents an internal hostname, so credentials were bound to a name the browser never sees                                            | A deployment fix — [Installation Guide](/installation/installation.md). Credentials already registered under the wrong name must be registered again |
| The browser refuses to register, saying the authenticator is already registered                   | The user's existing credentials are sent as excluded credentials, and this is the same authenticator                                               | Expected. Use a different authenticator, or delete the existing entry first                                                                          |
| *Data validation failed* on sign-in                                                               | The assertion did not validate against the expected origin, relying party or stored key                                                            | Confirm the hostname first; then confirm the credential row still exists for that user                                                               |
| *Invalid credential ID* when deleting                                                             | The credential identifier did not resolve                                                                                                          | Re-read the list and retry with the identifier it returns                                                                                            |
| User is stuck at `/idp/auth-web` with an empty, disabled username field                           | The page was reached without the login the sign-in page normally supplies — a bookmark, a hand-typed URL, or a redirect that dropped the parameter | Start at `/idp/login` instead. A bare `WEB_AUTH` root is fine; it is the entry point that is wrong                                                   |
| User has no authenticator and cannot register one                                                 | Registration lives behind the sign-in the user cannot complete                                                                                     | Restore another path in the rule — see [Roll it back](#roll-it-back). This is a design error, not a runtime fault                                    |
| Setting `org.openiam.idm.fido.auth.use.first.login` or `...rp.icon` has no effect                 | Both were removed in 4.2.2.0 and do not exist in a migrated database                                                                               | Only `org.openiam.idm.fido.auth.rp.name` is live. Whether FIDO is offered is decided by the rule                                                     |
| Nothing in the audit log when a user registers a key                                              | Registration and deletion are not audited                                                                                                          | Expected. The `FIDO_AUTHENTICATOR` row is the only evidence; do not promise an audit trail for enrolment                                             |
| An account locks after failed FIDO attempts                                                       | Failed assertions increment the failure count for the `WEB_AUTH` method like any other factor                                                      | Unlock as usual; check for a wrong hostname, which produces repeated failures that look like user error                                              |
| Changed the rule, behaviour unchanged                                                             | Provider and rule cache sweep is 60 s                                                                                                              | Wait a sweep, then re-test                                                                                                                           |

## Recover a lost, replaced or compromised authenticator

There is no reset and no re-issue. A FIDO credential cannot be moved to a new authenticator, and OpenIAM cannot recover one. The only operation is **delete and register again**, and everything below is about getting the user to a point where they can register again.

**A replaced laptop is the same incident.** A platform credential does not survive a rebuild, a re-imaging, or a move to a new machine, and the user will describe it as "it stopped working" rather than "I lost my key".

{% stepper %}
{% step %}

### Establish whether they have a second authenticator

Ask, or read the list — the SelfService page for them, the break-glass query below for you. A user with a second registered authenticator has no incident: they sign in with it and delete the lost one themselves on **Authenticators**. Stop here.
{% endstep %}

{% step %}

### Get them signed in by another route

A user with no remaining authenticator cannot reach the SelfService page that would let them register a new one. What saves them is that the rule offers another path — a sibling root such as `PASSWORD_AUTH`, or a branch that demands a different second factor. If the rule protecting their route has `WEB_AUTH` as its only satisfiable path, there is no user-side recovery and you are into rule surgery: see [Roll it back](#roll-it-back).

Decide the recovery path when you design the rule, not when the first key goes missing.
{% endstep %}

{% step %}

### Delete the lost credential

Once signed in, the user deletes it themselves on **Authenticators**; the newer shell warns *Are you sure you want to delete "…"? This action cannot be undone.* If they cannot sign in at all and you need the row gone — a stolen key, a machine handed to someone else — the deletion is break-glass, and the supported alternative is to restore a sign-in path first and let the user do it.

{% hint style="danger" %}
**Break-glass: removing a credential the user cannot remove.** This write bypasses the Webconsole, so it produces **no audit record**, validates nothing, and is invisible to anyone reading the audit trail afterwards. Note it in your own change record.

{% code overflow="wrap" %}

```sql
-- Identify first. Never delete by name alone; names are user-supplied and not unique.
SELECT f.ID, f.NAME, f.DESCRIPTION FROM FIDO_AUTHENTICATOR f
 JOIN LOGIN l ON l.USER_ID = f.USER_ID
 WHERE l.LOGIN = '<PRINCIPAL>' AND l.MANAGED_SYS_ID = '0';

DELETE FROM FIDO_AUTHENTICATOR_TRANSPORT WHERE AUTHENTICATOR_ID = '<ID FROM ABOVE>';
DELETE FROM FIDO_AUTHENTICATOR WHERE ID = '<ID FROM ABOVE>';
```

{% endcode %}

Deleting the last credential for a user who is required to present `WEB_AUTH` locks that user out completely. Confirm they have another path before you run it.
{% endhint %}
{% endstep %}

{% step %}

### Register the replacement, and a backup

The same procedure as any first registration. Register two.
{% endstep %}
{% endstepper %}

## Roll it back

Detach the rule. Do not edit the graph while people are locked out, and do not delete the rule — you will want it for diagnosis.

Delete the row that attaches your rule on the content provider or URI pattern, leaving `DEF_PASSWD_AUTH_RULE` at `EXEC_ORDER` 0 alone. Wait 60 seconds for `org.openiam.auth.provider.sweep.time`; retrying sooner tells you nothing. Then take two proofs, not one: sign in as the affected test identity in a fresh private session and confirm you are never sent to `/idp/auth-web`, and open the content provider's **Authentication rules** list and confirm your rule is no longer on it.

If the rule is protecting the Webconsole itself and nobody can sign in to detach it, the recovery is the break-glass procedure on [Adaptive authentication](/administration-guide/security/adaptive-authentication.md#roll-it-back-and-prove-a-rule-is-detached) — the attachment rows are the same two tables whichever factor the rule demands.

## Relying party, origin, and what breaks across domains

This is where deployments fail, and there is no setting to soften it.

OpenIAM derives the relying-party ID, per request, from the **host of the request that arrives at the IdP**, and it checks the assertion against the **full URL of that request** as the expected origin. A credential registered while the user was on `sso.example.com` will not verify when the same user arrives on `login.example.com`, on `sso.example.net`, or on an internal hostname that bypasses the proxy — the browser will not even offer it, because the browser applies the same rule.

What follows, in the order it tends to bite:

1. **Decide the hostname before anyone registers.** Renaming the sign-in host after a rollout invalidates every registered credential in the estate. Every user re-registers.
2. **The proxy must pass the external host through.** If your reverse proxy rewrites `Host` to an internal name, the relying-party ID becomes that internal name.
3. **One hostname, not several.** If two hostnames both reach OpenIAM, users who registered on one and arrive on the other are locked out of that factor. Constrain what reaches OpenIAM with the content provider's `DOMAIN_PATTERN` and with the proxy.
4. **Subdomains do not federate.** OpenIAM sends the host itself, not a registrable parent domain, so a credential registered at `sso.example.com` is not usable at `apps.example.com`.

## Platform and roaming authenticators

This distinction matters more than any setting on this page, because it decides what happens when a laptop is replaced.

A **roaming** authenticator is separable from the machine — a USB or NFC security key, or a phone used over the browser's cross-device flow. It moves with the user. Replace their laptop and the credential still works.

A **platform** authenticator is tied to the machine and its operating-system account — Touch ID, Windows Hello. It is faster and cheaper, because the user already owns it, and it is **per device**: a user with a platform credential on their laptop cannot sign in from their desktop, cannot sign in from their phone, and loses the credential outright when the machine is rebuilt.

OpenIAM does not choose for you and cannot enforce a choice: the registration page asks the browser for a credential without restricting the authenticator type, so the browser's own prompt decides what the user sees, and both kinds are stored identically. You enforce it by what you hand out. If your population is on Touch ID or Windows Hello, expect a re-registration at your hardware refresh rate and make sure the second authenticator is a roaming one.

## What the administrator sees afterwards

Less than you would expect, so plan for it.

**There is no Webconsole screen listing a user's authenticators**, and nothing reports how many users have registered one. **Registration is not audited** either: unlike TOTP enrolment, which writes `GENERATE_TOTP_SECRET`, registering or deleting a FIDO credential leaves no audit record at all. The `FIDO_AUTHENTICATOR` row is the only evidence that a credential exists, and its absence is the only evidence that one was removed. Say so in your control documentation rather than claiming an audit trail you do not have. **Successful use is audited**, as a `LOGIN` record carrying **Authentication type** = `WEB AUTHENTICATION`.

**And there is a product weakness to raise while you are here.** The IdP's own way of reading one identity's registered credentials is gated on holding *any* session rather than on holding an administrative role, and it accepts any principal, not only the caller's own. It is not a screen and it is not a path this guide teaches, but it is reachable by whoever can reach the IdP. Raise it with your security reviewer if that population is one you would not let read each other's credential names.

{% hint style="warning" %}
**Break-glass, read-only: who has registered what.** These queries bypass the Webconsole. They change nothing, they are not audited as reads, and their only cost is that you are reading the schema rather than the product's own view of it. There is no supported screen that answers either question, which is why they are here.

{% code overflow="wrap" %}

```sql
-- Every credential for one identity
SELECT f.ID, f.NAME, f.DESCRIPTION, f.COUNTER
FROM   FIDO_AUTHENTICATOR f JOIN LOGIN l ON l.USER_ID = f.USER_ID
WHERE  l.LOGIN = '<PRINCIPAL>' AND l.MANAGED_SYS_ID = '0';

-- Adoption: how many identities hold at least one, and which hold only one
SELECT COUNT(DISTINCT USER_ID) AS USERS_WITH_A_KEY FROM FIDO_AUTHENTICATOR;
SELECT USER_ID, COUNT(*) AS KEYS FROM FIDO_AUTHENTICATOR GROUP BY USER_ID HAVING COUNT(*) = 1;
```

{% endcode %}

The last query is the one to run before you attach `WEB_AUTH` to anything. `DESCRIPTION` holds the hostname the credential was registered against, which is the fastest way to spot a population that registered on the wrong host.
{% endhint %}

## Related

* [What OpenIAM's WebAuthn implementation does and does not do](/administration-guide/security/fido2-and-webauthn/fido2-and-webauthn-reference.md) — the fixed ceremony parameters, why passkeys are not supported, and what ships in 4.2.1.0
* [The authentication model](/administration-guide/security/authentication-model.md#passwordless-sign-in) — passwordless sign-in, the other shipped rules that do it, and why one FIDO step is one factor
* [Authentication providers and MFA](/administration-guide/security/authentication-providers-and-mfa.md) — choosing between the factors, and the break-glass recovery
* [Step-up authentication](/administration-guide/security/step-up-authentication.md) — demanding `WEB_AUTH` on one path only, and how long a satisfied step lasts
* [Adaptive authentication](/administration-guide/security/adaptive-authentication.md) — demanding FIDO only on a privileged branch

*Applies to OpenIAM 2026.8.1. Last reviewed 7 August 2026.*


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.openiam.com/administration-guide/security/fido2-and-webauthn.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
