Skip to content

Check phone numbers in Cognito's Pre sign-up and SMS sender triggers

updated · September 27, 2026

Check a phone number with Spaw in Cognito's Pre sign-up trigger and in the custom SMS sender Lambda, which sends every code itself. Fails open.

Mechanism
Pre sign-up and custom SMS sender Lambda triggers calling the API
Works today
Yes; the custom SMS sender sends every SMS itself
Refusing
Pre sign-up denies the sign-up; the sender just sends nothing
Visitor's IP
Only when your app passes it as ClientMetadata
Cost
1 credit per valid number; repeats within 7 days free

Amazon Cognito gives a phone check two places to run: the Pre sign-up trigger, which can deny a sign-up before any code exists, and the custom SMS sender trigger, which replaces Cognito's own SMS sending so that your Lambda function delivers every code. Asking Spaw about the number in both means a code is sent only where your account's SMS policy answers ok_to_send: true. The custom SMS sender is a delivery hook, not a filter: once it is assigned, Cognito sends no SMS at all, and a code your function does not send simply never arrives.

What the triggers receive

From Amazon Cognito's developer guide (the custom SMS sender, custom sender, Pre sign-up and Lambda trigger pages) and the AWS Lambda guide, read on 27 September 2026:

Item Pre sign-up Custom SMS sender
The number request.userAttributes.phone_number request.userAttributes.phone_number
The code none yet request.code, encrypted with your KMS key
The flow triggerSource PreSignUp_SignUp, the one the sample checks; the trigger also runs as PreSignUp_AdminCreateUser and PreSignUp_ExternalProvider triggerSource: CustomSMSSender_SignUp, _ResendCode, _VerifyUserAttribute, _UpdateUserAttribute, _Authentication, _ForgotPassword, _AdminCreateUser
Your app's extra data request.clientMetadata, from SignUp, AdminCreateUser, AdminRespondToAuthChallenge and ForgotPassword request.clientMetadata, from RespondToAuthChallenge and AdminRespondToAuthChallenge only
Refusing Throw an error: the sign-up is denied No refusal exists: Cognito "doesn't expect any additional return information in the response"
Invocation Synchronous, "must respond within 5 seconds" Not synchronous: "Except for Custom sender Lambda triggers, Amazon Cognito invokes Lambda functions synchronously"; the function's own timeout applies (3 seconds by default)

Neither event carries the visitor's IP address. Cognito passes on only what your app puts in ClientMetadata, so the samples read clientMetadata.client_ip and work without it. Two limits apply: a client-side SignUp call from a browser does not know the visitor's public address, so only a backend that makes the call can fill it in; and anyone calling Cognito's public sign-up API directly can put any address there, or none. Without an address the answer's client_velocity is null and numbers_per_client cannot fire, while range_burst, five distinct numbers your account looked up in one 1,000-number range within 60 minutes (+40), still counts.

Before you start

  1. Create a secret API key on the API keys page of your Spaw dashboard, scoped to phone, with a daily credit cap above a normal day's sign-ups, and store it in each function's SPAW_KEY environment variable (or read it from Secrets Manager).
  2. Switch the SMS policy's opt-out rule off, so a person who once replied STOP to a marketing message can still receive a code:
curl -X PUT https://spaw.co/api/v1/phone/policy \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"block_opted_out": false}'

An account has one policy, applied to every lookup it makes. With the rule off, an opted-out number is decided like any other and the answer still says meta.opted_out: true, so a marketing flow on the same account reads that field itself.

  1. For the custom SMS sender, follow Cognito's guide to activating custom sender triggers: a symmetric KMS key, permission for Cognito to invoke the function, and an aws cognito-idp update-user-pool call with CustomSMSSender={LambdaVersion=V1_0,LambdaArn=…} and the KMSKeyID. The console cannot set it. UpdateUserPool resets every parameter you leave out to its default, so the call has to repeat the pool's existing configuration.
  2. Raise the sender function's timeout above the Lambda default of 3 seconds. It decrypts the code, waits up to 2.5 seconds for Spaw and then calls SNS.

Deny a sign-up in the Pre sign-up trigger

