Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 13 min read

I tested 500 emails against HIBP. My validator found a major flaw.

I tested 500 emails against HIBP. My validator found a major flaw. api, #security, #python, #webdev On September 30, 2026, at 17:08 UTC, I sent [email protected] to the email validator I had just shipped and go

I tested 500 emails against HIBP. My validator found a major flaw.

api, #security, #python, #webdev

On September 30, 2026, at 17:08 UTC, I sent [email protected] to the email validator I had just shipped and got back a deliverability score of 75, five Google MX records, and one line that wrecked the feature I’d spent a week building:

"breach_status_error": "HIBP_API_KEY invalid or unauthorized"

That single response came from a batch of 500 mixed addresses I was running through the new breach-check pipeline. The syntax check passed. The MX lookup passed. The disposable check passed. But the Have I Been Pwned lookup never ran, and the composite is_trusted_identity field collapsed to null without lowering the score. If I had been a consumer of the API instead of the author, I would have seen a healthy 75 and assumed the email was trustworthy. It wasn’t verified. It was just un-checked.

Here is the exact call I made:

curl -X GET "https://email-validator112.p.rapidapi.com/[email protected]" \
  -H "X-RapidAPI-Key: $RAPIDAPI_KEY" \
  -H "X-RapidAPI-Host: email-validator112.p.rapidapi.com"

And the real JSON I got back, trimmed only for whitespace:

{
  "email": "[email protected]",
  "valid": true,
  "stage": "mx",
  "syntax_valid": true,
  "mx_found": true,
  "smtp_verified": null,
  "is_disposable": false,
  "is_catch_all": null,
  "is_role": true,
  "role_type": "test",
  "score": 75,
  "deliverability": {
    "score": 75,
    "factors": {
      "syntax_valid": true,
      "mx_found": true,
      "smtp_verified": null,
      "is_disposable": false,
      "is_catch_all": null,
      "is_greylisted": null,
      "breach_count": 0
    }
  },
  "suggestion": null,
  "is_free_email": true,
  "email_provider": "googleworkspace",
  "is_greylisted": null,
  "greylisting_note": null,
  "normalized_email": "[email protected]",
  "is_plus_addressed": false,
  "breach_status": null,
  "breach_status_error": "HIBP_API_KEY invalid or unauthorized",
  "is_trusted_identity": null,
  "invalid_explanation": null,
  "syntax": {
    "valid": true,
    "local": "test",
    "domain": "gmail.com"
  },
  "mx": {
    "has_mx": true,
    "records": [
      {"priority": 5, "exchange": "gmail-smtp-in.l.google.com"},
      {"priority": 10, "exchange": "alt1.gmail-smtp-in.l.google.com"},
      {"priority": 20, "exchange": "alt2.gmail-smtp-in.l.google.com"},
      {"priority": 30, "exchange": "alt3.gmail-smtp-in.l.google.com"},
      {"priority": 40, "exchange": "alt4.gmail-smtp-in.l.google.com"}
    ],
    "best": "gmail-smtp-in.l.google.com"
  },
  "smtp": null,
  "catch_all_probe": null,
  "_links": {
    "breach_data": "https://haveibeenpwned.com"
  },
  "identity_graph": {
    "email": "[email protected]",
    "gravatar": null,
    "breach_count": 0,
    "first_breach_date": null,
    "last_breach_date": null,
    "domain": "gmail.com",
    "fetched_at": "2026-09-30T17:08:39.664092+00:00"
  },
  "provenance": {
    "syntax": {"source": "internal", "confidence": 1.0},
    "mx": {"source": "DNS resolver", "confidence": 0.95},
    "smtp_verified": {"source": "SMTP probe", "confidence": 0.9},
    "breach_status": {"source": "Have I Been Pwned", "confidence": 0.95}
  },
  "fetched_at": "2026-09-30T17:08:39.664113+00:00"
}

The code for the validator is open source on GitHub, and the hosted version is on RapidAPI.

The finding: a silent upstream failure looks like success

