emailverifier.dev
Posts

Block Disposable Email Signups With an AWS Cognito Pre Sign-Up Lambda

| 5 min read | Usama Ejaz
Cognito pre-signup cover with an email checked before a new user reaches the account boundary.

An Amazon Cognito pre sign-up trigger runs before the user is created. Returning the event lets Cognito continue. Throwing an error stops the signup.

SignUp request
  -> PreSignUp_SignUp event
  -> one emailverifier.dev request
      -> action: allow or review -> return event
      -> action: block          -> throw error
  -> Cognito creates the user only on the return path

AWS documents the trigger as synchronous custom validation that can modify or deny a new local or federated user. See the current pre sign-up trigger reference.

Create the Lambda function

In the AWS Lambda console, create a function named check-signup-email with the Node.js 22.x runtime. Replace index.mjs with:

const API_URL = 'https://emailverifier.dev/api/v1/verify'

const coveredTriggers = new Set([
  'PreSignUp_SignUp',
  'PreSignUp_ExternalProvider',
])

function isVerificationResult(value) {
  return Boolean(
    value &&
    typeof value === 'object' &&
    typeof value.email === 'string' &&
    ['deliverable', 'risky', 'undeliverable', 'unknown']
      .includes(value.status) &&
    ['allow', 'review', 'block'].includes(value.action) &&
    Array.isArray(value.signals),
  )
}

export async function handler(event) {
  if (!coveredTriggers.has(event.triggerSource)) {
    return event
  }

  const email = event.request?.userAttributes?.email?.trim()
  if (!email) {
    throw new Error('An email address is required.')
  }

  const apiKey = process.env.EMAILVERIFIER_API_KEY
  if (!apiKey) {
    throw new Error('Email verification is not configured.')
  }

  let response
  try {
    response = await fetch(API_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-API-Key': apiKey,
      },
      body: JSON.stringify({ email }),
      signal: AbortSignal.timeout(3500),
    })
  } catch {
    throw new Error(
      'Email verification is temporarily unavailable. Try again.',
    )
  }

  if (!response.ok) {
    throw new Error(
      'Email verification is temporarily unavailable. Try again.',
    )
  }

  const result = await response.json()
  if (!isVerificationResult(result)) {
    throw new Error(
      'Email verification is temporarily unavailable. Try again.',
    )
  }

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

  if (result.signals.includes('disposable_address')) {
    throw new Error('Use a permanent email address.')
  }

  if (result.suggestion) {
    throw new Error(
      `Check the email address. Did you mean ${result.suggestion}?`,
    )
  }

  throw new Error('Check the email address and try again.')
}

The function makes one API request and returns the original Cognito event unchanged on the accepted paths. It does not set autoConfirmUser or autoVerifyEmail, so the user pool's normal confirmation policy remains in control.

The sample covers self-service signup and the first sign-in through an external identity provider. It deliberately returns without checking PreSignUp_AdminCreateUser. Add that trigger source to coveredTriggers only if administrator-created users must follow the same policy.

Add the project key without putting it in code

Create an emailverifier.dev project and copy its API key. In the Lambda function, open Configuration → Environment variables and add:

EMAILVERIFIER_API_KEY = your-project-key

Choose Save, return to the code editor, and deploy the function. AWS recommends reading configuration values through process.env rather than hard-coding them; the current Node.js handler documentation shows that pattern.

Trace a signup event in the Lambda console

Create a private test event named cognito-pre-signup:

{
  "version": "1",
  "triggerSource": "PreSignUp_SignUp",
  "region": "us-east-1",
  "userPoolId": "us-east-1_EXAMPLE",
  "userName": "person@example.com",
  "callerContext": {
    "awsSdkVersion": "example",
    "clientId": "example-client-id"
  },
  "request": {
    "userAttributes": {
      "email": "person@example.com"
    }
  },
  "response": {}
}

Run the test. An accepted address returns the same object. A blocked address produces the public error message from the handler. AWS's Lambda console testing guide explains how saved test events invoke the handler synchronously.

Attach the function to the user pool

Open Amazon Cognito and follow the current console path:

  1. Choose User Pools and open the target pool.
  2. Open Extensions and locate Lambda triggers.
  3. Choose Add a Lambda trigger.
  4. Select the sign-up category and the pre sign-up trigger.
  5. Assign check-signup-email. The function must be in the same AWS Region as the pool.
  6. Save the change.

When the trigger is added through the console, Cognito normally adds the resource-based permission needed to invoke the function. The complete procedure and IAM caveat are in AWS's user-pool trigger guide.

Run a real Cognito signup

Use your managed-login page or an app client that does not have a client secret. The AWS CLI form is:

aws cognito-idp sign-up \
  --client-id YOUR_APP_CLIENT_ID \
  --username person@example.com \
  --password 'replace-with-a-test-password' \
  --user-attributes Name=email,Value=person@example.com

Check the user-pool user list after each test. An allowed or review result creates the normal Cognito user. A blocking result returns the Lambda error and creates no user.

What each email status does inside the trigger

  • deliverable can return allow or review. Both continue to Cognito, where confirmation can still prove inbox control.
  • risky with disposable_address returns block and stops user creation. A risky typo with inconclusive evidence may instead return review.
  • undeliverable returns block after a confirmed syntax, routing, or recipient failure.
  • unknown returns review. The receiving system did not provide conclusive mailbox evidence.

The handler fails closed when the external request is unavailable. That suits a costly free trial, but it temporarily pauses legitimate signups too. A lower-risk product can replace the three availability errors with return event, record the outage in CloudWatch, and rely on Cognito confirmation or restricted access.

Use the REST API reference for the response contract and the validation, verification, and confirmation guide for the boundary between this trigger and Cognito's confirmation message.

Continue reading