BreachDirectory API Tutorial: Build a Privacy-Conscious Email Exposure Checker
November 6, 2026 · Updated November 6, 2026 · Suman Lama · 11 min read · BreachDirectory · For developers
Adding breach checks to an application involves more than sending an address to an API. You need to decide what information leaves your system, which provider receives it, how results are interpreted, and what happens when a lookup fails.
This tutorial builds a small local Node.js API that demonstrates those decisions. It accepts one identifier, returns a limited result, and distinguishes a completed no-match response from an unknown outcome.
Important scope: A current authoritative BreachDirectory endpoint, authentication method, and response contract could not be verified during preparation. The runnable implementation therefore uses synthetic fixtures. Its live adapter is deliberately unconfigured. It does not perform real breach checks or claim to be a completed live BreachDirectory integration.
The useful deliverable is a tested application boundary you can connect to an authorized provider only after verifying that provider's current documentation. It avoids presenting an old example or a similarly named marketplace API as a confirmed current contract.
What You Will Build
The request flow is simple: a local HTTP client sends a JSON request to your server; your checker validates the input; a provider adapter supplies a normalized outcome; the server returns only the information the caller needs.
| Application state | Meaning in this tutorial |
|---|---|
match | The adapter reported a match, or the demo fixture simulates one. |
no_match | The adapter reported a documented completed negative lookup, or the fixture simulates one. |
unavailable | A lookup could not be completed or understood. |
invalid | The application's input or request requirements were not met. |
These are our application's labels, not BreachDirectory's schema or official status messages.
The demo accepts only three fixture addresses. It does not accept ordinary addresses and pretend to evaluate them. Every synthetic outcome explicitly says that no real lookup occurred.
Prerequisites and File Layout
Use a supported Node.js release with ES modules and the built-in test runner. The example was checked with Node.js 24.22.1. It requires no third-party packages, database, or API subscription for its synthetic mode.
Create an empty folder with three files:
checker.mjs: validation, synthetic provider behavior, and normalized results.server.mjs: the local HTTP boundary.checker.test.mjs: behavior tests using synthetic inputs and injected provider fixtures.
The .mjs extension lets these files use ES-module syntax without a package manifest. Node documents its built-in test runner and command-line use. Source: Node.js test runner
Step 1: Implement the Checker Boundary
Save the following as checker.mjs:
export async function liveLookup() {
// Add only a verified, authorized provider implementation here.
// Never guess an endpoint, authentication scheme, or result schema.
throw new Error('LIVE_PROVIDER_NOT_CONFIGURED');
}
export function createChecker({ mode = 'demo', lookup = liveLookup } = {}) {
if (!['demo', 'live'].includes(mode)) throw new Error('Invalid mode');
return async function check(body) {
const reply = (httpStatus, state, message) => ({
httpStatus,
body: { mode, state, message }
});
if (!body || typeof body !== 'object' || Array.isArray(body) ||
Object.keys(body).some(key => key !== 'email') ||
typeof body.email !== 'string') {
return reply(400, 'invalid', 'Send one email field only.');
}
const email = body.email.trim();
// A basic demonstration check, not a full email-address validator.
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return reply(400, 'invalid', 'The email input is not accepted.');
}
if (mode === 'demo') {
const fixtures = new Map([
['match@checker.invalid', 'match'],
['none@checker.invalid', 'no_match'],
['error@checker.invalid', 'unavailable']
]);
const state = fixtures.get(email);
if (!state) {
return reply(400, 'invalid', 'Demo accepts the three fixture addresses only.');
}
if (state === 'unavailable') {
return reply(503, state, 'Synthetic failure; no real lookup occurred.');
}
return reply(200, state, 'Synthetic result; no real lookup occurred.');
}
try {
// lookup must return OUR normalized contract, not raw provider JSON.
const result = await lookup(email);
if (!result || !['match', 'no_match'].includes(result.state)) {
throw new Error('UNRECOGNIZED_PROVIDER_RESULT');
}
return reply(200, result.state,
result.state === 'match'
? 'A match was returned; review account security.'
: 'No match was returned by this provider; this is not a safety guarantee.');
} catch {
// Never echo exception messages, request URLs, or raw provider records.
return reply(503, 'unavailable', 'Lookup unavailable; exposure status is unknown.');
}
};
}The input check is deliberately modest: it catches obvious malformed input but is not a complete email-address validator or proof of address ownership. Trimming whitespace also does not establish that two aliases are equivalent, so the code does not silently merge aliases.
The more important boundary is the adapter contract. A provider implementation must return our normalized match or no_match state. An unexpected object is not silently converted to a negative result.
Notice that raw provider fields never become the public response. Even if an injected result contains extra information, the checker constructs a new small response instead of forwarding the object.
This design reduces what the application returns. It does not prove that an eventual live provider collects or returns no sensitive information internally.
Step 2: Add the Local HTTP Server
Save this as server.mjs:
import { createServer } from 'node:http';
import { createChecker } from './checker.mjs';
const check = createChecker({ mode: process.env.CHECKER_MODE ?? 'demo' });
const server = createServer(async (req, res) => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Content-Type-Options', 'nosniff');
const send = (status, value) => {
res.writeHead(status);
res.end(JSON.stringify(value));
};
if (req.url !== '/api/check') {
return send(404, { state: 'invalid', message: 'Unknown route.' });
}
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
return send(405, { state: 'invalid', message: 'Use POST.' });
}
if (req.headers['content-type']?.split(';')[0].trim().toLowerCase()
!== 'application/json') {
return send(415, { state: 'invalid', message: 'Use application/json.' });
}
let size = 0;
const chunks = [];
try {
for await (const chunk of req) {
size += chunk.length;
if (size > 1024) {
res.setHeader('Connection', 'close');
return send(413, { state: 'invalid', message: 'Request too large.' });
}
chunks.push(chunk);
}
} catch {
return send(400, { state: 'invalid', message: 'Request could not be read.' });
}
let body;
try {
body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
} catch {
return send(400, { state: 'invalid', message: 'Invalid JSON.' });
}
const result = await check(body);
send(result.httpStatus, result.body);
});
server.requestTimeout = 10000;
server.headersTimeout = 10000;
server.listen(3000, '127.0.0.1', () => {
console.log('Local tutorial server: http://127.0.0.1:3000');
});The server binds to the loopback address, accepts one POST route, limits the request body, and does not reflect the identifier in its response. It also does not create application request logs or store the submitted body.
Cache-Control: no-store instructs compliant caches not to store the response. It is useful, but it does not disable platform logging, erase memory, or control what a caller copies. Source: MDN Cache-Control reference
OWASP's logging guidance describes why sensitive information, including credentials and some personal data, needs careful handling in logs. In a deployed application, review access logs, tracing, error reporting, and request-body capture separately. Source: OWASP Logging Cheat Sheet
This loopback server is a local teaching example. It has no production authentication, shared rate limiter, or internet-facing deployment configuration. Do not publish it as an unrestricted real-identifier lookup service.
Step 3: Run Three Synthetic Lookups
Start the local server:
node server.mjsIn another terminal, send a synthetic match request:
curl -i http://127.0.0.1:3000/api/check \
-H 'Content-Type: application/json' \
--data '{"email":"match@checker.invalid"}'The response includes:
{
"mode": "demo",
"state": "match",
"message": "Synthetic result; no real lookup occurred."
}Use none@checker.invalid to simulate a completed negative result and error@checker.invalid to simulate unavailability. An ordinary address is rejected in demo mode.
These addresses are fixture labels, not live targets. Nothing is sent to BreachDirectory, and the response is not evidence about a real account.
The POST body keeps the identifier out of this application's query string. That does not make a live request anonymous or establish that upstream APIs accept POST bodies. The actual provider's documented request method must be evaluated separately.
Step 4: Test the Important Failure Boundaries
Save this as checker.test.mjs:
import test from 'node:test';
import assert from 'node:assert/strict';
import { createChecker } from './checker.mjs';
const demo = createChecker();
test('demo distinguishes synthetic match and no-match', async () => {
assert.equal((await demo({ email: 'match@checker.invalid' })).body.state, 'match');
assert.equal((await demo({ email: 'none@checker.invalid' })).body.state, 'no_match');
});
test('demo failure is not a negative lookup', async () => {
const r = await demo({ email: 'error@checker.invalid' });
assert.equal(r.httpStatus, 503);
assert.equal(r.body.state, 'unavailable');
});
test('demo rejects an ordinary address', async () => {
assert.equal((await demo({ email: 'person@example.com' })).httpStatus, 400);
});
test('extra fields are rejected', async () => {
assert.equal((await demo({ email: 'match@checker.invalid', password: 'example' })).httpStatus, 400);
});
test('live is unconfigured by default and fails closed', async () => {
const r = await createChecker({ mode: 'live' })({ email: 'person@example.com' });
assert.equal(r.body.state, 'unavailable');
});
test('unexpected provider schema never becomes no-match', async () => {
const run = createChecker({ mode: 'live', lookup: async () => ({ success: true }) });
assert.equal((await run({ email: 'person@example.com' })).body.state, 'unavailable');
});
test('raw records are not forwarded', async () => {
const run = createChecker({ mode: 'live', lookup: async () => ({
state: 'match', password: 'sensitive-fixture', email: 'person@example.com'
}) });
const r = await run({ email: 'person@example.com' });
assert.equal(r.body.state, 'match');
assert.equal(JSON.stringify(r).includes('sensitive-fixture'), false);
assert.equal(JSON.stringify(r).includes('person@example.com'), false);
});Run the tests:
node --test checker.test.mjsAll seven tests passed during preparation. The local server was also checked over HTTP for synthetic match, no-match, unavailability, ordinary-address rejection, and its no-store response header. These checks exercised only local fixtures; they did not validate a live provider.
The key regression test concerns an unknown response. If a provider changes its schema or returns an unexpected object, the application must not reassure the caller with a false negative.
Step 5: Verify the Provider Before Implementing Live Mode
The liveLookup function currently throws. Setting CHECKER_MODE=live does not magically enable a BreachDirectory connection: valid input will receive an unavailable outcome until the function is deliberately implemented.
Before replacing it, obtain current documentation for the exact provider you are authorized to use. Confirm:
- Operator, hostname, endpoint, method, and required authentication.
- Supported identifier types and permitted uses.
- Successful-match and completed-no-match response definitions.
- Error, rate-limit, pagination, and subscription behavior.
- What information the provider receives, returns, retains, or shares.
- Whether your intended individual or organizational workflow is permitted.
Do not use a similarly named marketplace listing as automatic proof that it is the same service. Do not invent a route from an old repository or assume that a result field has retained its historical meaning.
Document that evidence alongside your adapter implementation so the next developer knows which contract the code follows.
Step 6: Implement a Narrow Server-Side Adapter
Once verified, the adapter should accept the authorized identifier, make the documented request, validate the response, and map it into our small contract. It should not expose arbitrary provider URLs to the caller.
Keep any required API key in server-side secret configuration. Never add it to client code, a browser-visible environment variable, example screenshots, or a committed configuration file.
Use a fixed allowlisted provider destination, HTTPS, an appropriate timeout, and redirect handling consistent with your trust boundary. Check the actual HTTP status, content type, application-level error, and result schema before returning match or no_match.
Node documents AbortSignal.timeout() for limiting supported asynchronous operations such as fetch requests. A timeout should become an unavailable outcome, not a negative lookup. Source: Node.js globals documentation
Handle retryable failures with bounded backoff and provider guidance. Do not repeat invalid credentials or evade quotas by rotating identities. Add a response-size limit appropriate to the real contract rather than accepting an unlimited upstream body.
The article does not supply this live adapter because the provider contract remains unverified. That missing integration is explicit, not hidden behind invented sample JSON.
Step 7: Define Who May Search Before Production
A real exposure-checking service needs a clear authorization model. A checkbox saying “this is my address” is not technical verification that the caller controls it.
Choose a documented approach appropriate to the product: verified individual ownership, an authenticated security-team workflow, or authorized domain monitoring. Avoid exposing detailed personal records to anonymous callers.
Add deployment-appropriate rate limiting, request-size enforcement at the hosting edge, abuse controls, and monitoring that does not unnecessarily capture identifiers. Review origin/CSRF requirements if browser sessions or cookies become part of the design.
If you add a browser form, send requests to your own authorized server route and keep result state in memory. Do not store identifiers or records in browser storage simply for convenience.
These requirements are not implemented by the local demonstration server. Complete them before accepting real identifiers in a deployed product.
Keep User Messages Honest
A live match can say that the provider returned an exposure match and suggest reviewing account security. A completed no-match result should name its limited scope. An unavailable lookup should clearly say the status is unknown.
Do not display “account safe,” “all clear everywhere,” or a green certification badge merely because one provider returned no match.
Also avoid returning passwords, hashes, full breach records, or unnecessary source details when a limited state answers the legitimate task. Data minimization concerns both the user response and the information processed inside your system.
Final Thoughts
A privacy-conscious breach checker begins with a trustworthy application boundary and a verified provider contract. The local example demonstrates input checks, synthetic behavior, limited outputs, and failure states you can test without submitting real identifiers.
A live BreachDirectory integration remains a separate implementation step requiring current documentation, authorized access, and deployment controls. Keep that distinction visible throughout development and publishing.
Explore our BreachDirectory learning hub and BreachDirectory API overview for related context.
Editorial scope: Code was tested locally with synthetic data, not against a live BreachDirectory API. The live provider adapter is intentionally unconfigured. This is a teaching example, not a complete production service or an audit of any provider. ACyberSec is not affiliated with BreachDirectory.
Related articles