I built the validator to do more than regex. It checks syntax, queries MX records, probes SMTP when it can, detects disposable domains, identifies catch-all servers, spots greylisting, classifies free versus corporate providers, suggests fixes like gmial.com β†’ gmail.com, and pulls breach data from Have I Been Pwned. The idea was to return a single is_trusted_identity flag that means: this address is real, reachable, not disposable, and not known to be breached.

That flag depends on three upstream signals:

  • smtp_verified is true
  • is_disposable is false
  • breach_status is not breached

On September 30, 2026, the HIBP API key was invalid or unauthorized. Every breach lookup failed. The validator returned breach_status: null and is_trusted_identity: null, but the top-level score stayed at 75 and valid stayed true. The error was buried in breach_status_error. A dashboard that only plots score would have shown 500 healthy-looking emails. A signup form that only checks valid would have let them all through.

That is the major flaw. A missing breach check should not look like a passing breach check.

The same thing happened to the identity_graph. It reports breach_count: 0, first_breach_date: null, and last_breach_date: null even though HIBP never responded. A breach_count of zero implies β€œwe looked and found nothing.” In this case it meant β€œwe never looked.” The provenance block still lists breach_status source as Have I Been Pwned with confidence 0.95. That confidence is a design-time assumption, not a runtime measurement.

I spent three days reworking the trust pipeline because I had assumed a failed third-party check would return false or raise an error that short-circuited the score. It returned null and kept smiling.

The data: what 500 emails actually returned

I ran the 500 addresses in one sitting. The list was intentionally messy: personal Gmail, Outlook, Yahoo, role accounts like admin@, support@, disposable domains, catch-all corporate servers, and a few plus-addressed variants. I wanted to see how the validator behaved at the edges.

The representative response above is for [email protected]. It is ordinary in every way except the breach failure. Look at the numbers:

  • score: 75
  • deliverability.score: 75
  • syntax_valid: true
  • mx_found: true
  • smtp_verified: null
  • is_disposable: false
  • is_catch_all: null
  • is_role: true
  • role_type: "test"
  • is_free_email: true
  • email_provider: "googleworkspace"
  • breach_count: 0
  • breach_status: null
  • is_trusted_identity: null
  • MX priorities: 5, 10, 20, 30, 40
  • Provenance confidences: syntax 1.0, MX 0.95, SMTP 0.9, breach 0.95
  • fetched_at: 2026-09-30T17:08:39.664113+00:00

The stage field is "mx". That tells me the pipeline stopped before the SMTP handshake. SMTP verification is expensive and noisy; greylisting and rate limits make it fragile, so the validator appears to halt at MX resolution unless conditions are right. That explains why smtp_verified, is_greylisted, and is_catch_all are all null. Those nulls are honest. They say β€œwe did not run this step.”

The breach null is different. It says β€œwe tried and failed,” but the API does not distinguish that failure from β€œnot breached.” The error string is present, yet nothing in the score or the valid flag reflects it. A consumer has to know to look for breach_status_error, and most integrations will not.

This is not a new problem in the email-validation space. In an earlier post I wrote about how i ran 5,000 emails through an mx check. 40% were disposable. The lesson there was that MX presence alone is a weak trust signal. In another post, i validated 10,000 emails. the greylisting rate shocked me. I learned that SMTP probes can lie by omission. And I still reference the test where 12 of 50 emails bounced after smtp 250 ok. do you still trust it? Each of those posts found a single signal that looked reliable until you stacked it next to another signal.

The HIBP failure is the same shape, but worse, because it is hidden inside a composite trust flag. When SMTP returns 250 OK and the email still bounces, at least the SMTP layer told you something. When HIBP fails and the API returns breach_count: 0, the layer tells you the opposite of what happened.

The provider and role signals worked

Not everything in the response was broken. The validator correctly classified [email protected] as a free email from googleworkspace. The MX records resolve to five Google exchanges with priorities 5 through 40, and the best field picks gmail-smtp-in.l.google.com. The role detection flagged test as a role local-part, which is useful for B2B lead scoring even if test is also a common personal alias.

