> 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/adaptive-authentication.md).

# Adaptive authentication

Build an adaptive authentication rule that steps up when a user's country is new, prove which edge fired, and roll it back safely.

## What this is, and when you need it

Adaptive authentication is OpenIAM's **risk-based** authentication: the rule reads signals about the request and branches on them, instead of demanding the same proof from everybody.

A fixed sign-in flow treats a familiar employee on a known laptop and a privileged administrator arriving from an unfamiliar country identically. An **adaptive risk** step lets an authentication rule evaluate one signal, get back a boolean, and take a different edge — continue, demand another factor, offer a choice, or end at `AUTHORIZATION_DECLINE`.

This page builds one rule end to end: **step up to TOTP when the country is new.** It is the rule to deploy first, because it leaves the ordinary case alone and challenges the case that actually correlates with account takeover — the same credential presented from a country this identity has never used. A second, stricter variant is at the end.

Do not describe this feature as general fraud detection. The engine exposes fourteen signals and one Groovy escape hatch. Impossible travel, ASN reputation and MFA-fatigue detection are not among them. All fourteen, with the edge each one takes when its own evaluation fails, are in [Adaptive risk types and their values](/administration-guide/security/adaptive-authentication/adaptive-authentication-reference.md).

## At a glance

|                          |                                                                                                                                                                            |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **You will end up with** | One test URI pattern on which a known country signs in with a password alone, and a country this identity has never used demands a TOTP code as well                       |
| **Where**                | **Policy → Authentication Rule** (`/webconsole/authentication-rule-list`), then the URI pattern at `/webconsole/editProviderPattern`                                       |
| **You need first**       | Password sign-in and TOTP each working on their own, geolocation switched on and resolving, and a tested break-glass administrator on a route this rule does not cover     |
| **Takes**                | Two to three hours, most of it testing the branches — plus a week of ordinary sign-ins beforehand if you want the gentle rollout described under [Cold start](#cold-start) |
| **Reversible**           | Yes — deleting the attachment row restores the previous behaviour within one 60-second sweep. Nothing about a user's recorded history is undone                            |

## Before you begin

Four things block starting, and one of them ships in the wrong state:

* **`org.openiam.adaptive.risk.geo.location.use` ships `false`.** Nothing resolves a country until it is `true`. Set it in the `IP` category of **Administration → System Configuration**, then confirm a test sign-in populates `city` and `country` on that user's most recent `urifederationstatistic` document. Empty means either the switch or the lookup, and *"Can't get Geo location information by IP"* in the ESB log distinguishes them.
* **Trustworthy client-IP forwarding.** Every country here is derived from the address your reverse proxy hands OpenIAM. If the proxy passes its own address, every user resolves to the same country and the rule never fires. Compare `ipAddress` on a recent statistics document with the address a test user really came from.
* **Password sign-in and TOTP each already working**, separately, before either goes into a graph.
* **A tested break-glass administrator** on a route that does not use the rule you are building. This is the change class [Change configuration safely](/administration-guide/start/change-safely.md) calls unrecoverable.

Leave the six `org.openiam.approve.new.*` properties at their shipped `false` for now. Turning on the approve-link variants makes a newly seen country stay "new" until the user clicks a mailed link, which multiplies the challenges this rule produces.

## Build the rule

{% stepper %}
{% step %}

### Create the rule and save it once

**Policy → Authentication Rule → New**. Name it something a stranger can act on — `STEPUP_NEW_COUNTRY` — describe it, and save. The graph editor needs the persisted `RULE_ID` before it will render, so an empty canvas on a brand-new rule is expected rather than a fault.
{% endstep %}

{% step %}

### Lay out four nodes

Drag the authentication types from **Drag required authentication types here** and the risk from **Drag required adaptive risk items here**:

| Node | Grouping               | Risk type        | Conformity             | Adaptive Risk Value       | Lifetime |
| ---- | ---------------------- | ---------------- | ---------------------- | ------------------------- | -------- |
| 1    | `PASSWORD_AUTH`        | —                | —                      | —                         | empty    |
| 2    | —                      | `IS_NEW_COUNTRY` | disabled for this type | not offered for this type | —        |
| 3    | `TOTP_AUTH`            | —                | —                      | —                         | `15`     |
| 4    | `AUTHORIZATION_ACCEPT` | —                | —                      | —                         | empty    |

Select the risk node: the **Select conformity type** drop-down is greyed out and no **Adaptive Risk Value** field appears, which is how you confirm you picked a type that takes neither.

The risk node must sit **below** `PASSWORD_AUTH`. The four `IS_NEW_*` signals are computed during the step that authenticates the identity, not at the risk node, so a request that has not authenticated yet is treated as new every time.
{% endstep %}

{% step %}

### Draw three edges, and write the truth table down first

Which edge carries `Y` is the single thing this rule gets wrong most often, so decide it on paper before you draw: **`Y` means "the country is new", and `Y` goes to TOTP.**

| From               | To                       | `PARENT_RISK_RESULT`                          |
| ------------------ | ------------------------ | --------------------------------------------- |
| 1 `PASSWORD_AUTH`  | 2 `IS_NEW_COUNTRY`       | `Y`                                           |
| 2 `IS_NEW_COUNTRY` | 3 `TOTP_AUTH`            | **`Y`** — the country is new                  |
| 2 `IS_NEW_COUNTRY` | 4 `AUTHORIZATION_ACCEPT` | **`N`** — a country this user has used before |
| 3 `TOTP_AUTH`      | 4 `AUTHORIZATION_ACCEPT` | `Y`                                           |

Reuse node 4. A second accept node is a second terminal and makes the graph harder to read for no benefit. A step with no outgoing edge is a dead end, not a denial; add an `AUTHORIZATION_DECLINE` node if you want an explicit refusal.
{% endstep %}

{% step %}

### Set the reuse window on the TOTP step

Select the `TOTP_AUTH` node and set **Lifetime (minutes)** to `15`. A user who has just proved a code is not asked again for fifteen minutes as they move between protected URLs — short enough that a walk-away is not a free pass, long enough to cover a normal session's navigation. Leave it empty for a challenge on every protected request; do not set it to hours, and never above your session idle timeout.
{% endstep %}

{% step %}

### Save, reopen, and read the graph back

Read the canvas first. The edge flags are the part it renders least plainly, so if you need certainty about which edge is `Y`, read the rows directly.

{% hint style="warning" %}
**Break-glass, read-only.** These two queries bypass the Webconsole. They change nothing and are not audited as reads; 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 lists `PARENT_RISK_RESULT` per edge, which is why this is here.
{% endhint %}

{% code overflow="wrap" %}

```sql
SELECT s.ID, s.AUTH_LEVEL_GROUPING_ID, r.TYPE_RISK, r.TYPE_CONFORMITY, r.RISK_VALUE, s.LIFETIME
FROM   RULE_AUTHENTICATION_STEP s LEFT JOIN ADAPTIVE_RISK r ON s.ADAPTIVE_RISK_ID = r.ADAPTIVE_RISK_ID
WHERE  s.RULE_ID = '<YOUR_RULE_ID>';

SELECT PARENT_ID, CHILD_ID, PARENT_RISK_RESULT
FROM   RULE_AUTHENTICATION_STEP_XREF
WHERE  PARENT_ID IN (SELECT ID FROM RULE_AUTHENTICATION_STEP WHERE RULE_ID = '<YOUR_RULE_ID>');
```

{% endcode %}

You should see exactly one `Y` and one `N` row out of the risk step. One row means half your traffic has no path.
{% endstep %}

{% step %}

### Attach it to one narrow URI pattern, not the content provider

Attach the rule to a single test URI pattern at `/webconsole/editProviderPattern` — a `URI_RULE_XREF` row. That pattern now ignores the content provider's `DEF_PASSWD_AUTH_RULE` entirely, which is why `PASSWORD_AUTH` is a step in your rule and not an assumption. Never attach a first test to `DEFAULT_CP`. Wait out `org.openiam.auth.provider.sweep.time`, `60000` ms, before testing.
{% endstep %}

{% step %}

### Manufacture a new country without travelling

The honest way is a VPN whose exit is in another country. It exercises the whole path, including geolocation, and it is the only test that proves the lookup works.

The fast way is break-glass. `IS_NEW_COUNTRY` fires when the country resolved from the request matches no `USER_AUTH_PARAM` row of type `COUNTRY` for that user, so remove the row for their real country while leaving at least one other row of that type in place — an empty list of country rows is treated as "seed it", not as "new".

{% hint style="danger" %}
**Break-glass: manufacturing the new-country condition for a test identity.** This writes directly to the user's authentication history. It produces no audit record, validates nothing, and must only ever be done to a test identity — doing it to a real user re-challenges them for no reason. The supported alternative is the VPN.

{% code overflow="wrap" %}

```sql
-- Read the test user's recorded countries first
SELECT PARAM_ID, TYPE, PARAM_VALUE, CONFIRMED FROM USER_AUTH_PARAM
 WHERE USER_ID = '<TEST_USER_ID>' AND TYPE = 'COUNTRY';

-- Leave a placeholder so the type is not empty, then drop the real one
INSERT INTO USER_AUTH_PARAM (PARAM_ID, USER_ID, TYPE, PARAM_VALUE, CONFIRMED)
VALUES ('<NEW_ID>', '<TEST_USER_ID>', 'COUNTRY', 'ZZ', 'Y');
DELETE FROM USER_AUTH_PARAM
 WHERE USER_ID = '<TEST_USER_ID>' AND TYPE = 'COUNTRY' AND PARAM_VALUE = '<REAL_CODE>';
```

{% endcode %}

The next sign-in takes the `Y` edge and records the real country again, so the test is self-resetting — repeat it by re-running the `DELETE`. To undo everything, delete the `ZZ` row. `PARAM_VALUE` holds two-letter codes, and there is a uniqueness constraint on user, type and value, so a duplicate insert is rejected rather than silently ignored.
{% endhint %}
{% endstep %}

{% step %}

### Walk every branch, each in a fresh private session

Known country. New country with TOTP already enrolled. New country with TOTP **not** enrolled. The same user again from the country they were just challenged for, which must **not** challenge. Geolocation deliberately unreachable, which must pass **unchallenged** — run that one deliberately, because it is the failure mode most administrators guess wrong. A disabled user. And the break-glass administrator on a route that does not carry this rule.

Record which edge fired for each, from `/webconsole/authenticationDebug`.
{% endstep %}
{% endstepper %}

## Verification

| What to check                             | How                                                                                                                                                                                  |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| The graph is complete                     | One `Y` and one `N` edge out of the risk step, and a path from every root to `AUTHORIZATION_ACCEPT`. The canvas shows the paths; the break-glass queries above show the edge flags   |
| The rule is attached where you intended   | A `URI_RULE_XREF` row for your `RULE_ID`, at the `EXEC_ORDER` you set                                                                                                                |
| The rule actually evaluated               | `/webconsole/authenticationDebug` names the rules that applied to a given request                                                                                                    |
| The signal has history to compare against | `USER_AUTH_PARAM` rows of type `IP`, `DEVICE`, `CITY` and `COUNTRY` for the test user. No rows of a type means the next sign-in seeds that type rather than reporting it as new      |
| Geolocation is live                       | A recent `urifederationstatistic` document for the test user with `city` and `country` populated, and no *"Can't get Geo location information by IP"* in the ESB log                 |
| The reuse window behaves as labelled      | Set **Lifetime (minutes)** to `2` on the TOTP step, complete it, then re-request the protected URL after one minute (no prompt) and after three (prompt)                             |
| A risk failed rather than evaluated       | *"Cannot process risk: %s, params: %s"* in the ESB log. Its presence means the fallback decided the branch, not your signal                                                          |
| Audit                                     | `LOGIN`, `DO_LOGIN`, and `SAVE_AUTH_RULE` / `DELETE_AUTH_RULE` for the rule edits. See [Audit actions and retention](/administration-guide/reference/audit-actions-and-retention.md) |

## When it goes wrong

| Symptom                                                    | Cause                                                                                                                                                            | Fix                                                                                                                                                                                                                                     |
| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Rule saves, nothing changes                                | No `CP_RULE_XREF` / `URI_RULE_XREF` row, or the edit is waiting on the 60-second provider sweep                                                                  | Check both tables; only an *unknown* rule ID forces an immediate sweep                                                                                                                                                                  |
| Rule name saves but the canvas is empty                    | The editor needs the persisted `RULE_ID`                                                                                                                         | Save, reopen, then build                                                                                                                                                                                                                |
| Every session takes the same branch                        | Only one edge exists out of the risk node, or both edges carry `PARENT_RISK_RESULT='Y'`                                                                          | Add the missing edge with `N`                                                                                                                                                                                                           |
| The rule stops challenging anybody, with no error          | Geolocation is not resolving, so no country is recorded and none is compared — the request reads as "not new" and takes the `N` edge                             | Check for *"Can't get Geo location information by IP"* in the ESB log and for empty `city` and `country` on new statistics documents. See [What happens when geolocation is unavailable](#what-happens-when-geolocation-is-unavailable) |
| City and country always empty                              | `org.openiam.adaptive.risk.geo.location.use` is `false` (shipped), or geolocation cannot reach the API and the local database is not loaded                      | Enable the master switch; for an air-gapped deployment see [Adaptive risk types and their values](/administration-guide/security/adaptive-authentication/adaptive-authentication-reference.md#geolocation)                              |
| Location correct for some users, wrong for others          | The reverse proxy is not forwarding the real client IP consistently                                                                                              | A deployment fix — the `X-Forwarded-For` chain, [Installation Guide](/installation/installation.md). From your side, compare `ipAddress` on the statistics documents with the addresses users really came from                          |
| The risk node takes the same edge for every request        | The node sits above `PASSWORD_AUTH`. Before authentication all four `IS_NEW_*` signals are treated as new                                                        | Move the risk node below the password step                                                                                                                                                                                              |
| A brand-new user is challenged on their very first sign-in | First authentication has no history, so every `IS_NEW_*` signal reports new                                                                                      | Expected. Exclude the induction route if that is unacceptable                                                                                                                                                                           |
| The branch is the opposite of what you intended            | The fail-open flag decided it, or the signal's polarity is not what its label suggests                                                                           | Check the ESB log for *"Cannot process risk"*, then consult [Adaptive risk types and their values](/administration-guide/security/adaptive-authentication/adaptive-authentication-reference.md)                                         |
| A dependency outage sends everyone down the trusted path   | That type fails open: `CUSTOM_ADAPTIVE_RISK`, `IS_NEW_CITY`, `IS_NEW_COUNTRY`, `IS_NEW_DEVICE`                                                                   | Add a fail-closed companion signal, or move the check to a rule the outage does not reach                                                                                                                                               |
| `UNAUTHORIZED` with no credential error                    | The flow reached `AUTHORIZATION_DECLINE`, often because required adaptive extra data was absent — look for *"Mandatory extra data don't present"* in the ESB log | The request must carry IP, device and user agent; if the proxy is stripping them, that is a deployment fix — [Installation Guide](/installation/installation.md)                                                                        |
| Administrator locked out                                   | The rule reached a step nobody can satisfy, or a dead-end node                                                                                                   | There is no in-product way back; use the break-glass detach below. Keep the rule itself for investigation                                                                                                                               |

## Roll it back, and prove a rule is detached

Detaching is not the same as fixing, and it is the only thing you should be doing while people are locked out. Detach first, diagnose afterwards.

{% stepper %}
{% step %}

### Remove the attachment, not the rule

On the URI pattern or content provider, delete the row that attaches your rule. Do not edit the graph, do not delete the rule, and do not touch the row naming `DEF_PASSWD_AUTH_RULE` at `EXEC_ORDER` 0 — that row is what password sign-in falls back to. The failed graph is your evidence; keep it.
{% endstep %}

{% step %}

### Wait a sweep

`org.openiam.auth.provider.sweep.time` is `60000` ms. Retrying inside that minute tells you nothing, and concluding that the detach "did not work" is the commonest reason a second, worse change gets made on top of the first.
{% endstep %}

{% step %}

### Prove it, three ways

**The attachment is gone.** Open the URI pattern or content provider and read its **Authentication rules** list: your rule must not be on it.

**A real sign-in no longer routes through it.** In a fresh private session, sign in as the affected test identity and confirm you are never sent to `/idp/auth-totp`. `/webconsole/authenticationDebug` names the rules that actually applied; yours must not be among them.

**Both sign-in paths work.** The ordinary user path and the break-glass administrator path, each in a fresh session. A detach that restores one and not the other is not finished.
{% endstep %}
{% endstepper %}

Reattaching after a fix is the same procedure in reverse, and it goes back on a **narrow URI pattern** first, never straight back onto the content provider it came off.

{% hint style="danger" %}
**Break-glass: detaching a rule that locked everyone out.** A rule whose only path demands something nobody can satisfy cannot be detached from the Webconsole once it is protecting the Webconsole. The recovery is at the database, and it is outside the supported change path.

Read first, to find the attachment rather than guessing at it:

{% code overflow="wrap" %}

```sql
SELECT 'URI' AS SRC, URI_PATTERN_ID AS PARENT, RULE_ID, EXEC_ORDER FROM URI_RULE_XREF WHERE RULE_ID = '<YOUR_RULE_ID>'
UNION ALL
SELECT 'CP', CONTENT_PROVIDER_ID, RULE_ID, EXEC_ORDER FROM CP_RULE_XREF WHERE RULE_ID = '<YOUR_RULE_ID>';
```

{% endcode %}

Then delete only those rows — not the rule, and not any row naming `DEF_PASSWD_AUTH_RULE` — and wait out the 60-second sweep before retrying sign-in.

What this costs: the deletion is not audited, so nobody reviewing the audit trail will see that the route's protection changed; write it into your own change record. It also validates nothing, so deleting the wrong row un-protects a route you meant to keep protected. The supported alternative is to have the deployment owner or OpenIAM support make the change, which is slower but auditable.
{% endhint %}

## Why the country, and what the rule feels like

**Why the country and not the IP, the city or the device.** The client IP changes every time a home router reconnects, so `IS_NEW_IP` challenges half your population on a Monday. The city moves when a mobile carrier re-homes an address, and the shipped geolocation data resolves cities loosely. The device value on a web sign-in is the browser's user agent, so `IS_NEW_DEVICE` fires on every Chrome update. The country changes rarely, changes for a reason, and is the coarsest signal that still means something — which is exactly what you want for a first deployment, where a false challenge costs you goodwill you will need later.

**Known country.** Nothing. The user types their password and lands where they were going. This is the branch the overwhelming majority of sign-ins take, and it is the reason this rule is safe to deploy first.

**New country, user already enrolled in TOTP.** The password page, then `/idp/auth-totp` asking for a code, then their destination. One extra screen.

**New country, user not yet enrolled in TOTP.** The same page, but it shows an enrolment QR code instead of a code box. This is the branch that generates help-desk calls, for two reasons: the user was not expecting to enrol, and OpenIAM's TOTP time step is 60 seconds while most authenticator apps assume 30, so their first code may be rejected. Read [Add TOTP as a second factor](/administration-guide/security/totp.md) before you deploy this, and consider enrolling your population *before* you attach the rule rather than after.

**Every subsequent sign-in from that country.** Nothing. The country is recorded the first time it is seen, so the challenge is once per user per country, not once per trip.

### Cold start

An **existing** user who has signed in before but has no country recorded — because geolocation was off until today — is not challenged on their next sign-in. OpenIAM sees no country history at all for them, records the country it resolves, and reports nothing as new. The challenge only begins on a *second, different* country. That is a gentle rollout: switch geolocation on, let a week of normal sign-ins seed everybody's home country, then attach the rule.

A **brand-new** identity is different. Its very first authentication has no history of any kind, so everything is reported as new and the rule challenges it. Expect every new hire's first sign-in to demand a code. If that is unacceptable during induction, exclude the induction route from the rule rather than weakening the rule.

There is no minimum population and no model here — `IS_NEW_COUNTRY` is a comparison against stored values, not a prediction.

### What happens when geolocation is unavailable

This is the part to understand before you rely on the rule, and it is not what the fail-open table on the reference page would lead you to expect.

`IS_NEW_COUNTRY` carries a declared fallback of "trusted", which for this type means "the country is new" and would take the `Y` edge into the challenge. That fallback is almost unreachable, because the risk node does not perform the geolocation lookup. The country is resolved once, earlier, during the step that authenticates the identity, and recorded against the session; the risk node only reads the answer. So when the lookup fails — the external API is down, the address is unroutable, geolocation is switched off — **no country is resolved, nothing is compared, and the request is reported as "not a new country"**. It takes the `N` edge and is accepted without a challenge.

State that plainly in your control documentation: **this rule fails open in the direction of less friction, not more.** A geolocation outage does not lock anybody out and does not challenge anybody; it quietly stops the control working, and nothing on any screen announces that. The signals you have are the ESB log's *"Can't get Geo location information by IP"* and — the check that actually scales — `city` and `country` going empty on new `urifederationstatistic` documents. If this control is load-bearing for an audit, monitor one of those; if you cannot, say in the control description that the control is best-effort.

Two mitigations, in order of preference. Run the local geolocation database rather than the external API, so the lookup does not depend on an outbound call at all — see [Geolocation](/administration-guide/security/adaptive-authentication/adaptive-authentication-reference.md#geolocation). And, where the coverage gap matters more than the volume, add an `IS_NEW_IP` node in parallel: the IP is recorded for every sign-in regardless of geolocation, so it still fires when the country cannot be resolved. It also fires far more often, which is why it is a mitigation rather than the primary signal.

## Variant: privileged access always presents a hardware factor

The rule above trades friction for coverage. This one does not: it is for a route where the right answer to "can we make this easier" is no. Attach it to the URI pattern of the privileged application — the Webconsole, a payments console, a production jump page — and not to a content provider.

**The objective.** On this route, a member of the privileged role must present a FIDO authenticator, every session, whatever their country and whatever device they are on. Anybody who is not a member of that role does not reach the route at all.

**Why membership and not risk.** Risk signals answer "is this login unusual". On a privileged route the question is whether this identity should be here at all, and that has a definite answer that does not degrade when a geolocation API is down. `MEMBER_OF_ROLES` fails **closed**: if the authorization service cannot answer, nobody is a member, and with the graph below nobody gets in. For a privileged route that is the correct failure.

**Why FIDO and not TOTP.** A TOTP code can be read out over the phone to someone claiming to be from IT. A FIDO assertion cannot leave the browser it was created in, and will not be produced at all for the wrong hostname. See [FIDO2 and WebAuthn](/administration-guide/security/fido2-and-webauthn.md) — including the part where user verification is not enforced, so this is a possession factor chained after a password, not a replacement for one.

| Node | Grouping                | Risk type         | Conformity | Adaptive Risk Value        | Lifetime |
| ---- | ----------------------- | ----------------- | ---------- | -------------------------- | -------- |
| 1    | `PASSWORD_AUTH`         | —                 | —          | —                          | empty    |
| 2    | —                       | `MEMBER_OF_ROLES` | `IN`       | `["<PRIVILEGED_ROLE_ID>"]` | —        |
| 3    | `WEB_AUTH`              | —                 | —          | —                          | `10`     |
| 4    | `AUTHORIZATION_ACCEPT`  | —                 | —          | —                          | empty    |
| 5    | `AUTHORIZATION_DECLINE` | —                 | —          | —                          | empty    |

| From                | To                        | `PARENT_RISK_RESULT`                                               |
| ------------------- | ------------------------- | ------------------------------------------------------------------ |
| 1 `PASSWORD_AUTH`   | 2 `MEMBER_OF_ROLES`       | `Y`                                                                |
| 2 `MEMBER_OF_ROLES` | 3 `WEB_AUTH`              | **`Y`** — the user holds the role                                  |
| 2 `MEMBER_OF_ROLES` | 5 `AUTHORIZATION_DECLINE` | **`N`** — the user does not, or membership could not be determined |
| 3 `WEB_AUTH`        | 4 `AUTHORIZATION_ACCEPT`  | `Y`                                                                |

**Adaptive Risk Value** is a JSON array of role **IDs**, not names, with conformity `IN` when more than one role should qualify. `EQUALS` demands that the user hold *all* of them, which is almost never what you want. If a non-member should simply fall through to the ordinary rule rather than be refused, point edge `N` at node 4 instead of node 5 — refusing is the stronger posture and the one to start from.

Test it in this order, because the dangerous part is the population, not the graph:

| Order | Test                                                                                                        | Must be true                                                                                                                                                                                                                                                        |
| ----- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1     | List the role's members, then list who among them holds a FIDO credential, and reconcile by hand            | Nobody is on the first list and not the second. Anybody holding exactly one credential is one lost key from a lockout — use the adoption query on [FIDO2 and WebAuthn](/administration-guide/security/fido2-and-webauthn.md#what-the-administrator-sees-afterwards) |
| 2     | Break-glass administrator, on a route carrying only `DEF_PASSWD_AUTH_RULE`, not holding the privileged role | Signs in, before you attach anything, and the session stays open while you work                                                                                                                                                                                     |
| 3     | A member with a key                                                                                         | Password, then the browser's security-key prompt at `/idp/auth-web`, then the console                                                                                                                                                                               |
| 4     | A member whose key you have temporarily removed                                                             | Dead stop, with no self-service way out. This is the failure mode that makes the rule dangerous                                                                                                                                                                     |
| 5     | A non-member                                                                                                | `UNAUTHORIZED`, with no credential error, because the flow reached `AUTHORIZATION_DECLINE`. Tell your service desk what that means                                                                                                                                  |
| 6     | A member signing in twice inside ten minutes                                                                | Not prompted the second time — your proof that **Lifetime (minutes)** means what the label says                                                                                                                                                                     |
| 7     | The first day, in the audit log                                                                             | `LOGIN` records for the privileged route carry the **Authentication type** attribute `WEB AUTHENTICATION`. Its absence on a successful privileged sign-in means the rule is not attached where you think it is                                                      |

An authorization-service outage refuses everybody, including the privileged users. That is the deliberate consequence of `MEMBER_OF_ROLES` failing closed, and it is why your break-glass administrator must reach the Webconsole by a route this rule does not cover.

## A custom risk that compiles

`CUSTOM_ADAPTIVE_RISK` holds a Groovy script ID in **Adaptive Risk Value**. You implement one method, `isTrust`, and inherit four helpers for reading the request's extra data. The shipped example is `adaptive-risks/CustomAdaptiveRiskDefault_1.0.groovy`; the script below adapts it to something usable, keeping the signature exactly:

{% code overflow="wrap" lineNumbers="true" expandable="true" %}

```groovy
import org.openiam.script.AbstractCustomAdaptiveRisk
import org.openiam.base.request.AdaptiveAuthenticationExtraDataRequest
import org.openiam.am.srvc.dto.AdaptiveAuthenticationExtraDataType

class BusinessHoursRisk extends AbstractCustomAdaptiveRisk {

    @Override
    boolean isTrust(String userId, AdaptiveAuthenticationExtraDataRequest extraDataRequest) {
        // Trusted only inside 07:00-19:00 UTC. Anything else takes the N edge.
        int hour = Calendar.getInstance(TimeZone.getTimeZone('UTC')).get(Calendar.HOUR_OF_DAY)
        if (hour < 7 || hour >= 19) {
            log.info("Out-of-hours sign-in for user {}", userId)
            return false
        }

        // The requested URL is available as extra data; deny a sensitive path outright.
        Map<AdaptiveAuthenticationExtraDataType, String> extra = extraDataRequest.getExtraDataStringMap()
        String url = extra.get(AdaptiveAuthenticationExtraDataType.URL)
        if (url && url.contains('/webconsole/')) {
            return false
        }
        return true
    }
}
```

{% endcode %}

{% hint style="danger" %}
**`CUSTOM_ADAPTIVE_RISK` fails open, in every way it can fail.** It returns "trusted" when the script ID is blank, when no script exists at that ID, and when the script throws. A typo in the script ID is therefore indistinguishable from an unconditional pass, and nothing on the risk-node form tells them apart.

Prove your script is really running before you rely on it: make it return `false` unconditionally and confirm the `N` edge fires, then restore it. Test the missing-script case deliberately by renaming the script, because that is what happens when one goes missing in production. Never make a custom risk the only control on a privileged path.
{% endhint %}

Register it per [Create a Groovy script](/administration-guide/automation/manage-groovy-scripts.md).

## Related

* [Adaptive risk types and their values](/administration-guide/security/adaptive-authentication/adaptive-authentication-reference.md) — all fourteen signals, their fail-open flags, the conformity and value formats, geolocation and the behavioural model
* [The authentication model](/administration-guide/security/authentication-model.md) — the step graph, `PARENT_RISK_RESULT`, and why a URI pattern's rules replace a content provider's
* [Step-up authentication](/administration-guide/security/step-up-authentication.md) — demanding a stronger factor on one path, and how long a satisfied step lasts
* [Add TOTP as a second factor](/administration-guide/security/totp.md) — the factor this rule escalates to

*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/adaptive-authentication.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.