A sign-up is the flow where refusing is cleanest: the trigger runs before any code is sent, and an error it throws denies the sign-up, so your app's SignUp call receives the error and can say so.

import { isIP } from "node:net";

export const handler = async (event) => {
  const phone = event.request.userAttributes.phone_number;

  // Only self-service sign-up: an administrator creating a user, or a first
  // sign-in through an identity provider, is never denied here.
  if (event.triggerSource !== "PreSignUp_SignUp" || !phone) {
    return event;
  }

  // ClientMetadata is whatever the caller sent: pass it on only when it is an address.
  const clientIp = event.request.clientMetadata?.client_ip ?? "";
  let answer = null;

  try {
    const response = await fetch("https://spaw.co/api/v1/phone", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.SPAW_KEY}`,
        "Content-Type": "application/json",
        Accept: "application/json",
      },
      body: JSON.stringify(isIP(clientIp) ? { phone, client_ip: clientIp } : { phone }),
      signal: AbortSignal.timeout(2500),
    });

    answer = response.ok ? (await response.json()).data : null;
  } catch {
    // Fail open: the sign-up continues unchecked.
  }

  if (answer && !answer.ok_to_send && answer.blocked_by !== "opted_out") {
    throw new Error("This number cannot receive a verification code.");
  }

  return event;
};

Pass the visitor's address from your backend as ClientMetadata: { client_ip: "…" } on the SignUp call and the lookup counts it. The samples pass the value on only when it parses as an IP address: Spaw answers a malformed client_ip with 422, which a fail-open check would read as no answer, so a caller could otherwise skip the check by sending junk there. The code the custom SMS sender then sends for this sign-up looks the same number up again within minutes; a repeat within seven days is free.

Check every code in the custom SMS sender

import { KmsKeyringNode, buildClient, CommitmentPolicy } from "@aws-crypto/client-node";
import { SNSClient, PublishCommand } from "@aws-sdk/client-sns";
import { isIP } from "node:net";

const { decrypt } = buildClient(CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT);
const keyring = new KmsKeyringNode({ generatorKeyId: process.env.KEY_ID, keyIds: [process.env.KEY_ARN] });
const sns = new SNSClient({});

// Codes that attach a number to an account. Sign-in, password-reset and
// admin-created-user messages go to a number the account already holds.
const REFUSE_ON = new Set([
  "CustomSMSSender_SignUp",
  "CustomSMSSender_ResendCode",
  "CustomSMSSender_VerifyUserAttribute",
  "CustomSMSSender_UpdateUserAttribute",
]);

async function checkNumber(phone, clientIp) {
  try {
    const response = await fetch("https://spaw.co/api/v1/phone", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.SPAW_KEY}`,
        "Content-Type": "application/json",
        Accept: "application/json",
      },
      body: JSON.stringify(isIP(clientIp ?? "") ? { phone, client_ip: clientIp } : { phone }),
      signal: AbortSignal.timeout(2500),
    });

    return response.ok ? (await response.json()).data : null;
  } catch {
    return null;
  }
}

export const handler = async (event) => {
  const phone = event.request.userAttributes.phone_number;

  if (!phone || !event.request.code) {
    return;
  }

  // Only present when your app passed it through RespondToAuthChallenge.
  const answer = await checkNumber(phone, event.request.clientMetadata?.client_ip);

  if (answer && !answer.ok_to_send && answer.blocked_by !== "opted_out") {
    console.log(JSON.stringify({ trigger: event.triggerSource, spaw_blocked_by: answer.blocked_by }));

    if (REFUSE_ON.has(event.triggerSource)) {
      // Nothing is sent and Cognito is not told: the person receives no code.
      return;
    }
  }

  const { plaintext } = await decrypt(keyring, Buffer.from(event.request.code, "base64"));

  await sns.send(new PublishCommand({
    PhoneNumber: phone,
    Message: `Your code is ${Buffer.from(plaintext).toString("utf-8")}`,
  }));
};

@aws-crypto/client-node goes into the deployment package, as Cognito's own example does; @aws-sdk/client-sns is part of the AWS SDK for JavaScript v3 that the Node.js Lambda runtimes include. The number is checked before the code is decrypted, so a refused code is never decrypted at all, and Spaw receives the number and, when present, the address: never the code, the message or the other attributes. The samples follow the documented event shapes; run them in your own account before you rely on them.