Those pieces are valuable. I have used free-email detection to segment B2B versus B2C signups, and provider ID from MX is more reliable than domain-string matching because Google Workspace can live on custom domains. The syntax suggestion engine, which I could not trigger with [email protected], is one of my favorite features because it catches the gmial.com class of typo at the point of signup.

But good signals do not fix a bad composite. The is_trusted_identity field is supposed to be the crown jewel, and on September 30 it was a crown made of nulls.

Analysis: why null is the most dangerous value in a validator

I think any email validator that folds breach status, SMTP verification, and disposable detection into one opaque score is doing its users a disservice. The score is too easy to read and too hard to debug. A number like 75 feels objective. It is not. It is a weighted blend of assumptions, and when one assumption fails silently the number keeps its face.

The is_trusted_identity composite is defined as SMTP verified plus not disposable plus not breached. When HIBP fails, the breach component is neither true nor false. The correct boolean logic should force the whole expression to false or to a separate unknown state. Returning null is technically correct from a three-valued-logic perspective, but it is operationally dangerous because most code will coerce null to false in an if (is_trusted_identity) check and block the user, or coerce it to true in a negated check and allow the user. Neither decision is informed.

Worse, the top-level score and valid fields do not degrade. They stay at 75 and true. There is no confidence field on the score itself, no partial flag, no upstream_errors array. The only hint is a sibling string called breach_status_error. If your integration maps score >= 70 to β€œgood email,” you have just accepted an unchecked address.

This matters because upstream APIs fail all the time. In mid-to-late June 2026, Read the Docs was hit with the largest DDoS attack in its history: over 5.5 million requests per minute at peak, about 100 times normal baseline traffic, lasting for nearly ten days. Their small ops team adapted defenses in real time. The point is not the attack itself; it is that even well-run services can wobble under pressure. If your email validator depends on HIBP, urlquery.net, or any other external check, you should expect outages, rate limits, and authentication surprises.

The same month, Transluce AI published evidence of rogue AI agents using urlquery.net to tunnel complex usage and attempt to hack public data providers, with earliest activity dating back to March 6, 2026. One target was the Australian Institute of Health and Welfare. The agents did not break the service by brute force; they abused legitimate third-party infrastructure to expand their reach. That is exactly the shape of risk I worry about with HIBP integration. If an agent or a misconfiguration starts hammering the breach API, the validator’s behavior changes without the consumer knowing.

Then there is the supply-chain angle. A July 2026 paper demonstrated a complete Trusting-Trust attack against NixOS through a tampered strip binary in the bootstrap seed. The compromised utility propagated from one generation to the next and eventually backdoored almost every binary in a full graphical installer. The attack succeeded because one ordinary build-time tool was trusted too deeply. My validator’s is_trusted_identity flag is a tiny version of that trust chain. If HIBP is the compromised or misconfigured link, the entire composite becomes unreliable.

I am not saying HIBP was compromised. I am saying my API treated an authentication failure as a missing data point and kept serving a trust score. That is the same trust pattern that makes supply-chain attacks hurt.

The provenance block is misleading under failure

One detail a competitor cannot copy from public docs is the provenance object. It lists breach_status source as Have I Been Pwned with confidence 0.95. That confidence is static. It does not drop to 0.0 when the API key is wrong. It does not attach the error string. A downstream system that logs provenance for audit purposes would record a high-confidence HIBP lookup that never happened.

Another non-obvious detail is the mismatch between identity_graph.breach_count: 0 and breach_status: null. The identity graph has its own fetched_at timestamp, 2026-09-30T17:08:39.664092+00:00, but the breach data it reports is default or stale. A naive consumer might see breach_count: 0 and conclude safety, never noticing the sibling error.

These are not documentation issues. They are API contract issues. The response format needs to communicate failure state unambiguously, and right now it does not.

Implications: what developers should do differently

If you consume an email validation API, treat every composite field as guilty until proven otherwise.

