> 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/compromised-passwords.md).

# Compromised password detection

Understand, verify and — if your deployment requires it — turn off the post-login breached-password notification that ships enabled on policy 4000.

## What this is, and when you need it

A password can satisfy every length and composition rule and already be known from a public breach. After a successful password sign-in, OpenIAM can hash the submitted password, send the first five characters of the SHA-1 hash to a k-anonymity range service, compare the remainder locally, and email the user when their password appears in the corpus.

This control does **not** block sign-in, reject the password during a change, force a reset, or scan stored password hashes. It notifies, after the fact, on the password login path only.

{% hint style="danger" %}
**This ships ENABLED, and it makes an outbound HTTPS call on every password sign-in.** Earlier documentation told administrators to switch it on. That is wrong for this release. These are the rows 2026.1.1 ships:

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

```sql
INSERT INTO POLICY_DEF_PARAM (DEF_PARAM_ID, POLICY_DEF_ID, NAME, DESCRIPTION, OPERATION, VALUE1, VALUE2, REPEATS,
                              PARAM_GROUP, HANDLER_LANGUAGE, POLICY_PARAM_HANDLER)
VALUES ('PASS_COMPROMISED_CHECK', '100', 'PASS_COMPROMISED_CHECK', 'Check if password has been compromised', 'BOOLEAN',
        'true', NULL, 0, 'PSWD_CHANGE_RULE', NULL, NULL);

INSERT INTO POLICY_ATTRIBUTE (POLICY_ATTR_ID, DEF_PARAM_ID, POLICY_ID, VALUE1, VALUE2, RULE_TEXT, REQUIRED)
VALUES ('PASS_COMPROMISED_CHECK', 'PASS_COMPROMISED_CHECK', '4000', 'true', NULL, NULL, 'Y');
```

{% endcode %}

`POLICY_ID='4000'` is the default password policy — the one every user falls back to. `VALUE1` is `true`, `REQUIRED` is `'Y'`, and the check needs both. No release since has changed either row, so unless somebody in your organisation has cleared the checkbox, this is your running state. Confirm it with the `POLICY_ATTRIBUTE` query below, which covers every policy and not just `4000`.

The endpoint is `https://api.pwnedpasswords.com/range/`. If your deployment has not approved that egress from the ESB, **your first action on this page is to turn the check off**, not on.
{% endhint %}

