Checking emails in an Auth0 pre-user-registration Action
· 6 min read · Tutorials
To check signup emails in Auth0, create an Action on the pre-user-registration trigger, read event.user.email, call an email-classification API with a short timeout, and call api.access.deny(reason, userMessage) when the address should be rejected. The Action below rejects disposable and machine-generated addresses, tags low-trust ones in app_metadata, backs off on HTTP 429 using api.cache, and lets signups through if the check can't run.
What Auth0 gives you in this trigger
From the pre-user-registration trigger docs and its event and API object references:
- The trigger runs when a user registers through a Database or Passwordless connection. Social signups skip it.
- Actions in this flow are blocking: registration waits until your code finishes.
event.user.emailis the address being registered (optional, so handle it missing).event.secretsholds secrets you define on the Action.event.request.ipandevent.connection.nameare there if you want them in logs.api.access.deny(reason, userMessage)stops registration.reasongoes to tenant logs;userMessagemay be shown to the user. No later Actions run.api.user.setAppMetadata(key, value)sets app metadata on the user being created. Auth0 notes this doesn't currently work for passwordless users in this trigger.api.cache.get(key)/api.cache.set(key, value, { ttl })give you a small cache that persists across executions, with a 15-minute default lifetime.
The Actions limitations page caps each trigger execution at 20 seconds. Your outbound call needs a much smaller budget than that, because the user is watching a spinner.
The decision rule
The Action calls pingutil's /v1/lookup with skip_enrichment: true. That skips company enrichment and returns the domain category, heuristic email_signals and a verdict quickly.
| API result | Action does |
|---|---|
verdict.is_spam: true, reason: "disposable_domain" |
Deny with a "use a permanent address" message |
verdict.is_spam: true, any other reason |
Deny with a generic message |
verdict.low_trust: true |
Allow, store verdict.reason in app_metadata |
| Clean | Allow |
HTTP 400 invalid_input |
Deny as an invalid address |
| Timeout, network error, 429, 5xx | Allow and mark for re-check (fail open) |
is_spam covers domains on the disposable list, reputation-flagged domains and randomized local parts such as ccjxx90sze@. low_trust covers role, system and sales mailboxes like info@ or noreply@. We don't recommend denying those at registration; plenty of small companies sign up with a shared inbox. Role-based email addresses covers when to treat them differently.
Step 1: create the Action and its secret
- In the Auth0 dashboard go to Actions > Library, create a custom Action, and pick the pre-user-registration trigger with the latest Node runtime offered. Node 18 and later have a global
fetch, so no npm dependency is needed. - In the Action editor's Secrets panel, add
PINGUTIL_KEYwith your API key (it starts withpk_live_). Optionally addFAIL_MODEwith valueopenorclosed.
Secrets are read from event.secrets, never hard-coded.
Step 2: the Action code
/**
* Handler for the pre-user-registration trigger.
* @param {Event} event
* @param {PreUserRegistrationAPI} api
*/
exports.onExecutePreUserRegistration = async (event, api) => {
const email = (event.user.email || '').trim().toLowerCase();
if (!email) return; // e.g. a passwordless SMS signup
const result = await screenEmail(email, event, api);
if (result.kind === 'invalid') {
api.access.deny('invalid_email', 'Please enter a valid email address.');
return;
}
if (result.kind === 'checked') {
const { verdict } = result;
if (verdict.is_spam) {
api.access.deny(
`email_rejected:${verdict.reason}`,
verdict.reason === 'disposable_domain'
? 'Temporary email addresses are not allowed. Please use a permanent address.'
: 'This email address cannot be used to sign up.'
);
return;
}
if (verdict.low_trust) {
api.user.setAppMetadata('email_low_trust_reason', verdict.reason);
}
return;
}
// The check could not run: timeout, network error, rate limit, server error.
console.log(`email check unavailable (${result.why}) for connection ${event.connection.name}`);
if (event.secrets.FAIL_MODE === 'closed') {
api.access.deny(
`email_check_unavailable:${result.why}`,
'Sign-ups are briefly unavailable. Please try again in a minute.'
);
return;
}
api.user.setAppMetadata('email_check_pending', true);
};
const LOOKUP_TIMEOUT_MS = 3000;
const BACKOFF_KEY = 'pingutil_backoff_until';
async function screenEmail(email, event, api) {
// Shared backoff across executions, so a 429 isn't followed by more requests.
const cached = api.cache.get(BACKOFF_KEY);
if (cached && Number(cached.value) > Date.now()) {
return { kind: 'unavailable', why: 'rate_limited' };
}
let res;
try {
res = await fetch('https://pingutil.com/v1/lookup', {
method: 'POST',
headers: {
authorization: `Bearer ${event.secrets.PINGUTIL_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({ input: email, skip_enrichment: true }),
signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS),
});
} catch (err) {
return { kind: 'unavailable', why: err && err.name === 'TimeoutError' ? 'timeout' : 'network' };
}
if (res.status === 429) {
const body = await res.json().catch(() => ({}));
const seconds = Number(body.retry_after_seconds) || 60;
const until = Date.now() + seconds * 1000;
api.cache.set(BACKOFF_KEY, String(until), { ttl: seconds * 1000 });
return { kind: 'unavailable', why: 'rate_limited' };
}
if (res.status === 400) return { kind: 'invalid' };
if (res.status === 401) {
console.log('pingutil rejected the API key: check the PINGUTIL_KEY secret');
return { kind: 'unavailable', why: 'auth' };
}
if (!res.ok) return { kind: 'unavailable', why: `http_${res.status}` };
const data = await res.json();
return { kind: 'checked', category: data.category, verdict: data.verdict };
}
The exact signatures come from the pre-user-registration API object reference: deny takes the log reason first and the user message second. Swapping them is an easy mistake and puts your internal reason code on the signup screen.
Step 3: test, deploy and attach
- Use the editor's Test panel. Edit the sample event so
user.emailistest@mailinator.comand run it; the output should show anaccess.denycommand. Run it again with a normal address and confirm no deny. - Click Deploy.
- Open Actions > Triggers, select pre-user-registration, drag your Action into the flow and Apply. A deployed Action does nothing until it's in the flow.
Then do one real signup through Universal Login with a throwaway domain. The user-facing message should be the userMessage you passed.
Why the failure handling looks like this
Timeout: 3 seconds
The platform allows 20 seconds, but that's a hard ceiling, not a target. In skip_enrichment mode there's no LLM call and the lookup usually returns far faster than 3 seconds. If it doesn't, waiting longer only makes the signup worse. AbortSignal.timeout rejects with an error named TimeoutError, which the code reports separately from other network failures so you can tell them apart in logs.
429: honour retry_after_seconds without sleeping
pingutil allows 60 requests per minute per key. Over that, it returns { "error": "rate_limited", "retry_after_seconds": 60 }. Sleeping inside the Action would hold the user for a minute. Instead the Action writes the backoff deadline to api.cache with a matching TTL, and later executions skip the API until it expires. That's the right reading of retry_after_seconds in a synchronous flow: don't call again before then, and don't make the user wait either.
api.cache is a convenience, not a distributed lock, so a few requests may still go out around the edge of the window. That's fine; they just get another 429.
Fail open, and mark the user
When the check can't run, the Action allows registration and sets app_metadata.email_check_pending = true. A scheduled job can list those users through the Management API, re-run the lookup, clear the flag, and block anyone who comes back as is_spam.
Why fail open by default: an outage or rate-limit window on a third-party API should not stop real people from creating accounts, and a handful of throwaway accounts can be removed afterwards. Set FAIL_MODE to closed when a fresh account is immediately worth something to an attacker, for example signup credits or a free usage allowance. The trade-offs are covered more broadly in stopping free-trial abuse.
One gap: Auth0 notes that metadata can't currently be set on passwordless users in this trigger. For passwordless connections the email_check_pending flag won't stick, so rely on the log line, or run that connection in fail-closed mode.
Checklist
PINGUTIL_KEYis an Action secret, not in code.- The fetch has a timeout far below the 20-second limit.
api.access.denyarguments are in the right order: reason, then user message.- A 429 writes a backoff to
api.cacheand skips the API until it expires. - Unchecked users are marked and re-checked later.
- Social connections are covered elsewhere, because this trigger doesn't run for them.
The same pattern for Supabase is in blocking disposable emails in Supabase Auth. For the full response schema, see the API quickstart and the API reference.
Questions people ask
- Does the Auth0 pre-user-registration trigger run for social logins?
- No. Auth0's documentation says it runs when a user registers through a Database or Passwordless connection. Users signing up with a social connection don't pass through it.
- What is the difference between api.access.deny and api.validation.error in Auth0?
- Both stop the registration and prevent later Actions from running. api.access.deny(reason, userMessage) takes an internal reason for tenant logs and a message for the user; api.validation.error(errorCode, errorMessage) takes an error code and message. Use deny for policy rejections like disposable emails.
- How long can an Auth0 Action take?
- Auth0's Actions limitations page says each trigger execution must finish within 20 seconds or it ends in an error. Any outbound HTTP call needs its own, much shorter timeout.