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}/VerificationswithToandChannel. - Check:
POST /v2/Services/{ServiceSid}/VerificationCheckwithToandCode. - 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.
/ 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 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, status max_attempts_reached |
| Send attempts | per service rate limits | Error 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.
/ 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.
| 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 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.