The gate that decides whether the check fires, what the lookup sends, where it fails open and what the notification contains are all set out after the procedure, under [The gate, exactly](#the-gate-exactly) onwards. You do not need any of it to make the decision below.

## At a glance

|                          |                                                                                                                                                                                                                  |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **You will end up with** | A deliberate, recorded state: the breached-password check either off on every password policy that carries it, or on and proven with one known-breached and one unique test credential                           |
| **Where**                | **Policy → Password Policy** at `/webconsole/editPolicy?id=4000`, parameter **Check if password has been compromised**; results in **Administration → Log Viewer** at `/webconsole/auditLog`                     |
| **You need first**       | A decision on whether per-sign-in egress to `https://api.pwnedpasswords.com/range/` is approved, two disposable non-production users under the same policy with valid email addresses, and working mail delivery |
| **Takes**                | 30 to 60 minutes once the egress question is settled                                                                                                                                                             |
| **Reversible**           | Yes for the checkbox, which flips back on any policy. No for notifications already sent, and the endpoint itself is not administrator-editable                                                                   |

## Decide: leave it on, or turn it off

{% stepper %}
{% step %}

### Establish whether the egress is approved

The ESB must reach `https://api.pwnedpasswords.com/range/` — or whatever you point `org.openiam.password.compromised.check.service` at — on every password sign-in. Confirm the outbound path, the proxy, and that a security review has accepted a per-login third-party call. An endpoint override is a security-sensitive service change, not a preference.
{% endstep %}

{% step %}

### If it is not approved, disable it before the first production sign-in

**Policy → Password Policy → 4000**, clear **Check if password has been compromised**. That sets `POLICY_ATTRIBUTE.VALUE1` to something other than `true` and the gate stops firing. Then check every *other* policy — a policy copied from `4000` inherits the attribute.

{% code overflow="wrap" %}

```sql
SELECT POLICY_ID, VALUE1, REQUIRED
FROM   POLICY_ATTRIBUTE
WHERE  DEF_PARAM_ID = 'PASS_COMPROMISED_CHECK';
```

{% endcode %}

Every row with `VALUE1='true'` **and** `REQUIRED='Y'` is a policy that will call out.
{% endstep %}

{% step %}

### If it is approved, confirm the mail path first

The notification is the entire user-visible output. Confirm mail delivery works, the `PASSWORD_HAS_BEEN_COMPROMISED` template is active and edited to name your change-password URL, and `<TEST_USER>` has a default email address. A working check with broken mail is silent.
{% endstep %}

{% step %}

### Prove both outcomes with disposable identities

Two non-production users under the same policy, each with a valid email address.

Give the first a value you know is in the corpus — use a well-known throwaway string, never a real person's password. Sign in successfully as them. Expect **all three**: sign-in succeeds and is not blocked; a child audit event `CHECK_IF_PASSWORD_WAS_COMPROMISED` under the login event whose info line contains `isPwned=true` and a non-zero `breachCount`, plus the warning *"Breach result: %s. Sending notification to user..."*; and the email arrives with that count.

Give the second a uniquely generated value. Sign in. Expect the same audit event with `isPwned=false, breachCount=0` and the info line *"Breach result: 0. Password wasn't detected as compromised."*, and **no** email.

Do not put either value in a ticket, a screenshot or retained documentation.
{% endstep %}

{% step %}

### Change the first user's password and re-test

Set a unique value and sign in again. The audit result must flip to `isPwned=false`. That closes the loop the user is being asked to close.
{% endstep %}

{% step %}

### Know what an endpoint outage looks like, and alert on it

The control fails open, so an outage is invisible unless you are watching for it. When the range service is unreachable, sign-in still succeeds, the audit event still appears — with `responseCode=500` and `isPwned=false` — no email is sent, and *"Error checking HIBP API"* appears in the ESB log. When the service answers with a non-200 rather than failing outright, there is **no log line at all** and the audit event carries that status code instead.

So alert on `responseCode != 200` in the audit stream. Log absence proves nothing.

Rehearsing the outage means denying the ESB egress to the range service, which is a network change on the server rather than a Webconsole one — see the [Installation Guide](/installation/installation.md). The observable consequences above are what you check from this side, whether you rehearse it or wait for the real thing.
{% endstep %}
{% endstepper %}

## Verification

| What to check              | How                                                                                                                                                                                               |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Which policies have it on  | The `POLICY_ATTRIBUTE` query above. `VALUE1='true'` **and** `REQUIRED='Y'` are both needed                                                                                                        |
| A check ran                | A `CHECK_IF_PASSWORD_WAS_COMPROMISED` audit event as a **child** of the `LOGIN` event, with the target user set                                                                                   |
| The verdict                | The event's info line carries the whole `BreachCheckResult` — `responseCode`, `isPwned`, `breachCount`                                                                                            |
| The endpoint is healthy    | `responseCode` in that info line is `200`. Anything else means fail-open; a non-200 is otherwise DEBUG-only                                                                                       |
| The notification was sent  | A `PASSWORD_HAS_BEEN_COMPROMISED` mail event whose parent is the `CHECK_IF_PASSWORD_WAS_COMPROMISED` child log                                                                                    |
| The template is active     | `SELECT IS_ENABLED, CAPTION FROM MAIL_TEMPLATE WHERE ID='PASSWORD_HAS_BEEN_COMPROMISED'` — ships `Y`                                                                                              |
| Coverage is what you think | Only a password sign-in through `DefaultLoginModule` is checked. Federated, social, OAuth and certificate sign-ins are never covered, and their absence from the audit stream is the confirmation |

## When it goes wrong

| Symptom                                                      | Cause                                                                                                                                        | Fix                                                                                                                   |
| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| Users receive breach emails nobody enabled                   | It ships enabled on policy `4000`                                                                                                            | Clear the checkbox on `4000` and on every policy copied from it                                                       |
| Outbound calls to `api.pwnedpasswords.com` from the ESB      | Same cause                                                                                                                                   | Same fix, or repoint `org.openiam.password.compromised.check.service` at an internal mirror                           |
| No audit event after a password login                        | The resolved policy does not have the attribute, or `REQUIRED` is not `'Y'`, or this was not a password sign-in through `DefaultLoginModule` | Check the resolved policy ([Password policy](/administration-guide/security/password-policy.md)) and the sign-in path |
| Audit event exists but no email for a known-breached value   | Check `breachCount` in the info line; then the user's default email, the template's `IS_ENABLED`, and the mail queue                         | Fix mail; the check itself worked                                                                                     |
| Every result is clean and you doubt it                       | A non-200 from the endpoint returns "not found" and logs at **DEBUG only**                                                                   | Read `responseCode` from the audit info line; raise the logger to DEBUG while diagnosing                              |
| The check silently stopped after a network change            | Exceptions log *"Error checking HIBP API"* at ERROR and return `500`/`false`                                                                 | Alert on `responseCode != 200` in the audit stream, not on log absence                                                |
| Federated users are never checked                            | Only the password login module has the cleartext password                                                                                    | Combine with IdP-side breached-password enforcement; there is no OpenIAM setting for this                             |
| The email does not tell users where to change their password | The shipped template has no OpenIAM link                                                                                                     | Edit `PASSWORD_HAS_BEEN_COMPROMISED` to name your trusted change-password URL                                         |
| The user changed their password and is notified again        | The new value is also in the corpus, or the user signed in with a cached older credential from a synchronized target                         | Re-check the audit `breachCount`; confirm the new value is active in OpenIAM                                          |
| You want it to block the sign-in                             | It cannot. It runs on a separate thread after success                                                                                        | Use composition rules, a dictionary, MFA or a risk response instead                                                   |

## The gate, exactly

Three conditions must hold on the password policy that resolved for the user, and the shipped state satisfies all three:

* the policy carries the `PASS_COMPROMISED_CHECK` attribute at all;
* `REQUIRED` on that attribute row is `'Y'`;
* `VALUE1` is the string `true`, compared case-insensitively.

So there are **two** ways to disable it: set `VALUE1` to anything other than `true`, or clear `REQUIRED` on the `POLICY_ATTRIBUTE` row. The Webconsole checkbox drives `VALUE1` only — a policy whose checkbox looks clear but whose `REQUIRED` is still `'Y'` is off, but a policy you disabled by clearing `REQUIRED` directly will look enabled on screen. Prefer the checkbox.

The check fires from one place only: `DefaultLoginModule`, on the success path of a password sign-in. Three consequences worth knowing:

* It runs **after** authentication has already succeeded, on a separate thread. Sign-in latency is not affected and the sign-in outcome cannot be affected.
* The login's own audit event is passed as the parent, so the result is a **child** audit event of the login rather than a standalone one. That parent/child shape is how you find it.
* Only the password login module reaches it. A SAML, OAuth, social or certificate sign-in never supplies the cleartext password, so those populations are not covered — and no configuration changes that.

## What the lookup actually sends

Four steps, and they are the standard k-anonymity range protocol:

1. OpenIAM takes the SHA-1 of the password as uppercase hex — 40 characters.
2. It splits that at five: the first five characters are the prefix, the remaining 35 the suffix.
3. It issues a plain `GET` to the configured service base with the prefix appended, and nothing else.
4. On `200` it reads the response line by line, splits each line on `:`, and compares the returned suffixes with the local one. A match yields the breach count from the corpus.

The password and the complete hash never leave the ESB — only those five hex characters go out. SHA-1 here is the corpus's identifier format, not how OpenIAM stores the password.

What leaves your network is one five-character prefix per password sign-in, to a third party. That is the fact to take to a security review, and it is not visible from the Webconsole — the only place you can see the calls happening is the audit stream named under Verification, or your own egress logs.

## Fail-open, and precisely where

Three paths all return `isPwned=false`:

| Path                                     | Returned                   | Logged                                                  |
| ---------------------------------------- | -------------------------- | ------------------------------------------------------- |
| `200` with no matching suffix            | `(200, false, 0)`          | nothing                                                 |
| Any non-`200` status                     | `(responseCode, false, 0)` | *"HIBP API returned status code: {}"* at **DEBUG only** |
| Any exception — DNS, timeout, TLS, parse | `(500, false, 0)`          | *"Error checking HIBP API"* at ERROR                    |

The non-200 branch is the one to watch: it is DEBUG-level, so in a default logging configuration an endpoint returning `403` or `429` produces **no log line at all** and an unbroken stream of clean results. An absence of notifications is not evidence that passwords are safe.

The whole result, `responseCode` included, is written into the audit event's info line — so a failing endpoint **is** recorded, and the audit log, not the application log, is the reliable place to detect one.

## Notification

On a match, OpenIAM sends the `PASSWORD_HAS_BEEN_COMPROMISED` notification asynchronously with two parameters:

| Parameter      | Value                                                              |
| -------------- | ------------------------------------------------------------------ |
| `BREACH_COUNT` | The integer count from the corpus                                  |
| `user`         | The matched user, so the template can address them by display name |

The mail template `PASSWORD_HAS_BEEN_COMPROMISED` ships **enabled** (`IS_ENABLED='Y'`, `IS_HTML='Y'`) with the caption *"Security Alert: Your Password Has Been Compromised"* — the `MAIL_TEMPLATE` query under Verification confirms it on your system. It references `${req.getNotificationParam('user').valueObj.displayName}` and `${req.getNotificationParam("BREACH_COUNT").valueObj}`, tells the user to change the password immediately, and credits the Have I Been Pwned service. It does not contain the password and does not contain a link into OpenIAM — which is good, but means the user is not told *where* to change it. Edit the template to add your trusted change-password URL before you rely on this in production. See [Mail and notifications](/administration-guide/platform/mail-and-notifications.md) and [Shipped mail templates](/administration-guide/reference/shipped-mail-templates.md).

There is no log-only mode, no snooze, and no force-reset. When a match is found the notification is sent. Treat any of those as an enhancement request.

## The property

| Property                                         | Shipped value                           |
| ------------------------------------------------ | --------------------------------------- |
| `org.openiam.password.compromised.check.service` | `https://api.pwnedpasswords.com/range/` |

This one is **not** a `PROPERTY_FILE_VALUES` row, so it does not appear under **Administration → System Configuration** and you cannot read or change its running value from the Webconsole. It lives in the ESB's own property configuration and takes effect on restart, which puts the change itself in the [Installation Guide](/installation/installation.md) and in the deployment owner's hands, not yours. If you need to know which endpoint a running system is calling and nobody can tell you, your egress logs will show it.

What you do own is the requirement. The prefix is appended directly to the value, so the trailing `/` is part of it. Any replacement must accept `GET <base><5-hex-chars>` and answer with `SUFFIX:COUNT` lines, uppercase hex, one per line — nothing else is negotiated, and an endpoint that does not do this fails open exactly like an outage. Specify that contract when you ask for an internal mirror, and test it with the two-user procedure above before you trust it.

## Operating it

Once it is on, four things belong in monitoring:

* The rate of `CHECK_IF_PASSWORD_WAS_COMPROMISED` events, and how many carry `responseCode != 200`.
* `PASSWORD_HAS_BEEN_COMPROMISED` delivery failures.
* Repeat notifications for the same user — a user notified twice has not acted.
* Time from notification to that user's next successful password change.

Your support runbook must tell users to navigate to the trusted OpenIAM change-password page themselves. The notification must never ask for a password by reply, and this control is exactly the kind of email a phishing campaign will imitate — tell your users what a genuine one looks like.

Audit retention for the child action is `org.openiam.audit.log.prune.days.CHECK_IF_PASSWORD_WAS_COMPROMISED`, which ships `-1` — never prune — like every other retention property. See [Audit actions and retention](/administration-guide/reference/audit-actions-and-retention.md).

## Related

* [Password policy](/administration-guide/security/password-policy.md) — `PASS_COMPROMISED_CHECK` in the `PSWD_CHANGE_RULE` group, and how a policy is resolved
* [Mail and notifications](/administration-guide/platform/mail-and-notifications.md) — editing the template
* [Shipped mail templates](/administration-guide/reference/shipped-mail-templates.md)
* [Audit actions and retention](/administration-guide/reference/audit-actions-and-retention.md)
* [Password and authentication policy parameters](/administration-guide/reference/policy-parameters.md)

*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/compromised-passwords.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.
