A disposable-email check has to finish before account creation starts. If the two run in parallel, a rejected address can still leave behind an account or trigger a verification email.
If your signup policy rejects disposable addresses, enforce that decision at the backend operation that creates users.
This tutorial builds a small server-side signup function and tests that boundary, including quota errors and timeouts. It rejects confirmed disposable addresses but continues signup if the check is unavailable. This fail-open policy keeps screening outages from blocking registrations, at the cost of letting disposable addresses through unchecked during those failures.
Disclosure: I work on IronDome, the API used below. You can get a free API key here.
The complete runnable example is on GitHub, including the offline demo, boundary tests, and SDK-contract tests. The compact version below can be run without an API key until you connect the real client.
Install the SDK
Use Node.js 22 or newer and the pinned IronDome Node.js SDK.
Terminal
mkdir signup-check && cd signup-check
npm init -y
npm pkg set type=module
npm install @irondome-dev/sdk@0.2.0
The example accepts one field, email, and preserves the submitted address. Run your existing auth validation for email syntax and other required fields before reaching it. Its coarse input guards are not an email parser, and an unavailable API cannot provide a syntax verdict.
Keep account creation behind the decision
Putting an email check and account creation in the same Promise.all starts both operations without waiting for the classification. Rejecting the address afterward does not undo account creation or recall a verification code already sent. Await the classification before starting either action.
The function takes two dependencies: a client that checks the address, and continueExistingSignup, your existing account-and-verification flow. Passing that flow as a callback lets the handler decide whether it runs at all. The screening code does not implement authentication.
Reject malformed input before the API call, and reject known invalid-email errors. After the check, stop only for a confirmed disposable result. If screening fails or returns no usable classification, continue the existing signup flow once. Do not manufacture a disposable: false result to represent that failure.
Save as signup.js:
import { APIError, ValidationError } from '@irondome-dev/sdk/node';
const response = (status, error, message) => ({
status,
body: { error, message },
});
// keep normal auth validation before this step.
function parseSignup(input) {
if (!input || typeof input !== 'object' || Array.isArray(input) ||
Object.keys(input).some(key => key !== 'email') ||
typeof input.email !== 'string') return null;
const email = input.email;
// reject malformed or oversized payloads.
if (!email || email.length > 1024 || !email.isWellFormed() ||
/[\u0000-\u001f\u007f]/u.test(email) ||
Buffer.byteLength(JSON.stringify({ email }), 'utf8') > 1024) return null;
return Object.freeze({ email });
}
export function createSignupHandler({ client, continueExistingSignup }) {
if (typeof client?.check !== 'function' ||
typeof continueExistingSignup !== 'function') {
throw new TypeError('A screening client and existing signup continuation are required.');
}
return async function signup(input) {
const validated = parseSignup(input);
if (!validated) {
return response(400, 'invalid_signup_input',
'Provide a valid email address.');
}
let result;
try {
// one attempt; no automatic retry.
result = await client.check(validated.email);
} catch (error) {
if (error instanceof ValidationError) {
return response(400, 'invalid_signup_input', 'Provide a valid email address.');
}
if (error instanceof APIError && error.code === 'invalid_email') {
return response(400, 'invalid_email', 'Provide a valid email address.');
}
// continue without a classification if screening fails.
}
if (result?.valid === true && result.disposable === true) {
return response(422, 'email_not_allowed',
'Please use a non-disposable email address.');
}
// keep auth errors outside the screening catch.
return continueExistingSignup(validated);
};
}
The check is awaited before making this choice. Both the allowed and fail-open paths reach the same continuation call outside the screening try/catch. Auth or delivery exceptions therefore propagate once rather than being caught and retried as screening failures.
Test the side effects, not just the response
The test replaces both dependencies: the check returns a chosen result or throws, while the continuation records calls instead of creating accounts or sending mail. This lets each failure happen deterministically, without an API key.
Save as signup.test.js:
import test from 'node:test';
import assert from 'node:assert/strict';
import { APIError, ValidationError, TimeoutError, TransportError } from '@irondome-dev/sdk/node';
import { createSignupHandler } from './signup.js';
const input = { email: 'person@example.test' };
const allowed = { provider: 'custom', valid: true, disposable: false, reason: 'not_listed' };
const blocked = { ...allowed, disposable: true, reason: 'blocked_domain' };
const continuedCalls = ['check', 'createAccount', 'sendOTP'];
const cases = [
['allowed', input, allowed, 202, continuedCalls],
['blocked', input, blocked, 422, ['check']],
['invalid input', { email: 42 }, allowed, 400, []],
['invalid email', input, new APIError(422, 'invalid_email'), 400, ['check']],
['local validation', input, new ValidationError('Offline fixture.'), 400, ['check']],
['quota exhausted', input, new APIError(429, 'monthly_quota_exceeded'), 202, continuedCalls],
['timeout', input, new TimeoutError('Offline fixture.'), 202, continuedCalls],
['network failure', input, new TransportError('Offline fixture.'), 202, continuedCalls],
['service failure', input, new APIError(500, 'internal_error'), 202, continuedCalls],
['unusable response', input, null, 202, continuedCalls],
];
for (const [name, payload, outcome, status, expectedCalls] of cases) {
test(name, async () => {
const calls = [];
const signup = createSignupHandler({
client: {
async check(email) {
assert.equal(email, input.email);
calls.push('check');
if (outcome instanceof Error) throw outcome;
return outcome;
},
},
async continueExistingSignup(validated) {
assert.deepEqual(validated, input);
// record effects without creating accounts or sending mail.
calls.push('createAccount');
calls.push('sendOTP');
return { status: 202, body: { status: 'verification_pending' } };
},
});
assert.equal((await signup(payload)).status, status);
// require the expected call sequence.
assert.deepEqual(calls, expectedCalls);
});
}
Terminal
node --test signup.test.js
All ten tests should pass. The expected case names and summary are:
Expected test output
✔ allowed
✔ blocked
✔ invalid input
✔ invalid email
✔ local validation
✔ quota exhausted
✔ timeout
✔ network failure
✔ service failure
✔ unusable response
ℹ tests 10
ℹ pass 10
ℹ fail 0
A passing blocked test means the handler returned 422 and recorded only check. If account creation had already run, the call assertion would fail. Timeout, quota, service, and unusable-response cases instead expect one check followed by the existing signup flow. Their 202 comes from the stub, not the IronDome API.
IronDome returns HTTP 200 even when an address is classified as disposable, and HTTP 422 for invalid_email. Those are the API response semantics. This application returns 422 for a blocked address and 400 for malformed input, SDK validation errors, or invalid_email. Other screening failures, including quota exhaustion, continue to auth; they do not prove the address is non-disposable.
These are offline fixtures, not claims about the classification of any real email domain. They test application control flow; they do not measure detection accuracy or exercise your auth provider.
Connect the real client at account creation
Enforce the check inside the operation that actually creates users. If your auth provider exposes a separate signup route, a browser precheck or a wrapper around another route will leave that path unguarded. Use its supported pre-create hook or otherwise restrict creation to the guarded backend.
Call wireSignup once with your existing continuation. It returns the function to invoke with { email }; adapt its { status, body } result to your web framework.
Save as server-integration.js:
import { IronDome } from '@irondome-dev/sdk/node';
import { createSignupHandler } from './signup.js';
export const screeningClient = new IronDome({
apiKey: process.env.IRONDOME_API_KEY,
timeoutMs: 1500,
});
// wire once during server startup.
export function wireSignup(continueExistingSignup) {
return createSignupHandler({ client: screeningClient, continueExistingSignup });
}
Keep IRONDOME_API_KEY in server configuration. Reuse the client across requests; call screeningClient.close() only from your server's shutdown lifecycle after active requests drain. The timeout bounds the wait before fail-open signup can proceed. Make one attempt because a timed-out check may already have completed and counted toward usage. Alert on failed checks using aggregate error counts, without recording addresses or credentials.
A valid: true result describes email syntax, not mailbox ownership or deliverability. When screening is unavailable, your auth flow must still validate the address and complete ownership verification. Keep your request-size limits, signup rate limits, and other abuse controls. Before release, test the actual creation path: a blocked result must leave the auth store and verification outbox unchanged, while a screening outage must invoke signup once.
Originally published on IronDome.
Top comments (0)