Integrate password risk
Hash and match locally, apply versioned policy, and preserve native IdP controls.
Prerequisites and plan required
Credential Intelligence beta is included with Professional and Enterprise. It combines current hostile-use evidence with breach prevalence for sanitized passwords observed in a rolling 30-day window; it is not a general breach search or evidence that a named account was compromised.
Breach data by LeakJar, a SEWORKS product. For organization-wide exposure monitoring and broader breach coverage, use LeakJar. Only Mothlight-attacked suffixes are returned here, never a whole upstream breach bucket.
Reading the result
There are two moments to use the recommendation:
- When someone chooses this password (
recommendation.password_set): check before accepting it at sign-up, password change or reset. - Right after someone signs in with this password (
recommendation.login): check after their password was accepted, before giving them normal access. Do not check every failed guess.
For available data, a breach match takes priority over network counts. Data older than 26 hours gives no verdict.
| Situation | When choosing a password | After sign-in |
|---|---|---|
| Seen in attacks + in breach data | Block | Reset |
| Attacked from 2+ networks | Block | Extra check (step-up) |
| 1 network only | Monitor | Monitor |
| Not seen by Mothlight | Monitor | Monitor |
| Data older than 26 hours | No verdict; keep your existing policy | No verdict; keep your existing policy |
- Block (
block): Don't allow this password. Ask for a different one. - Extra check (
step_up): Let them in only after an extra check, such as MFA. - Reset (
reset): After they sign in, require a new password (and sign out other sessions). Require an extra check before access. - Monitor (
monitor): No extra action from Mothlight. Keep your usual controls — this is not a 'safe' verdict.
NOT_OBSERVED_BY_MOTHLIGHT means: not seen in attacks on Mothlight sensors in the last 30 days. Mothlight shows breach data only for attacked passwords — check LeakJar for full breach exposure. It does not mean the password is absent from all breach data.
Attack data covers the last 30 days. Use freshness.age_seconds to show how many minutes ago it was updated. Data more than 2 hours old is delayed; data older than 26 hours gives no verdict. Keep your existing policy when data is unavailable. Technical reason codes and the policy version are available for audit records.
Request and local matching
Use POST /api/v1/credentials/risk/range with a verified bearer API key and exactly {"prefix":"ABCDE"}. Hash the exact UTF-8 bytes accepted by your verifier, with no trimming, case folding or additional Unicode normalization. SHA-1 is a transient lookup index, never a password-storage algorithm. Compare the remaining 35 uppercase hex characters locally. Keep passwords and full hashes short-lived; do not log them or embed API keys in browser code.
The schema is password-risk-response-v1; the policy is mothlight-password-risk-2026-11-01. JSON entries carry suffix, aggregate attack evidence, tri-state breach, risk_level, and separate recommendation.password_set and recommendation.login actions. breach.status is seen, not_seen, pending or stale; pending is unknown, not false. The response also carries generated_at, a 30-day window, freshness, no_match, and the exact attribution. Ignore JSON padding when matching.
An absent suffix selects no_match: unknown, monitor, NOT_OBSERVED_BY_MOTHLIGHT. Not seen is not safe, not an allow verdict, and says nothing about general breach status. Continue native IdP controls. The service covers sanitized typed passwords, excluding proofs/tokens, email/domain-like values and other excluded material; coverage is incomplete.
Node / TypeScript trusted backend
Run this in the customer's trusted backend, after successful primary verification for login or before accepting a prospective password for sign-up/set/reset. Never call on every invalid guess. The helper returns a local match or no-match policy; null means continue your existing IdP policy and emit an availability event. No automatic retries occur.
import { createHash } from "node:crypto";
type Decision = { action: string; reason_codes: string[] };
type Match = { risk_level: string; recommendation: { password_set: Decision; login: Decision } };
type Range = {
entries: (Match & { suffix: string })[];
no_match: { risk_level: string; password_set: Decision; login: Decision };
freshness: { age_seconds: number };
};
export async function passwordRisk(password: string, apiKey: string) {
let hash = createHash("sha1").update(password, "utf8").digest("hex").toUpperCase();
try {
const response = await fetch("https://mothlight.ai/api/v1/credentials/risk/range", {
method: "POST", signal: AbortSignal.timeout(1500),
headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ prefix: hash.slice(0, 5) }),
});
if (!response.ok) return null;
const range: Range = await response.json();
if (!Number.isFinite(range.freshness.age_seconds) || range.freshness.age_seconds > 93600) return null;
return range.entries.find(entry => entry.suffix === hash.slice(5)) ?? {
risk_level: range.no_match.risk_level,
recommendation: { password_set: range.no_match.password_set, login: range.no_match.login },
};
} catch { return null; }
finally { hash = ""; }
}Strings cannot be reliably zeroized in managed runtimes. Release caller references promptly and exclude all sensitive values from errors, tracing, analytics and crash reports.
Python trusted backend
Python uses httpx with a total 1.5-second transaction deadline plus per-operation limits. No retries. None means continue existing policy.
import asyncio
import hashlib
import httpx
async def password_risk(password: str, api_key: str):
digest = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
try:
async with asyncio.timeout(1.5):
async with httpx.AsyncClient(timeout=1.5) as client:
response = await client.post(
"https://mothlight.ai/api/v1/credentials/risk/range",
headers={"authorization": f"Bearer {api_key}"},
json={"prefix": digest[:5]},
)
if response.status_code != 200:
return None
data = response.json()
age = data["freshness"]["age_seconds"]
if not isinstance(age, (int, float)) or not 0 <= age <= 93600:
return None
return next((entry for entry in data["entries"]
if entry["suffix"] == digest[5:]), data["no_match"])
except (TimeoutError, httpx.HTTPError, KeyError, ValueError, TypeError):
return None
finally:
digest = ""Java trusted backend (also callable from Kotlin)
Java 17+ example using Jackson for JSON. Configure the client once with a 1.5-second connect timeout; each request has the same deadline. No retries. null means continue existing policy.
static final java.net.http.HttpClient CLIENT = java.net.http.HttpClient.newBuilder()
.connectTimeout(java.time.Duration.ofMillis(1500)).build();
static final com.fasterxml.jackson.databind.ObjectMapper JSON = new com.fasterxml.jackson.databind.ObjectMapper();
static com.fasterxml.jackson.databind.JsonNode passwordRisk(String password, String key) {
try {
String hash = java.util.HexFormat.of().withUpperCase().formatHex(
java.security.MessageDigest.getInstance("SHA-1").digest(password.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
var request = java.net.http.HttpRequest.newBuilder(java.net.URI.create("https://mothlight.ai/api/v1/credentials/risk/range"))
.timeout(java.time.Duration.ofMillis(1500))
.header("Authorization", "Bearer " + key).header("Content-Type", "application/json")
.POST(java.net.http.HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(java.util.Map.of("prefix", hash.substring(0, 5))))).build();
var response = CLIENT.send(request, java.net.http.HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) return null;
var data = JSON.readTree(response.body());
var age = data.path("freshness").path("age_seconds");
if (!age.isNumber() || age.asDouble() < 0 || age.asDouble() > 93600) return null;
for (var entry : data.path("entries"))
if (entry.path("suffix").asText().equals(hash.substring(5))) return entry;
return data.get("no_match");
} catch (InterruptedException e) { Thread.currentThread().interrupt(); return null; }
catch (Exception e) { return null; }
}C# / .NET trusted backend
Reuse HttpClient; the cancellation token bounds the full operation to 1.5 seconds. No retries. A null result continues existing policy.
static readonly HttpClient Client = new();
static async Task<System.Text.Json.Nodes.JsonNode?> PasswordRisk(string password, string key) {
using var deadline = new CancellationTokenSource(TimeSpan.FromMilliseconds(1500));
try {
var hash = Convert.ToHexString(System.Security.Cryptography.SHA1.HashData(System.Text.Encoding.UTF8.GetBytes(password)));
using var request = new HttpRequestMessage(HttpMethod.Post, "https://mothlight.ai/api/v1/credentials/risk/range");
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", key);
request.Content = System.Net.Http.Json.JsonContent.Create(new { prefix = hash[..5] });
using var response = await Client.SendAsync(request, deadline.Token);
if (!response.IsSuccessStatusCode) return null;
var data = System.Text.Json.Nodes.JsonNode.Parse(await response.Content.ReadAsStringAsync(deadline.Token))!;
var age = data["freshness"]?["age_seconds"]?.GetValue<double>();
if (age is null || !double.IsFinite(age.Value) || age < 0 || age > 93600) return null;
foreach (var entry in data["entries"]!.AsArray())
if (entry?["suffix"]?.GetValue<string>() == hash[5..]) return entry;
return data["no_match"];
} catch (Exception) { return null; }
}Shell transport inspection
This fictional precomputed prefix is for inspection only. Do not type real passwords into a shell command or model. The request has a 1.5-second timeout and no retries. On timeout, 429, 5xx or older than 26-hour freshness, continue the existing IdP policy; never interpret an empty/error response as allow.
curl --fail --max-time 1.5 --request POST \
'https://mothlight.ai/api/v1/credentials/risk/range' \
--header "Authorization: Bearer $MOTHLIGHT_API_KEY" \
--header 'Content-Type: application/json' \
--data '{"prefix":"ABCDE"}'Local conformance vector (synthetic test only): UTF-8 password hashes to 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8; send 5BAA6, compare 1E4C9B93F3F0682250B6CF8331B7EE68FD8 locally. This is a hashing vector, not a live breach or risk assertion.
Expected result
A local match selects the context-specific recommendation; no match continues native controls with unknown/monitor.
Policy and failure handling
LeakJar seen: block a prospective password and recommend reset after valid login. Two or more masked source networks: block at set/reset/sign-up and step up a valid login. One network alone: monitor regardless of attempt volume. Attack tiers are observed (1), corroborated (2–4), broad (5–19) and high (20+), based on masked IPv4 /24 or IPv6 /48 networks. Reset means step up first, choose a new password, and revoke other sessions as appropriate; do not expose matches to unauthenticated claimants.
Snapshot refresh is hourly: fresh at most 2 hours, delayed older than 2 to at most 26 hours (DATA_DELAYED), unavailable older than 26 hours (503). Breach checks older than 35 days are stale; stale false is unknown, while stale true retains its evidence with LEAKJAR_RESULT_STALE. Reason codes and policy version support auditability, not calibrated compromise probabilities.
Use a 1–2 second deadline. On unavailable data, timeout, 429/5xx or age older than 26 hours, fail open to the customer's existing IdP policy, emitting an availability event without any allow verdict. Keep native breach controls, MFA, rate limiting and recovery. Examples do not retry; if needed, permit at most one bounded idempotent retry within the original deadline. A repeated lookup is metered again. Respect Retry-After asynchronously, never sleep through a login transaction.
IdP recipes and capability limits
- Microsoft Entra ID: sync the existing source-ranked, 1,000-entry, 4–16-character Entra blocklist into native Password Protection. Call this API only in first-party sign-up/reset middleware that actually handles the password. Entra does not expose password bytes to an arbitrary external hook.
- Okta: a Password Import Inline Hook migration adapter can hash locally on migration/first login. Registration/token hooks are not a general password-policy interception point. Ongoing set/reset requires a customer-controlled flow or the blocklist feed.
- Auth0: retain native breached-password protection. Hosted Actions are not a plaintext-password hook. Use a custom backend to check the password, then pass a previously calculated enforcement flag to post-login/reset Actions to drive step-up/reset.
- OIDC/SAML gateway: check only after successful primary password verification where your trusted backend handles the password; apply step-up before issuing a normal session. A federated gateway that never sees the password needs a previously computed risk flag, not a claim that it can inspect a hosted IdP password.
SIEM / SOAR
Log only customer-side actions and safe evidence metadata. Do not log passwords, hashes, prefixes, suffixes, buckets or username-password associations. Example customer-owned correlation ID:
{"action":"step_up","risk_level":"elevated","attack_tier":"corroborated","reason_codes":["LEAKJAR_CHECK_PENDING","ATTACK_CORROBORATED"],"policy_version":"mothlight-password-risk-2026-11-01","data_age_seconds":120,"provider_status":"pending","correlation_id":"local-event-123"}Troubleshooting
Verify entitlement and quota headers without logging request/response bodies. An unavailable response is never a no-match result.
Limits, errors and MCP
Professional: 300 requests/minute, 20,000 requests/day, 50,000 rows/day. Enterprise: 600/minute, 100,000/day, 1,000,000 rows/day. Lower key limits win. Each call reserves one request and max(1, real entries) rows, including empty buckets; durable quota failure closes the API with 503. This is not unlimited identity traffic.
Input is prefix-only, at most 1 KiB; unknown properties, arrays, plaintext and full hashes are rejected. REST supports JSON or tab-separated text/plain with versioned columns and comment lines; unsupported Accept returns 406. Successful REST bodies are exactly 32,768 bytes, Cache-Control: private, no-store, no-transform with identity encoding. At most 64 real entries; overflow returns 503 without truncation. Errors are safe {error:{code,message}}: 400 invalid input, 401 key, 403 entitlement, 404 non-brand host, 406 format, 413 size, 429 quota, 500 internal, 503 unavailable (Retry-After: 300). No bulk endpoint.
The authenticated MCP tool get_password_risk_range accepts the same prefix-only object and returns the same unpadded schema, sharing request/row quotas. Anonymous and Researcher clients cannot list or invoke it. Compute the prefix outside the model for real passwords: LLM/MCP clients may retain inputs and outputs. Use MCP for investigation and policy simulation, not synchronous sign-in enforcement.
See the API reference, API notes, MCP notes, plans and Console → Password Risk. The existing GET /api/v1/credentials/range/{prefix} remains a compact broad-tier blocklist lookup; use the new POST endpoint for current attack/breach policy decisions.