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

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.

Updated: 2026-09-27

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](/docs/phone-intelligence#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`](/docs/signals/phone/numbers_per_client) cannot fire, while [`range_burst`](/docs/signals/phone/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:

```bash
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.

3. 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.
4. 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.

```javascript
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

```javascript
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](/guides/gate-sms-verification-codes-with-the-sms-policy) 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:

```javascript
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](/docs/api/phone-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.

Reference: https://spaw.co/integrations/amazon-cognito
