---
title: "Twilio Verify WordPress: Build an SMS & Email 2FA Flow"
description: "Add Twilio Verify 2FA to WordPress: the two API calls, the REST routes that wrap them, and the abuse controls Twilio will not handle for you."
url: "https://wpwebhooks.org/blog/twilio-verify-wordpress-2fa/"
date: "2026-08-05"
---

# Twilio Verify WordPress: Build an SMS & Email 2FA Flow

**TL;DR:** Twilio Verify turns two-factor auth into two API calls, and moves the hard parts of code handling off your server.

-   **Send:** `POST /v2/Services/{ServiceSid}/Verifications` with `To` and `Channel`.
-   **Check:** `POST /v2/Services/{ServiceSid}/VerificationCheck` with `To` and `Code`.
-   Twilio generates, delivers, expires and rate-limits the code. **You never store a passcode.**
-   Codes last **10 minutes**, allow **5 check attempts**, then the verification is deleted.
-   The route that triggers a send has to be public — so rate limiting, enumeration defence and abuse control are entirely yours.

/ Overview

## What does **Twilio Verify** actually give you?

It gives you the part of two-factor authentication that is boring to build and unpleasant to get wrong: generating a random code, delivering it over a channel the user chose, remembering it for exactly long enough, and refusing it after too many guesses. Your application sends a request saying "verify this address" and later asks "is this code correct".