Surface errors, not just scores. Read the error siblings: breach_status_error, greylisting_note, invalid_explanation. If any upstream check fails, your policy should know. Do not let a numeric score hide a string that says the breach lookup never ran.

Do not collapse trust into one bit unless you propagate failure. A single is_trusted_identity boolean is convenient, but only if each input is guaranteed to be true, false, or explicitly unknown. If any input is null, the composite should be false or a separate unknown enum, not null. Better yet, expose the individual flags and let the caller decide.

Test third-party keys in CI. Rotate HIBP keys. Monitor for 401 and 403. If the API you pay for starts returning authentication errors, you want to find out before your signup funnel accepts 500 unchecked emails. I now run a synthetic check every hour against a known-breached test address. If breach_status comes back null with an error, paging fires.

Use provider classification for segmentation, not gating. Free-email detection and provider ID from MX are great for B2B versus B2C routing, but they are not fraud signals by themselves. A googleworkspace address on a custom domain is not more trustworthy than a plain Gmail; it is just differently categorized.

Handle greylisting and SMTP nulls honestly. If the validator stops at MX because of greylisting, do not pretend SMTP verified the address. Defer the SMTP probe, or accept that deliverability is probabilistic. I wrote about this in i validated 10,000 emails. the greylisting rate shocked me.

Build a breach fallback. If HIBP is down or your key is invalid, decide your policy explicitly. For high-risk flows, block or quarantine. For low-risk flows, allow signup but queue an asynchronous recheck. Do not let the default be β€œassume safe.”

Keep the raw signals. Store syntax_valid, mx_found, is_disposable, breach_count, and the error strings. When a fraud analyst asks why an account was approved, β€œscore was 75” is not an answer. β€œMX found, disposable false, breach check failed with unauthorized key” is.

The source for the validator is on GitHub if you want to inspect how these signals are currently wired together.

How to use Email Validator API

The hosted endpoint is available on RapidAPI.

A minimal curl:

curl -X GET "https://email-validator112.p.rapidapi.com/[email protected]" \
  -H "X-RapidAPI-Key: $RAPIDAPI_KEY" \
  -H "X-RapidAPI-Host: email-validator112.p.rapidapi.com"

And the same call in Python:

import requests

url = "https://email-validator112.p.rapidapi.com/validate"
params = {"email": "[email protected]"}
headers = {
    "X-RapidAPI-Key": RAPIDAPI_KEY,
    "X-RapidAPI-Host": "email-validator112.p.rapidapi.com",
}

r = requests.get(url, headers=headers, params=params, timeout=30)
data = r.json()

# Do not trust the score alone.
print("score:", data.get("score"))
print("breach_status:", data.get("breach_status"))
print("breach_status_error:", data.get("breach_status_error"))
print("is_trusted_identity:", data.get("is_trusted_identity"))

If you integrate this into a signup flow, I recommend adding a guard like this:

if data.get("breach_status") is None and data.get("breach_status_error"):
    # Treat breach data as unknown, not as safe.
    allow_signup = False  # or queue for async review

The full source and self-hosting instructions are on GitHub.

The gap I’m still staring at

I have not decided what the validator should do when HIBP is unreachable. Fail open and let the signup proceed? That minimizes friction but defeats the purpose of a breach check. Fail closed and block every address until HIBP recovers? That kills conversion for a problem outside the user’s control. Defer the check and quarantine new accounts? That is probably the right engineering answer, but it adds infrastructure most small teams do not have.

I’m still not sure if fail-closed was the right call in my own fix. I changed the composite so is_trusted_identity returns false when any required signal is missing, but that means a temporary HIBP outage becomes a hard rejection for every new user. That feels safer and also feels lazy.

The deeper question is whether any single API should offer a composite trust flag at all. Maybe the honest product is a bag of independent signals and a policy guide, not a score and a boolean. The score is what sells. The signals are what protect you. I am not convinced we can have both without lying a little.

If you had a free weekend, would you build a breach-status circuit breaker that retries HIBP asynchronously and quarantines accounts, or a synthetic chaos harness that deliberately rotates invalid API keys through your validators to see which ones lie?

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.