Home / Integrations / Supabase
Validate Emails Before supabase.auth.signUp
Published 2026-10-18
This guide sets up a Supabase Edge Function that checks an email with the ContactLint API, and a signup handler that calls it before supabase.auth.signUp. Your secret key stays in Supabase secrets, and people see "Did you mean ...?" before their account is created. If ContactLint is slow or unreachable, the signup goes ahead.
Where the check goes in Supabase
Your ContactLint secret key must never be in browser code, so the check runs in an Edge Function. The browser calls the function with the address, the function calls POST https://contactlint.com/v1/email/validate, and the browser only calls supabase.auth.signUp if the answer is ok.
Be clear about one limit: the browser decides whether to call signUp, so someone calling the Supabase Auth API directly can skip this check. It stops real people with typos and throwaway addresses. It is not a hard gate. If you need one, check the Supabase Auth docs for a hook that runs before a user is created, and whether your plan includes it.
Create the Edge Function
- Get a free secret key at /signup and store it as a Supabase secret:
supabase secrets set CONTACTLINT_KEY=your_secret_key - Create the function with
supabase functions new check-emailand replacesupabase/functions/check-email/index.tswith:const corsHeaders = { 'Access-Control-Allow-Origin': '*', // set this to your site's origin in production 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Access-Control-Allow-Methods': 'POST, OPTIONS', }; function reply(body: Record<string, unknown>) { return new Response(JSON.stringify(body), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }); } Deno.serve(async (req) => { if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders }); let email = ''; let typoOk = false; try { const body = await req.json(); if (typeof body?.email === 'string') email = body.email.trim(); typoOk = body?.typo_ok === true; } catch { // no JSON body } if (!email) return reply({ ok: false, message: 'Enter an email address.' }); try { const res = await fetch('https://contactlint.com/v1/email/validate', { method: 'POST', headers: { Authorization: 'Bearer ' + (Deno.env.get('CONTACTLINT_KEY') ?? ''), 'Content-Type': 'application/json', }, body: JSON.stringify({ email }), signal: AbortSignal.timeout(3000), }); if (!res.ok) { console.warn('ContactLint returned HTTP ' + res.status + ', accepting email'); return reply({ ok: true }); // 401, 402, 429 or other error: fail open } const { result } = await res.json(); if (result.verdict === 'invalid') { return reply({ ok: false, message: 'That email address cannot receive mail.' }); } // Check the suggestion before disposable: gmial.com is both. if (result.did_you_mean && !typoOk) { return reply({ ok: false, suggestion: result.did_you_mean }); } if (result.disposable) { return reply({ ok: false, message: 'Please use a permanent email address.' }); } return reply({ ok: true }); } catch (err) { console.warn('ContactLint unreachable, accepting email', err); return reply({ ok: true }); // timeout or network error: fail open } }); - Deploy it:
supabase functions deploy check-email
The function always answers with HTTP 200 and an ok field, so the client never confuses a ContactLint verdict with a function error.
Call it before signUp and show the result
In the browser, use your Supabase project URL and anon key as usual. The anon key is meant to be public. The ContactLint key is not, and stays in the function.
import { createClient } from '@supabase/supabase-js';
const supabase = createClient('https://YOUR_PROJECT_REF.supabase.co', 'YOUR_ANON_KEY');
async function checkEmail(email, typoOk) {
try {
const call = supabase.functions.invoke('check-email', { body: { email, typo_ok: typoOk } });
// The function waits up to 3 seconds for ContactLint. Allow a little extra for the function itself.
const timeout = new Promise((resolve) => setTimeout(() => resolve({ data: null, error: 'timeout' }), 5000));
const { data, error } = await Promise.race([call, timeout]);
if (error || !data) return { ok: true }; // fail open
return data;
} catch {
return { ok: true }; // fail open
}
}
const form = document.querySelector('#signup-form');
const hint = document.querySelector('#email-hint');
let typoOk = false;
form.email.addEventListener('input', () => { typoOk = false; });
form.addEventListener('submit', async (event) => {
event.preventDefault();
const email = form.email.value.trim();
hint.textContent = '';
const check = await checkEmail(email, typoOk);
if (!check.ok && check.suggestion) {
const use = document.createElement('button');
use.type = 'button';
use.textContent = check.suggestion;
use.onclick = () => { form.email.value = check.suggestion; hint.textContent = ''; };
const keep = document.createElement('button');
keep.type = 'button';
keep.textContent = 'Keep mine';
keep.onclick = () => { typoOk = true; form.requestSubmit(); };
hint.append('Did you mean ', use, '? ', keep);
return;
}
if (!check.ok) {
hint.textContent = check.message;
return;
}
const { error } = await supabase.auth.signUp({ email, password: form.password.value });
hint.textContent = error ? error.message : 'Account created.';
});- invalid: blocked with "That email address cannot receive mail."
- risky with
did_you_mean: one click fixes the address, or "Keep mine" sends it again withtypo_ok. A typo domain that is also disposable, such as gmial.com, is still blocked as disposable. - risky with reason
disposable: blocked. Remove that branch if throwaway addresses are fine for you. - risky with reason
role_address, valid and unknown: signup continues.
Test it
Call the deployed function with curl. Each call uses one check.
curl -X POST 'https://YOUR_PROJECT_REF.supabase.co/functions/v1/check-email' \
-H 'Authorization: Bearer YOUR_ANON_KEY' \
-H 'Content-Type: application/json' \
-d '{"email":"jane@gmial.com"}'
# {"ok":false,"suggestion":"jane@gmail.com"}
curl -X POST 'https://YOUR_PROJECT_REF.supabase.co/functions/v1/check-email' \
-H 'Authorization: Bearer YOUR_ANON_KEY' \
-H 'Content-Type: application/json' \
-d '{"email":"x@mailinator.com"}'
# {"ok":false,"message":"Please use a permanent email address."}To confirm it fails open, set CONTACTLINT_KEY to a wrong value and call it again. ContactLint answers 401, the function logs a warning and returns {"ok":true}. For local runs, use supabase functions serve with an env file that sets CONTACTLINT_KEY.
Limits and failing open
- ContactLint does not connect to mail servers. It cannot confirm that a mailbox exists or detect catch-all domains. It checks syntax, MX and A records, known disposable domains, role addresses and typos of major providers.
- Every failure path returns
ok: true: timeouts, network errors,401,402(no free checks or credits left) and429(more than 60 requests per minute on the key). Read your function logs for the warnings. - Each key gets 100 free checks per month, then credit packs from $9 for 5,000. A check that ends as
unknownbecause the DNS lookup failed is not billed. - For a hint while the person types, add the widget with a
cl_pub_publishable key. Note that the widget and the function each use a check.
Questions
Can someone skip this check?
Yes. The browser calls signUp, so a person calling the Supabase Auth API directly can skip the Edge Function. It catches typos and throwaway addresses from real users. For a hard gate, look at the Supabase Auth docs for a hook that runs before a user is created.
Why not call ContactLint straight from the browser?
That would expose your secret key. Use the Edge Function, or, for browser-only hints, the ContactLint widget with a cl_pub_ publishable key that only works from the origins you list.
Does each signup attempt use a check?
Each call to the function uses one check, including a retry after "Keep mine". A check that ends as unknown because the DNS lookup failed is not billed.
Other integrations
Get a free API key. 100 checks per month, no card required. Or try a live check first.