Which codes it refuses, and what the person sees

REFUSE_ON holds the flows where a number is being attached to an account: a sign-up, its resent code, and adding or changing a phone number. Sign-in codes (_Authentication), password resets and the temporary password of a user you created go to a number the account already holds, and refusing them would lock an existing customer out, so they are logged and sent.

A refusal in the sender is silent. Cognito does not wait for this function, so your app moves on to its code-entry step and the code never arrives. Say on that screen what to do when no code comes, and keep the Pre sign-up check in place, because a sign-up refused there gets a clear error while one refused in the sender leaves an unconfirmed user behind.

A refusal names the rule in blocked_by; the gate-SMS guide explains each rule and the order they apply in.

Why it fails open, and why it never throws over Spaw

A timeout, a network error, 402 INSUFFICIENT_CREDITS on an empty balance, 429 when the key's daily cap is spent or your phone requests exceed 50 a second, or a 5xx leaves answer null, and the code is sent as if the check did not exist. Failing closed would turn a Spaw outage into an outage of your sign-up. In the sender there is a second reason: Cognito does not wait for its answer, so an error thrown over the Spaw call would reach nobody who could show it to the person, and the code would simply not be sent.

Report the codes people entered

The Post confirmation trigger runs on ConfirmSignUp, AdminConfirmSignUp and ConfirmForgotPassword. When a sign-up was confirmed and the phone number is a verified attribute, report it as verified. A pool whose Pre sign-up trigger sets autoVerifyPhone marks numbers verified without a code, so skip the report there:

export const handler = async (event) => {
  const { phone_number: phone, phone_number_verified: verified } = event.request.userAttributes;

  if (event.triggerSource === "PostConfirmation_ConfirmSignUp" && phone && verified === "true") {
    try {
      await fetch("https://spaw.co/api/v1/phone/feedback", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.SPAW_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ items: [{ phone, outcome: "verified" }] }),
        signal: AbortSignal.timeout(1500),
      });
    } catch {
      // A missed report only means one less outcome on your account.
    }
  }

  return event;
};

Reports are free. Spaw has no delivery-receipt adapter for SNS, so verified is the outcome this setup reports, and with verified alone the feedback summary reads verified_share: 1 and lists no blocks. For a conversion figure, turn on delivery status logging in the SNS text-messaging preferences, which writes each SMS's result to CloudWatch Logs (Amazon SNS developer guide, read 27 September 2026), and forward those results to POST /api/v1/phone/feedback as delivered or undelivered. Otherwise compare the verified count with the codes the sender function sent.

Try the branches without Cognito

The documented test numbers answer a fixed result each, cost nothing and are never counted in your traffic. Under the default policy +1 202 555 0103 answers ok_to_send: false with blocked_by: "disposable", +1 202 555 0105 answers blocked_by: "fictional", and +1 202 555 0102 (the test number that answers the virtual signal, score 40) and +1 202 555 0100 answer ok_to_send: true. Post them to https://spaw.co/api/v1/phone with curl, or put one in a Lambda console test event, to see each branch.

What to leave out

Leave hlr out of the request. The live carrier-network check (hlr: true) is not enabled on the service: hlr_checked answers false and nothing extra is charged. Leave the policy's require_reachable rule off for the same reason: while the check is not enabled, no lookup answers ok_to_send: true under it: every number that passes the other rules answers blocked_by: "unconfirmed".

ok_to_send is a fraud and deliverability decision. It is not consent and not a do-not-call scrub, and whether the person agreed to hear from you stays yours to record.

Cost

A lookup costs 1 credit when the number is valid. Invalid numbers, the test numbers and a repeat of the same number within seven days are free, so the sender's second look at a number the Pre sign-up trigger checked costs nothing, and neither does a resent code. Outcome reports are free. SNS bills the SMS. Spaw is not affiliated with Amazon Web Services.

Related

markdown version: /integrations/amazon-cognito.md

Verify addresses the same way

The Spaw API runs every check described here on each lookup, with an SMTP handshake that never sends mail. 10 free lookups a month, no card required.

Get your API key

More integrations