ContactLintAPIMCPPricingFree toolsCompareGet a key

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

  1. Get a free secret key at /signup and store it as a Supabase secret: supabase secrets set CONTACTLINT_KEY=your_secret_key
  2. Create the function with supabase functions new check-email and replace supabase/functions/check-email/index.ts with:
    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
      }
    });
  3. 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.';
});

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

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.