emailverifier.dev
Posts

How to Validate Emails Before Supabase Signup

| 5 min read | Usama Ejaz
Supabase signup hook cover with an email, approval check, and database.

This tutorial adds email verification to the point immediately before Supabase Auth creates a user. You will deploy a signed HTTP hook, attach it as the Before User Created hook, and prove that a blocking result prevents insertion into auth.users.

Supabase gives HTTP auth hooks five seconds to respond. The function uses a shorter verification timeout so it can return a controlled error instead of being terminated by the platform.

Prepare the Supabase project

You need a Supabase project, the Supabase CLI, and Docker for local Edge Function development. Initialize the project and create the function:

supabase init
supabase functions new validate-signup-email

The tutorial changes one file:

supabase/
└─ functions/
   └─ validate-signup-email/
      └─ index.ts

Add the two secrets

Create an emailverifier.dev project and copy its API key. You also need the signing secret shown when you configure the Supabase HTTP hook.

Store both secrets for the deployed function:

supabase secrets set \
  EMAILVERIFIER_API_KEY=replace_with_your_project_key \
  SUPABASE_HOOK_SECRET=replace_with_your_hook_secret

The browser never receives either value. The hook secret proves that the request came from Supabase; the project key authenticates the one verification request.

Implement the signed hook

Replace supabase/functions/validate-signup-email/index.ts with:

import { Webhook } from 'npm:standardwebhooks@1.0.0'

type HookPayload = {
  user?: {
    email?: string
  }
}

type VerificationResult = {
  email: string
  status: 'deliverable' | 'risky' | 'undeliverable' | 'unknown'
  action: 'allow' | 'block' | 'review'
  signals: string[]
  suggestion: string | null
}

function json(body: unknown, status = 200) {
  return new Response(JSON.stringify(body), {
    status,
    headers: { 'Content-Type': 'application/json' }
  })
}

Deno.serve(async (request) => {
  const apiKey = Deno.env.get('EMAILVERIFIER_API_KEY')
  const hookSecret = Deno.env.get('SUPABASE_HOOK_SECRET')

  if (!apiKey || !hookSecret) {
    return json(
      { error: { http_code: 500, message: 'Hook is not configured.' } },
      500
    )
  }

  const rawBody = await request.text()
  let payload: HookPayload

  try {
    const webhook = new Webhook(hookSecret)
    payload = webhook.verify(rawBody, {
      'webhook-id': request.headers.get('webhook-id') ?? '',
      'webhook-timestamp':
        request.headers.get('webhook-timestamp') ?? '',
      'webhook-signature':
        request.headers.get('webhook-signature') ?? ''
    }) as HookPayload
  } catch {
    return json(
      { error: { http_code: 401, message: 'Invalid hook signature.' } },
      401
    )
  }

  const email = payload.user?.email?.trim()
  if (!email) {
    return json({})
  }

  let response: Response
  try {
    response = await fetch(
      'https://emailverifier.dev/api/v1/verify',
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-API-Key': apiKey
        },
        body: JSON.stringify({ email }),
        signal: AbortSignal.timeout(3500)
      }
    )
  } catch {
    return json(
      {
        error: {
          http_code: 503,
          message: 'Email verification is temporarily unavailable.'
        }
      },
      503
    )
  }

  if (!response.ok) {
    return json(
      {
        error: {
          http_code: 503,
          message: 'Email verification is temporarily unavailable.'
        }
      },
      503
    )
  }

  const result = (await response.json()) as VerificationResult

  if (result.action !== 'block') {
    return json({})
  }

  const message = result.signals.includes('disposable_address')
    ? 'Use a permanent email address.'
    : result.suggestion
      ? 'Check the email address and use the suggested correction.'
      : 'Check the email address and try again.'

  return json(
    { error: { http_code: 400, message } },
    400
  )
})

An empty successful object allows user creation. A 4xx response with the hook error shape stops it. The function verifies the Standard Webhooks signature before trusting the email payload. Supabase documents this lifecycle in its Before User Created hook guide.

Serve the function locally

supabase start
supabase functions serve validate-signup-email \
  --no-verify-jwt \
  --env-file supabase/.env.local

For local serving, create supabase/.env.local with the same two variables. Do not commit that file.

A hand-written curl request will fail signature verification, which is the correct result. Test the complete flow through Supabase Auth after attaching the hook.

Deploy and attach the hook

supabase functions deploy validate-signup-email \
  --no-verify-jwt

In the Supabase Dashboard, open Authentication → Hooks, add a Before User Created HTTP hook, select the deployed function URL, and copy the generated signing secret into SUPABASE_HOOK_SECRET. Redeploy or reset the secret if its value changes.

The --no-verify-jwt flag is intentional. The function authenticates the hook with its Standard Webhooks signature rather than a user JWT.

Trigger a real signup

Use the same client call your application already uses:

const { data, error } = await supabase.auth.signUp({
  email,
  password
})

if (error) {
  console.error(error.message)
} else {
  console.log(data.user?.id)
}

For a command-line test, call the public Auth endpoint with your project URL and anon key:

curl "$SUPABASE_URL/auth/v1/signup" \
  -H "apikey: $SUPABASE_ANON_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"email\":\"person@example.com\",\"password\":\"ChangeThis123!\"}"

An allowed or review result continues through normal Supabase signup. A blocked result returns the message from the hook and does not create the user row. A timeout or API error returns a temporary failure rather than pretending the mailbox is invalid.

Know what each status does

  • deliverable: allow unless a separate role-address or catch-all signal recommends review.
  • risky: block disposable addresses; show a correction for a likely typo.
  • undeliverable: block after a confirmed syntax, routing, or recipient failure.
  • unknown: allow the account into confirmation or a restricted state because recipient evidence was inconclusive.

The REST API reference lists the exact result and error fields. The tutorial is complete when an accepted signup creates a user, a blocking fixture does not, an invalid signature returns 401, and a forced timeout returns a temporary hook error.

The separate jobs of the hook and Supabase confirmation are mapped in Email Validation vs Verification vs Confirmation.

Keep Supabase email confirmation enabled when your product needs proof that the registrant controls the inbox. Verification evaluates the address; confirmation proves completion of the message sent to it.

Continue reading