Skip to content

Verify Supabase signups before the user is created

Integration Signups and authentication

An Edge Function on Supabase's Before User Created hook asks PricklyMails about each new address and refuses the signup when it cannot receive mail.

No connector needed: at each signup, Supabase calls the PricklyMails API from the Before User Created hook, and an address that cannot receive mail is refused before the account exists.

Verify for free

Step by step

Check each signup in four steps

  1. In PricklyMails

    Create an API key in PricklyMails

    1. In the dashboard, open API Keys and click New key.
    2. Under Environment, pick Test first: the key starts with pm_test_, answers from canned results, bills nothing and contacts no mail server.
    3. Copy the key as soon as it shows: it is shown only once.

    Tip

    The Free plan holds one key: once everything works, revoke the test key, then create the one under Live.

  2. In Supabase

    Add the check to a Supabase Edge Function

    The hook is an HTTP endpoint, here an Edge Function of your project, created and deployed with the Supabase CLI once supabase link points it at the project. The key goes in the function's secrets, never in its code.

    1. Create the function with supabase functions new before-user-created.
    2. Replace the content of its index.ts with the code below.
    3. Store your test key with supabase secrets set PRICKLYMAILS_KEY=pm_test_your_key.
    4. Deploy the function with supabase functions deploy before-user-created --no-verify-jwt.

    The code asks PricklyMails to answer within a few seconds (Prefer: wait) and stops waiting soon after, so a signup never hangs on the answer. It refuses the signup only when the recommendation is remove, and lets it through otherwise: a risky address, no answer in time, an error.

    supabase/functions/before-user-created/index.ts
    import { Webhook } from 'https://esm.sh/standardwebhooks@1.0.0'
    
    const VERIFY_URL = 'https://api.pricklymails.com/api/verify'
    const REFUSAL =
        'This email address cannot receive mail. ' +
        'Check it for a typo, or use another one.'
    
    const hookSecret = Deno.env.get('BEFORE_USER_CREATED_HOOK_SECRET') ?? ''
    const webhook = new Webhook(hookSecret.replace('v1,whsec_', ''))
    
    interface BeforeUserCreatedEvent {
        user: { email?: string }
    }
    
    // Supabase gives the hook 5 seconds: wait 3 for the answer, give up at 3.5.
    async function recommendationFor(
        email: string,
        key: string,
    ): Promise<string | null> {
        try {
            const response = await fetch(VERIFY_URL, {
                method: 'POST',
                headers: {
                    Authorization: `Bearer ${key}`,
                    'Content-Type': 'application/json',
                    Prefer: 'wait=3',
                },
                body: JSON.stringify({ email }),
                signal: AbortSignal.timeout(3500),
            })
            if (200 !== response.status) {
                return null
            }
            const { verification } = await response.json()
            return verification?.recommendation ?? null
        } catch {
            return null
        }
    }
    
    Deno.serve(async (request) => {
        const payload = await request.text()
        const headers = Object.fromEntries(request.headers)
        let event: BeforeUserCreatedEvent
        try {
            event = webhook.verify(payload, headers) as BeforeUserCreatedEvent
        } catch {
            return new Response('Invalid signature', { status: 401 })
        }
        const email = event.user.email
        const key = Deno.env.get('PRICKLYMAILS_KEY') ?? ''
        if (email && 'remove' === (await recommendationFor(email, key))) {
            // Supabase turns a 4xx from the hook into a 500: refuse in a 200 body.
            return Response.json({ error: { http_code: 400, message: REFUSAL } })
        }
        return Response.json({})
    })
    
    How the API answers in one call

    Note

    Supabase Auth calls the hook before any user token exists, hence --no-verify-jwt: the Standard Webhooks signature the code checks is what proves a call comes from Supabase. To keep that setting in your repository, set verify_jwt = false under [functions.before-user-created] in supabase/config.toml.

    Warning

    Keep the refusal on a 200 status, the error in its body: Supabase's hooks guide treats a 400 or 403 from an HTTP hook as an internal error, and answers the signup with a 500 instead of the message.

  3. In Supabase

    Turn on the hook in Supabase

    Supabase signs each call with a secret it generates for the hook, and the function refuses any call it cannot verify, so store the secret before you create the hook. A deployed function reads a new secret at once, with no redeploy.

    1. In the dashboard, go to Authentication then Hooks.
    2. Create a new Before User Created hook.
    3. Select HTTPS as the hook type.
    4. In the URL field, paste the URL of the function, which ends in /functions/v1/before-user-created.
    5. Click Generate Secret.
    6. Store the secret it shows with supabase secrets set BEFORE_USER_CREATED_HOOK_SECRET=v1,whsec_your_secret.
    7. Click Create.

    Tip

    Prefer the dashboard to the CLI? On the Edge Function Secrets page, enter the Key and Value, then click Save, for PRICKLYMAILS_KEY and the hook secret alike.

  4. In Supabase

    Try it with the test key

    1. Sign up with an address that starts with invalid, such as invalid@example.com: the test key answers remove, and the signup is refused.
    2. Sign up with another address, such as valid@example.com: the account is created.
    3. Where you stored the key, replace it with a key created under Live, which starts with pm_live_: from then on, each signup is verified for real and billed like any verification.

