WP Webhooks / Blog / WordPress integrations
Article · WordPress integrations

Adding Twilio Verify Two-Factor Auth to WordPress

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.

9 min 2026-08-05
#Twilio#Security

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, 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.

A Twilio Verify two-factor flow wired through WordPress REST routesThe browser posts an identifier to a public WordPress REST route. That route resolves the user, looks up the destination it will actually send to, and calls the Twilio Verify Verifications endpoint, which generates and delivers the code. Twilio stores the code, not your site. The user then submits the code to a second REST route, which calls VerificationCheck. Twilio answers approved or pending, and only on approved does the WordPress side mint a password reset key and hand it back. Rate limiting, user enumeration and abuse control sit on your routes, because both routes must be reachable by logged-out visitors.

approved

pending / max_attempts_reached

browser submits identifier

POST /wp-json/your-plugin/v1/verifications
(public route)

your code: resolve user,
pick destination, rate limit

Twilio: POST /Verifications
( To, Channel )

Twilio generates + delivers the code
sms / call / email / whatsapp

user reads the code

POST /wp-json/your-plugin/v1/verificationCheck

Twilio: POST /VerificationCheck
( To, Code )

status?

mint password reset key
get_password_reset_key()

reject, count the attempt

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.

ValueLooks likeWhere it comes from
Account SIDAC…Twilio console dashboard
Auth Tokena secret stringTwilio console dashboard — treat as a password
Service SIDVA…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 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:

StatusWhat it meansLet the user through?
approvedThe code matchedYes — this value only
pendingSent, not yet correctly answeredNo
expiredPast the validity windowNo
max_attempts_reachedToo many wrong guessesNo
canceled / failed / deletedTerminated or unsuccessfulNo

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.

LimitDefaultWhat happens at the boundary
Code lifetime10 minutesStatus becomes expired; configurable 2 min – 24 h via support
Check attempts5 per verificationError 60202, status max_attempts_reached
Send attemptsper service rate limitsError 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.

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 generates a key, stores its hash against the user, and stamps it with a time. The matching 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.

/ 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 — 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: 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.

ConcernPublic route, no gateWhat to do instead
Send abuseAnyone can trigger unlimited sendsRate limit per IP and per account before calling Twilio
Account discovery404 "user not found" vs 200 reveals who is registeredIdentical response and timing either way
CSRF tokenAn endpoint that hands out a fresh nonce to any callerNonce tied to a session, or drop the pretence and rate limit properly
DestinationNumber taken from the request bodyNumber 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 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 and VerificationCheck API references.
² The 10-minute default lifetime and its 2 minute – 24 hour configurable range are documented under Rate Limits and Timeouts; the 5-attempt check limit is error 60202.
FAQ

Things engineers always ask.

Don't see yours? Open an issue on GitHub or check the full reference in the API docs.

How do I add Twilio Verify 2FA to WordPress? +
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.
What are the two Twilio Verify API calls? +
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.
How long is a Twilio Verify code valid? +
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.
Does Twilio Verify protect my WordPress site from SMS pumping? +
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.
Should the 2FA REST route use a permission callback? +
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.
Ready

Your next automation is
one sentence away.

$ wp plugin install flowsystems-webhook-actions --activate