Check breached emails at signup without running HaveIBeenPwned yourself
A returning user types their favorite password into your signup form. That password leaked in the 2024 AT&T dump, the 2023 23andMe breach, and a 2022 LastPass vault extraction. You store the hash, congratulate yourself on bcrypt, and three weeks later a credential-stuffing bot walks into the account from a residential proxy in Brazil.
The fix is cheap. One API call at signup tells you whether the email shows up in known breach corpora. Use that signal to force MFA, require a stronger password, or drop the signup into a review queue. This post shows the endpoint, a Next.js route handler, and a React Hook Form validator that adds about 40ms to your signup latency.
Why breach-check at signup, not later
Credential stuffing works because 65% of users reuse passwords across sites (Google/Harris 2019, replicated in the 2023 Bitwarden survey). An email tied to 14 prior breaches is not a random sample of the internet; it's a user who has handed the same password to 14 different leaky vendors. The marginal probability that they typed the same one into your form is high.
Checking at signup costs you one round-trip. Checking after a takeover costs you a support ticket, a refund, a trust-and-safety review, and often a GDPR breach notification. You want the signal before the password lands in your user table, not after.
One API call, two signals
POST /v1/breach/check accepts either an email or a password. Here's the email call.
curl -X POST https://api.botoi.com/v1/breach/check \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${BOTOI_API_KEY}" \
-d '{"email":"jane@example.com"}' And a sample response for an address that shows up in three corpora.
{
"found": true,
"breach_count": 3,
"breaches": [
{
"name": "LinkedIn",
"date": "2012-05-05",
"pwned_count": 164611595,
"data_classes": ["Emails", "Passwords"]
},
{
"name": "Adobe",
"date": "2013-10-04",
"pwned_count": 152445165,
"data_classes": ["Emails", "Passwords", "Usernames"]
},
{
"name": "Collection #1",
"date": "2019-01-07",
"pwned_count": 772904991,
"data_classes": ["Emails", "Passwords"]
}
],
"password_exposed": false
}
Two things worth calling out. breach_count and the breaches array give you dataset names, dates, and total record counts; they do not return the exposed password, the full leaked record, or any PII beyond what you sent in. password_exposed is only populated when you pass a password field in the request body. For signup, the email alone is enough to tier risk.
HaveIBeenPwned's k-anonymity API is free for personal use and excellent for password prefix lookups. At production scale you pay $3.50/month per key and maintain SHA-1 prefix handling plus client-side rate limits. The botoi endpoint wraps that plumbing and adds edge caching, unified auth with the rest of the API, and a single bill.
Next.js route handler
Put the call behind your own route so the API key stays on the server. Validate input with Zod, add a 300ms AbortController timeout, and fail open on upstream errors so a slow breach-check never blocks a real signup.
// app/api/auth/breach-check/route.ts
import { NextResponse } from 'next/server';
import { z } from 'zod';
const inputSchema = z.object({
email: z.string().email(),
});
type Risk = 'low' | 'medium' | 'high';
function tier(breachCount: number): { risk: Risk; require_mfa: boolean } {
if (breachCount === 0) return { risk: 'low', require_mfa: false };
if (breachCount <= 3) return { risk: 'medium', require_mfa: true };
return { risk: 'high', require_mfa: true };
}
export async function POST(req: Request) {
const parsed = inputSchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: 'invalid_email' }, { status: 400 });
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 300);
try {
const res = await fetch('https://api.botoi.com/v1/breach/check', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.BOTOI_API_KEY}`,
},
body: JSON.stringify({ email: parsed.data.email }),
signal: controller.signal,
});
if (!res.ok) {
// fail open: do not block signup on upstream errors
return NextResponse.json({ risk: 'low', breach_count: 0, require_mfa: false });
}
const data = (await res.json()) as {
found: boolean;
breach_count: number;
};
const verdict = tier(data.breach_count);
return NextResponse.json({ ...verdict, breach_count: data.breach_count });
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
return NextResponse.json({ risk: 'low', breach_count: 0, require_mfa: false });
}
return NextResponse.json({ error: 'upstream_error' }, { status: 502 });
} finally {
clearTimeout(timeout);
}
}
A few design choices worth naming. The timeout is tight (300ms) because the P95 response is 120ms and anything slower is a transient; the fail-open branch returns risk: 'low' so a flaky third party can't take your signup form down. The tier() function is the only place risk policy lives, which makes it one diff to retune later.
Client-side React Hook Form validator
Debounce by 500ms so you don't fire a request on every keystroke. Don't block the submit button on the result; show a warning and pre-toggle MFA instead. Warnings convert; hard blocks lose you the user.
// hooks/use-breach-check.ts
import { useEffect, useState } from 'react';
type BreachResult = {
risk: 'low' | 'medium' | 'high';
breach_count: number;
require_mfa: boolean;
};
export function useBreachCheck(email: string) {
const [result, setResult] = useState<BreachResult | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setResult(null);
return;
}
const controller = new AbortController();
const handle = setTimeout(async () => {
setLoading(true);
try {
const res = await fetch('/api/auth/breach-check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
signal: controller.signal,
});
if (res.ok) setResult(await res.json());
} catch {
// ignore; treat as no signal
} finally {
setLoading(false);
}
}, 500);
return () => {
clearTimeout(handle);
controller.abort();
};
}, [email]);
return { result, loading };
}
// Banner usage inside a form:
// {result?.risk === 'high' && (
// <div className="rounded-md bg-amber-50 p-3 text-amber-900">
// This email shows up in {result.breach_count} known breaches. We'll require MFA on your account.
// </div>
// )}
Wire the hook into a React Hook Form component. The watch call feeds the email into the hook, and the result pre-checks the MFA box for breached addresses.
// components/signup-form.tsx
'use client';
import { useForm } from 'react-hook-form';
import { useBreachCheck } from '@/hooks/use-breach-check';
type FormValues = {
email: string;
password: string;
enableMfa: boolean;
};
export function SignupForm() {
const { register, handleSubmit, watch, setValue } = useForm<FormValues>({
defaultValues: { email: '', password: '', enableMfa: false },
});
const email = watch('email');
const { result } = useBreachCheck(email);
// auto-toggle MFA when the breach check flags the address
if (result?.require_mfa && !watch('enableMfa')) {
setValue('enableMfa', true);
}
const onSubmit = async (values: FormValues) => {
await fetch('/api/auth/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...values, breach_risk: result?.risk ?? 'low' }),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<input
type="email"
{...register('email', { required: true })}
className="w-full rounded-md border px-3 py-2"
placeholder="you@company.com"
/>
{result && result.risk !== 'low' && (
<div className="rounded-md bg-amber-50 p-3 text-sm text-amber-900">
This email appears in {result.breach_count} known breaches. We recommend a new password and MFA.
</div>
)}
<input
type="password"
{...register('password', { required: true, minLength: 12 })}
className="w-full rounded-md border px-3 py-2"
placeholder="At least 12 characters"
/>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" {...register('enableMfa')} />
Enable MFA (required if your email was exposed)
</label>
<button type="submit" className="rounded-md bg-black px-4 py-2 text-white">
Create account
</button>
</form>
);
} What to do with the result
Translate the breach count into a policy. Start with this table and tune once you have two weeks of signups behind the scoring.
| Breach count | Action | UX | Why |
|---|---|---|---|
| 0 | Proceed | No change | No public evidence of prior compromise; default path. |
| 1-3 | Prompt MFA | Pre-check the MFA box, show a soft warning | One reused password per breach is common; nudge the user to protect the new account. |
| 4-10 | Force MFA + strong password | Require TOTP enrollment before first login, min 14 chars | Repeated exposure suggests the email is in every major credential list; a weak password here is a takeover waiting to happen. |
| 10+ | Soft-block with friction | Add a captcha or send to manual review | This email is effectively public. Increase the attacker's cost before you create an account on it. |
Stack it with two more checks for under 120ms
Breach-check alone catches the reused-password risk. Pair it with /v1/disposable-email/check to flag tempmail domains and /v1/vpn-detect to flag datacenter or Tor source IPs, and you have a three-signal scorecard that runs in parallel under 120ms at the P95. The full scoring pattern (weights, thresholds, audit logging) lives in Stop signup fraud with 3 API checks.
Grab a free API key at botoi.com/api/signup. Free tier covers 1,000 breach checks per day (5 req/min burst), enough for a small SaaS or to replay your last 30 days of signups through the endpoint and calibrate the tiers above against churn and abuse data.
Full endpoint reference: Breach Check API.
Frequently asked questions
- Does this leak email addresses to botoi?
- The email hits the API over TLS and is used only to compute the breach match. Request bodies are not persisted in access logs; only method, path, status, and latency are retained for 30 days. See the API docs for the full data-handling statement.
- What's the typical latency?
- Median response time is 40ms from a Cloudflare Workers edge node, 120ms at the 95th percentile. The handler in this post sets a 300ms AbortController timeout so a slow call cannot block your signup form.
- How fresh is the breach dataset?
- The dataset syncs weekly from public dumps (HIBP-tracked breaches, credential stuffing lists, and Collection #1 through #5). New corpora show up within 7 days of public disclosure.
- Can I check passwords directly?
- Yes. POST a SHA-1 prefix (first 5 hex chars) or the plaintext password to the same endpoint. The prefix form is safer for client-side callers because the full hash never leaves the browser. Use plaintext only from your server.
- Is this compliant with GDPR?
- Breach-checking is a security measure under GDPR Article 32 (security of processing). Document the check in your Records of Processing Activities, list botoi as a processor in your DPA, and note the lawful basis (legitimate interest in preventing account takeover).
Try this API
Breach Check API — interactive playground and code examples
More tutorial posts
Start building with botoi
150+ API endpoints for lookup, text processing, image generation, and developer utilities. Free tier, no credit card.