The thing worth noticing is what leaves your database. A hand-rolled OTP implementation stores a code — usually hashed, often not — against a user, with an expiry column and an attempt counter, and every one of those is a place to introduce a bug. With [Verify](https://www.twilio.com/docs/verify/api/verification), the code lives on Twilio's side for its whole lifetime. There is no passcode column in your schema, so there is no passcode to leak, no expiry check to forget, and no attempt counter to increment in a race.

What you build instead is the glue: routes the browser can call, a mapping from a user to the destination you will actually send to, and the decision about what a successful check entitles someone to do.

FIG 01 — The two Verify calls, and where your code sits either side

/ Setup

## What do you need before writing any **PHP**?

A Verify Service, and three values. The Service is a container in the Twilio console that holds the settings shared by every verification you send — the friendly name shown in the message, code length, which channels are enabled — and it has its own SID beginning `VA`. That is separate from your Account SID.

| Value | Looks like | Where it comes from |
| --- | --- | --- |
| Account SID | `AC…` | Twilio console dashboard |
| Auth Token | a secret string | Twilio console dashboard — treat as a password |
| Service SID | `VA…` | Verify → Services, one per application |

Install the official SDK with `composer require twilio/sdk`. It is a thin wrapper over the REST API, and the [twilio-php](https://github.com/twilio/twilio-php) client is the only dependency the integration needs.

**Every PHP example below uses that official `twilio/sdk` package** — the `$client`, the `->verify->v2` chain and the `\Twilio\Exceptions\RestException` class all come from it. Nothing here calls the REST API over raw `wp_remote_post`. You can talk to Verify with plain HTTP if you would rather not add the dependency, but then the exception types and the fluent chain in these snippets do not apply.

**Do not put the credentials in a `.env` inside the plugin directory.** Anything under the webroot is one server misconfiguration away from being downloadable, and a leaked Auth Token is an account someone else can spend money from. Put them in `wp-config.php` above the webroot, or in real environment variables set by the server.

/ Sending

## How do you **send** a verification code?

One call, two required parameters. `To` is a phone number in E.164 format (`+441234567890` — the leading plus is not optional) or an email address, and `Channel` is how it should arrive.

PHP (twilio/sdk) — send a code

```
// Requires the official Twilio PHP SDK: composer require twilio/sdk
use TwilioRestClient;

$client = new Client( TWILIO_SID, TWILIO_TOKEN );

try {
    $verification = $client->verify->v2
        ->services( TWILIO_SERVICE_ID )
        ->verifications
        ->create( $to, $channel );   // 'sms' | 'call' | 'email' | 'whatsapp'

    // $verification->status === 'pending'
} catch ( TwilioExceptionsRestException $e ) {
    // 60200 invalid parameter, 60203 max send attempts, 60410 blocked by Fraud Guard
    error_log( 'verify send failed: ' . $e->getStatusCode() . ' ' . $e->getMessage() );
}
```

Catching `RestException` specifically rather than `\Exception` matters, because it is the only one that carries `getStatusCode()` and `getMoreInfo()`. A generic catch that returns `$e->getCode()` gives you Twilio's application error number in some paths and a PHP error code in others, and the caller cannot tell which it got.

A successful send returns status `pending`. That means delivery was accepted, not that anyone received anything — a disconnected number or a spam-filtered inbox still returns `pending`.

/ Checking

## How do you **check** the code the user typed?

The mirror call. Pass the same `To` and the code, and read one field on the response.

PHP (twilio/sdk) — check a code

```
// $client is the same TwilioRestClient from the SDK, constructed above.
$check = $client->verify->v2
    ->services( TWILIO_SERVICE_ID )
    ->verificationChecks
    ->create( [ 'to' => $to, 'code' => $code ] );

if ( 'approved' === $check->status ) {
    // and ONLY here
}
```

Compare against `approved` and nothing else. The status field has seven possible values, and treating "not failed" as success is how people accidentally let `pending` through:

| Status | What it means | Let the user through? |
| --- | --- | --- |
| `approved` | The code matched | Yes — this value only |
| `pending` | Sent, not yet correctly answered | No |
| `expired` | Past the validity window | No |
| `max_attempts_reached` | Too many wrong guesses | No |
| `canceled` / `failed` / `deleted` | Terminated or unsuccessful | No |

There is a sharp edge in the failure path. Twilio deletes the verification once it is approved, expires, or hits the attempt limit — so a check against a verification that no longer exists returns a **404**, not a status. Code that only inspects `$check->status` and lets exceptions bubble will crash on the sixth wrong guess rather than telling the user they have run out of attempts.

/ Limits

## What are the built-in **limits**?

Three numbers do most of the work, and they are the reason you do not need your own expiry and attempt-counter logic.

| Limit | Default | What happens at the boundary |
| --- | --- | --- |
| Code lifetime | 10 minutes | Status becomes `expired`; configurable 2 min – 24 h via support |
| Check attempts | 5 per verification | [Error 60202](https://www.twilio.com/docs/api/errors/60202), status `max_attempts_reached` |
| Send attempts | per service rate limits | [Error 60203](https://www.twilio.com/docs/api/errors/60203), max send attempts reached |

Once a verification hits the attempt limit, a new one cannot be created for that destination until the existing one expires. So the worst case a user can talk themselves into is a ten-minute wait — which is a sensible product behaviour you get without writing a lockout table, but it is also a support call you should write copy for.

> Twilio deletes the verification once it is approved, expired, or out of attempts. There is nothing on your side to clean up — and nothing to check against either. — the rule that saves you a lockout table

/ REST routes

## How should the **WordPress routes** be shaped?

Two `POST` routes registered on `rest_api_init`, one per Verify call, with a JSON schema on each so malformed input is rejected before it reaches Twilio. WordPress will do the validation for you if you hand it a schema through [register\_rest\_route](https://developer.wordpress.org/reference/functions/register_rest_route/).

PHP — route registration with a validated schema

```
add_action( 'rest_api_init', function () {

    register_rest_route( 'your-plugin/v1', '/verificationCheck', [
        'methods'             => 'POST',
        'callback'            => 'your_plugin_check',
        'permission_callback' => 'your_plugin_gate',   // never __return_true
        'args'                => [
            'email' => [
                'required'          => true,
                'type'              => 'string',
                'format'            => 'email',
                'sanitize_callback' => 'sanitize_email',
            ],
            'code'  => [
                'required' => true,
                'type'     => 'string',
                'pattern'  => '^d{6}$',
            ],
        ],
    ] );
} );
```

Declaring the arguments in `args` rather than parsing `$request->get_body()` by hand is the difference between validation you maintain and validation the platform maintains. WordPress runs the schema, returns a properly formed `rest_invalid_param` error, and your callback only ever runs on input that already matched.

/ The handoff

## What happens after a code is **approved**?

For a password-reset flow, the approved check is the point where you mint a reset token — and you should use WordPress's own, not invent one. [get\_password\_reset\_key](https://developer.wordpress.org/reference/functions/get_password_reset_key/) generates a key, stores its hash against the user, and stamps it with a time. The matching [check\_password\_reset\_key](https://developer.wordpress.org/reference/functions/check_password_reset_key/) validates it on the way back, honouring the standard expiry.

PHP — approved check to reset key

```
if ( 'approved' !== $check->status ) {
    return new WP_REST_Response( [ 'ok' => false ], 401 );
}

$key = get_password_reset_key( $user );

if ( is_wp_error( $key ) ) {
    return new WP_REST_Response( [ 'ok' => false ], 500 );
}

// Hand back a short-lived key the next request must present.
return new WP_REST_Response( [ 'ok' => true, 'token' => $key ], 200 );
```

Two things are easy to get wrong here. The first is returning HTTP `200` with a failure encoded in the body — a client that checks the transport status sees success, and every logging and monitoring layer between you and the user agrees with it. Let the HTTP status carry the outcome. The second is reaching for `wp_set_auth_cookie()` instead of a reset key: passing a code proves control of a phone or mailbox, which is exactly the right basis for resetting a password and a much weaker basis for handing out a logged-in session.

![Cyberpunk illustration of a developer at a wall of neon terminals, facing a green holographic panel that reads "Adding Twilio Verify Two-Factor Auth to WordPress", with a "SECURE YOUR SITE" padlock card and a phone projecting a fingerprint above a "VERIFY — Text message" notification carrying the Twilio logo.](https://wpwebhooks.org/blog/twilio-verify-wordpress-2fa/og_image.jpg)

/ Abuse

## What does Twilio **not** protect you from?

Your own endpoint. This is the part that gets skipped, because everything above works perfectly in testing and the gap only shows up on a bill.

Note that this list is specific to Verify. Twilio's Programmable Messaging API — the one behind [WooCommerce order SMS](https://wpwebhooks.org/blog/woocommerce-sms-order-notification-twilio/) — has a different set of traps, starting with A2P 10DLC registration and per-segment billing.

A lost-password flow is used by people who cannot log in, so the send route must be callable by anonymous visitors. That means a permission callback that returns `true` unconditionally, which is the natural thing to write, hands anyone on the internet a button that makes your site pay to send a message. That is [SMS pumping](https://www.twilio.com/docs/verify/preventing-toll-fraud): an attacker drives volume to numbers they profit from, and you are the one being invoiced. Twilio's Fraud Guard, geographic permissions and service rate limits help, and Twilio is explicit that no provider-side control is complete on its own.

| Concern | Public route, no gate | What to do instead |
| --- | --- | --- |
| Send abuse | Anyone can trigger unlimited sends | Rate limit per IP and per account before calling Twilio |
| Account discovery | 404 "user not found" vs 200 reveals who is registered | Identical response and timing either way |
| CSRF token | An endpoint that hands out a fresh nonce to any caller | Nonce tied to a session, or drop the pretence and rate limit properly |
| Destination | Number taken from the request body | Number read from stored user meta, never from the caller |

The last row is the one that turns a costly problem into a contained one. If the destination comes from the request, the endpoint will send to any number in the world on request. If it is looked up from the account's stored contact details, an attacker can at worst spam a real user's real phone — still worth rate limiting, but no longer a payout mechanism.

The enumeration point is subtler and just as real. Returning a distinct `404` for an unknown address turns the endpoint into an oracle for which email addresses have accounts, which is a useful shopping list for credential stuffing. Answer the same way in both cases, and do the work of looking busy for the same length of time.

/ Delivery

## Should the API call happen **in the request**?

Here, yes — and it is worth being clear about why, because it is the opposite of the advice for most outbound calls. The user is standing at a form waiting to be told whether a code was sent, so the send is genuinely synchronous work; deferring it to a queue would mean answering "we will send something shortly", which is not a login experience anyone wants.

What you should do is bound it. Set an explicit timeout on the HTTP client rather than accepting the default, decide what the form says when Twilio is slow, and make sure a Twilio outage produces a clear error instead of a hung request. The asynchronous pattern that suits [outbound notifications](https://wpwebhooks.org/blog/wordpress-webhooks-rest-api/) is the wrong shape for an interactive step in an authentication flow — the same reasoning, applied to a case where the answer comes out differently.

/Footnotes

¹ Endpoints, channels and status values from the Twilio [Verification](https://www.twilio.com/docs/verify/api/verification) and [VerificationCheck](https://www.twilio.com/docs/verify/api/verification-check) API references.

² The 10-minute default lifetime and its 2 minute – 24 hour configurable range are documented under [Rate Limits and Timeouts](https://www.twilio.com/docs/verify/api/rate-limits-and-timeouts); the 5-attempt check limit is error [60202](https://www.twilio.com/docs/api/errors/60202).

## Structured data

```json
{"@context":"https://schema.org","@type":"Article","headline":"Twilio Verify WordPress: Build an SMS & Email 2FA Flow","description":"Add Twilio Verify 2FA to WordPress: the two API calls, the REST routes that wrap them, and the abuse controls Twilio will not handle for you.","datePublished":"2026-08-05","dateModified":"2026-08-05","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/twilio-verify-wordpress-2fa/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/twilio-verify-wordpress-2fa/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk illustration of a developer at a wall of neon terminals, facing a green holographic panel that reads \"Adding Twilio Verify Two-Factor Auth to WordPress\", with a \"SECURE YOUR SITE\" padlock card and a phone projecting a fingerprint above a \"VERIFY — Text message\" notification carrying the Twilio logo."},"keywords":["twilio verify wordpress","wordpress 2fa api","twilio 2fa wordpress","wordpress sms verification","twilio verify php","wordpress two factor rest api","twilio verificationcheck","wordpress otp implementation"]}

{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"Twilio Verify WordPress: Build an SMS & Email 2FA Flow","item":"https://wpwebhooks.org/blog/twilio-verify-wordpress-2fa/"}]}

{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"How do I add Twilio Verify 2FA to WordPress?","acceptedAnswer":{"@type":"Answer","text":"Create a Verify Service in the Twilio console, install the twilio/sdk package, and wrap two API calls in your own REST routes: Verifications to send a code and VerificationCheck to validate it. Twilio generates, delivers, stores and expires the code, so your plugin never stores a one-time passcode itself."}},{"@type":"Question","name":"What are the two Twilio Verify API calls?","acceptedAnswer":{"@type":"Answer","text":"POST /v2/Services/{ServiceSid}/Verifications with To and Channel sends a code, and POST /v2/Services/{ServiceSid}/VerificationCheck with To and Code validates it. The check returns a status of approved when the code matches; anything else — pending, expired, max_attempts_reached — means do not let the user through."}},{"@type":"Question","name":"How long is a Twilio Verify code valid?","acceptedAnswer":{"@type":"Answer","text":"Ten minutes by default, and the window is configurable between 2 minutes and 24 hours through Twilio support. Twilio deletes the verification once it is approved, expires, or reaches the maximum number of check attempts, which is 5 by default."}},{"@type":"Question","name":"Does Twilio Verify protect my WordPress site from SMS pumping?","acceptedAnswer":{"@type":"Answer","text":"Only partly. Twilio offers SMS Fraud Guard, geographic permissions and service-level rate limits, but the endpoint that triggers a send is your own public REST route. If a logged-out visitor can POST an arbitrary phone number to it without a rate limit, they can make your site pay for messages. That control is yours to build."}},{"@type":"Question","name":"Should the 2FA REST route use a permission callback?","acceptedAnswer":{"@type":"Answer","text":"It still needs one, but it cannot require a logged-in user — a lost-password flow is used by people who cannot log in. Return true only after your own checks: a per-IP and per-account rate limit, a generic response whether or not the account exists, and a nonce that is genuinely bound to a session rather than one any caller can request."}}]}
```