What each signup gets

The code reads the recommendation the API gives the address, the same one a list's report gives.

Remove

Refused: the address does not exist, its domain cannot receive mail, or it is disposable.

Supabase Auth does not create the user and answers the signup with the hook's message and a 400 status. With supabase-js, signUp returns it in error, an AuthApiError whose message is that sentence and whose status is 400: the person sees it where your form shows error.message.

Review

Let through: a domain that accepts any address, a generic address such as contact@, or a server that did not answer. The address may work.

Send

Let through: the address is valid, or hosted by a large provider that accepts every address.

No answer in time
Supabase gives an HTTP hook 5 seconds in total, retries included, and fails the signup with a hook_timeout error when no answer comes within them. The code waits at most 3 seconds for PricklyMails, gives up at 3.5 and lets the signup through, so the hook always answers in time.

Frequently asked questions

Does the hook run when someone signs up through an OAuth provider?

Yes. It runs each time Supabase Auth is about to create a user, and Supabase's own examples use it to block new accounts made through an OAuth provider, read from app_metadata.provider. A user who already exists and signs in again is not created again, so the hook does not run for them.

SourceSupabase's official help center

Can I try the hook locally with the Supabase CLI?

Yes. In supabase/config.toml, add an [auth.hook.before_user_created] section with enabled = true, a uri that reaches the function through host.docker.internal, the name the Auth container uses for your machine, and secrets = "env(BEFORE_USER_CREATED_HOOK_SECRET)". Generate the secret with openssl rand -base64 32, prefixed with v1,whsec_, and put it in the .env at the project root, which config.toml reads, and in supabase/functions/.env with PRICKLYMAILS_KEY, which the function reads. Then run supabase functions serve before-user-created --no-verify-jwt.

Could the hook be a Postgres function instead of an Edge Function?

Supabase accepts one, but gives a Postgres hook 2 seconds to complete, against 5 for an HTTP hook. The Edge Function leaves room for the 3-second wait on the PricklyMails answer.

SourceSupabase's official help center

Does the hook need a paid Supabase plan?

No. Supabase offers the Before User Created hook on the Free plan as well as Pro, and the Free plan holds up to 100 Edge Functions per project.

SourceSupabase's official help center

Is there a PricklyMails extension for Supabase?

No, and there is nothing to install: the code above calls the PricklyMails API with your key.

These trademarks belong to their respective owners. Naming them implies no partnership.

Check your list before your next send.

1,000 tokens free when you sign up, no credit card. After that, you only pay for answers.