Search by

cloudrepublic / shield-whatsapp-mfa

cloudrepublic

WhatsApp OTP MFA action for CodeIgniter Shield.

Package info

github.com/CloudRepublic-io/shield-whatsapp-mfa

Homepage

Issues

pkg:composer/cloudrepublic/shield-whatsapp-mfa

Statistics

Installs: 3

Dependents: 0

Suggesters: 1

Stars: 0

v1.0.0 2026-09-27 12:38 UTC

This package is auto-updated.

Last update: 2026-09-27 16:54:04 UTC


README

A drop-in Authentication Action for CodeIgniter Shield that sends a 6-digit one-time code over WhatsApp instead of email, for use as a second factor after login (or as the confirmation step after registration).

It follows the exact same show() / handle() / verify() lifecycle as Shield's built-in Email2FA action, just swapping the delivery channel.

Also includes WhatsAppSettingsController - lets an already-logged-in user self-service verify (or change, or remove) a WhatsApp number, the same "add this later" shape as shield-totp-mfa's TotpSettingsController and shield-passkey-mfa's PasskeySettingsController. Without this, WhatsApp MFA only works if your app already has a verified phone number on file for every user; with it, a user can turn WhatsApp on for themselves at any time, the same as they'd add a passkey or set up an authenticator app.

Requirements

  • PHP 8.2 or later
  • CodeIgniter 4.6 or later
  • CodeIgniter Shield 1.4 or later

Tested on CodeIgniter 4.6 and 4.7, up to PHP 8.5.

What's in the box

src/
  Authentication/Actions/
    WhatsAppMfa.php                       <- 'login' action: sends + verifies a fresh code each time
    WhatsAppActivator.php                 <- 'register' action: optional phone verification at signup
  Commands/
    Setup.php                             <- `php spark whatsapp-mfa:setup`
    ChannelStatus.php                     <- `php spark whatsapp-mfa:channel-status` - migration readiness report
  Config/WhatsAppMfa.php                  <- provider + credentials config, step-up settings, view overrides
  Controllers/
    WhatsAppActivatorController.php       <- handles WhatsAppActivator's "skip for now" link
    WhatsAppSettingsController.php        <- self-service phone verification + WhatsApp channel test flow
    WhatsAppStepUpController.php          <- step-up challenge (send + verify)
  Filters/RequireFreshWhatsApp.php        <- step-up auth filter for sensitive routes
  Sender/WhatsAppSenderInterface.php      <- contract for delivery providers
  Sender/MetaCloudApiSender.php           <- Meta WhatsApp Cloud API (default)
  Sender/TwilioWhatsAppSender.php         <- Twilio alternative - WhatsApp or SMS, via $channel
  Language/en/WhatsAppMfa.php
  Libraries/
    PhoneNumberStore.php                  <- shared verified-phone storage/verification/step-up logic,
                                              plus WhatsApp channel-test tracking
    CompletesPendingAction.php            <- shared "finish this pending action" trait
    ChannelLabel.php                      <- resolves {channel} placeholders to "WhatsApp"/"SMS"
  Views/
    whatsapp_mfa_show.php                 <- "we're about to send you a code" (login)
    whatsapp_mfa_verify.php               <- "enter your code" + resend (login)
    whatsapp_activator_enroll.php         <- phone entry + skip (registration)
    whatsapp_activator_verify.php         <- confirm the code sent to it (registration)
    whatsapp_settings_index.php           <- current verified number, add/change/remove, channel-test status
    whatsapp_settings_enroll.php          <- enter a phone number to verify (self-service)
    whatsapp_settings_verify.php          <- confirm the code sent to it (self-service)
    whatsapp_settings_test_enroll.php     <- enter a number to test WhatsApp delivery on
    whatsapp_settings_test_verify.php     <- confirm the WhatsApp test code
    whatsapp_step_up_show.php             <- "we're about to send a code" (step-up)
    whatsapp_step_up_verify.php           <- confirm the code sent to it (step-up)
routes-snippet.php                        <- routes to add by hand

How Shield Actions work (quick recap)

Shield lets you plug in a class for what happens right after register or login. You register it in app/Config/Auth.php:

public array $actions = [
    'register' => null,
    'login'    => \WhatsAppMfa\Authentication\Actions\WhatsAppMfa::class,
];

Shield's own routes (added by service('auth')->routes($routes), which most Shield installs already call) point at a generic ActionController that calls show(), handle(), and verify() on whichever class you configured. You don't need to add routes yourself.

Installation

Option A - via Composer (recommended)

  1. composer require cloudrepublic/shield-whatsapp-mfa.

  2. Run the setup command - publishes Config/WhatsAppMfa.php and Language/en/WhatsAppMfa.php into your app:

    php spark whatsapp-mfa:setup
    

Option B - manual drop-in

  1. Copy src/ into your app (e.g. app/ThirdParty/WhatsAppMfa/src) and register the namespace in app/Config/Autoload.php:

    public array $psr4 = [
        APP_NAMESPACE  => APPPATH,
        'WhatsAppMfa'  => APPPATH . 'ThirdParty/WhatsAppMfa/src',
    ];
  2. Copy Config/WhatsAppMfa.php to app/Config/WhatsAppMfa.php, and Language/en/WhatsAppMfa.php to app/Language/en/WhatsAppMfa.php.

Either way, finish with these

  1. Register the action in app/Config/Auth.php:

    public array $actions = [
        'register' => null,
        'login'    => \WhatsAppMfa\Authentication\Actions\WhatsAppMfa::class,
    ];
  2. Add credentials to .env (Meta Cloud API shown; see Config/WhatsAppMfa.php for Twilio equivalents):

    whatsAppMfa.phoneNumberId = "1234567890"
    whatsAppMfa.accessToken   = "EAAG..."

    Never commit real tokens — .env is git-ignored by default in CI4 apps.

  3. Add the settings routes from routes-snippet.php to app/Config/Routes.php, and link to account/whatsapp from wherever your account settings page lives - this is what lets a user self-service verify their own WhatsApp number (see "Self-service phone verification" below), which is the recommended way to get a phone number on file at all. If you'd rather use a phone number your app already stores instead, skip this and see step 6.

  4. Or, if you already store phone numbers yourself: set $phoneNumberField in app/Config/WhatsAppMfa.php to whatever property your User entity exposes it as (defaults to phone) - see customizing the User entity if you haven't added one yet. resolvePhoneNumber() checks the self-service verified record first and this as a fallback, so both can coexist if you want.

  5. Get a WhatsApp Business sender approved. Both Meta's Cloud API and Twilio require an approved authentication-category message template for OTP codes sent outside an existing conversation — you can't just fire free-form text. Set the template name in app/Config/WhatsAppMfa.php ($metaTemplateName / $twilioContentSid) to match what you got approved.

  6. If you'd rather verify a phone number at registration time instead of (or in addition to) self-service, register WhatsAppActivator for 'register' in app/Config/Auth.php:

    public array $actions = [
        'register' => \WhatsAppMfa\Authentication\Actions\WhatsAppActivator::class,
        'login'    => \WhatsAppMfa\Authentication\Actions\WhatsAppMfa::class,
    ];

    Then add WhatsAppActivator's own routes from routes-snippet.php

    • including its "skip" route by default. The enrollment/verify views always render a "skip for now" link, regardless of whether this route exists - omitting it is safe (the views detect a missing route and simply hide the link, rather than throwing when the very first user registers), but you'd be silently taking away a "skip" option from every new user unless that's actually what you want (e.g. because MFA is mandatory for everyone via shield-mfa-dispatcher's $required/$requiredMethodsForGroups). If you deliberately don't want "skip" offered at all, omitting the route is now enough on its own - you don't need to also edit the views.

Self-service phone verification

WhatsAppSettingsController gives an already-logged-in user their own account/whatsapp page to verify (or change, or remove) a WhatsApp number - the same "add this later" model shield-totp-mfa and shield-passkey-mfa use for their own methods. The flow:

  1. account/whatsapp/enroll - enter a phone number.
  2. account/whatsapp/send - a 6-digit code is sent to it via whichever sender you've configured (the same $config->sender the login action itself uses).
  3. account/whatsapp/verify → .../confirm - enter the code; on success, the number becomes the permanent, verified one WhatsAppMfa::resolvePhoneNumber() reads by default.

This is deliberately a separate concern from the per-login OTP code - see PhoneNumberStore's class doc comment for the full explanation, but in short: WhatsAppMfa::createIdentity() generates a fresh, short-lived code every single login attempt (a whatsapp_mfa identity), while PhoneNumberStore manages one permanent, verified number that only changes when the user explicitly re-verifies a new one - stored via CodeIgniter's own Settings library (a per-user context), not as a Shield identity record. See "Why the verified phone number is stored via Settings, not a Shield identity" further down for why - a real, confirmed bug with a Shield-identity-based approach, fixed in the current version.

This is NOT wired into Shield's own pending-action check the way TOTP/passkey enrollment is - verifying a phone number doesn't make WhatsApp MFA "the" login method for anyone by itself. If you're using shield-mfa-dispatcher, a user still needs to separately choose 'whatsapp' as their preferred method from the dispatcher's own settings page after verifying their number here. If WhatsAppMfa is registered directly as Auth::$actions['login'] (not via the dispatcher), it already runs unconditionally for every login attempt regardless of this page - self-service verification just controls which number it sends to, not whether it runs at all.

Why the verified phone number is stored via Settings, not a Shield identity

Fixed in the current version - update if you're on an older copy. An earlier version of PhoneNumberStore stored the permanent, verified phone number as a Shield identity record, with the actual phone number in the secret column - reusing Shield's own auth_identities table the same way every package in this series reuses it for its own per-user data.

That table's UNIQUE constraint, however, is on (type, secret) - not (user_id, type, secret). Two different accounts both verifying the same phone number - one person with two accounts, or a shared family phone - produced two rows with the identical (type='whatsapp_phone', secret='+15551234567') pair, and the second account's own, entirely unrelated verification failed with a duplicate-key database error. This is Shield's own base schema, not something this package should (or safely could) alter - the exact same bug, and the exact same fix, found and applied to shield-mfa-dispatcher's MfaPreference (see that package's README for the fuller account of the same underlying pattern).

PhoneNumberStore now stores the verified number through CodeIgniter's own Settings library instead, using its per-user context mechanism - each user's number is stored and looked up under its own context string, with no shared uniqueness constraint between different users' rows at all. Nothing about PhoneNumberStore's own public API changed - only its internal storage mechanism for this one identity type specifically. The other three identity types (ID_TYPE_PHONE_PENDING, ID_TYPE_PHONE_ACTIVATE, ID_TYPE_PHONE_STEP_UP) remain real Shield identity records - see PhoneNumberStore's own class doc comment for why each one is safe to remain one, or (for ID_TYPE_PHONE_ACTIVATE) needs to.

A second, more severe bug found and fixed alongside it

While fixing the above, a related but more severe bug was found in ensureActivationMarker() (the registration-in-progress marker WhatsAppActivator creates): it stored a fixed literal string ('n/a') as the secret, reasoning that "it's never read, only the marker's existence matters." That reasoning missed the same UNIQUE(type, secret) constraint - any two people registering at roughly the same time, regardless of phone number, would both produce (type='whatsapp_phone_activate', secret='n/a'), an identical pair. Unlike the phone-sharing bug above, this one needed no coincidence at all - it's a real risk for any live, multi-user app. Fixed by randomizing the value (bin2hex(random_bytes(8))), matching the pattern shield-passkey-mfa's own activation marker already used correctly. This identity type still needs to remain a real Shield record (Shield's own pending-check queries it directly), so it wasn't moved to Settings the way the verified phone number was - only its secret's content needed to change.

If you get a "must implement getType, createIdentity" fatal error

This package was written against a version of ActionInterface that only required show()/handle()/verify(). Current Shield versions also require getType() (returns the identity type string this action uses - 'whatsapp_mfa' here) and createIdentity(User $user): string (generates the code, stores it, and returns the plaintext value so the caller can send it). Both are implemented in WhatsAppMfa.php - handle() now calls $this->createIdentity($user) rather than doing that work inline, matching how Shield's own Email2FA separates "create the code" from "send the code". If you're reading this having pulled an older copy of this file, update it from the current version.

Login completion: completeLogin(), not login()

verify() calls auth('session')->getAuthenticator()->completeLogin($user) to finish the pending login. This was originally a guessed placeholder (login($user)), flagged as something to double-check - and the TotpMfa package (built later in this series) hit the actual failure mode: Shield's login() is a stricter, separate public method (used for things like remember-me auto-login) that refuses if it finds leftover identities for the pending action type, with an error like "The user has identities for action, so cannot complete login." completeLogin() is the method Shield's own Session::attempt() actually calls to finish a pending action, and doesn't have that restriction. Confirmed against Shield v1.3.0's real source; still worth a quick sanity check against whichever version you're running if you hit login issues after upgrading Shield - vendor/codeigniter4/shield/src/Authentication/Authenticators/Session.php.

Switching providers

Config/WhatsAppMfa.php has a single $sender property:

public string $sender = \WhatsAppMfa\Sender\MetaCloudApiSender::class;
// or
public string $sender = \WhatsAppMfa\Sender\TwilioWhatsAppSender::class;

To use a different provider entirely (360dialog, Vonage, an in-house gateway), implement WhatsAppSenderInterface (one method: send()) and point $sender at your class. The Action itself never talks to any provider API directly.

Sending via SMS instead of WhatsApp (Twilio only)

TwilioWhatsAppSender can deliver the code via plain SMS instead of WhatsApp - useful if your users don't reliably have WhatsApp, or you'd rather not depend on it. Set:

public string $channel = 'sms'; // default is 'whatsapp'

This is an app-wide toggle, not a per-user choice - every user gets whichever channel is configured. If you need different users on different channels, this package doesn't support that today; you'd need your own sender implementing the branching yourself.

The same $twilioFromNumber is used for both channels - this class strips or adds the whatsapp: prefix on that configured value as needed, rather than requiring a second number configured specifically for SMS. This assumes your Twilio number is capable of both channels, which is common (many Twilio numbers are both WhatsApp-enabled and SMS-capable) but not universal - if your WhatsApp-approved sender and your SMS-capable number are genuinely two different numbers on your Twilio account, update $twilioFromNumber itself to the SMS-capable one before switching $channel to 'sms', since this class has no way to know about a second number it was never given.

$twilioContentSid (WhatsApp's Content Template requirement) is ignored entirely when $channel is 'sms', regardless of whether it's set - SMS has no equivalent restriction on free-form text outside a session window, and Twilio's own SMS API doesn't support ContentSid/ContentVariables at all.

Every user-facing string that says "WhatsApp" is channel-aware automatically - sendIntro, sendButton, settingsHeading, phoneLabel, and others all use a {channel} placeholder rather than hardcoding "WhatsApp", substituted at render time by WhatsAppMfa\Libraries\ChannelLabel (a plain, autoloaded class - deliberately not a global helper function requiring an explicit helper() call, since this package has already hit two real bugs from exactly that pattern - see "If you get 'Call to undefined function...'" below). Switch $channel to 'sms' and every page correctly says "SMS" instead, without editing the language file yourself - though you can still override channelLabel_sms/channelLabel_whatsapp in your own app's language file if you'd prefer different wording (e.g. "text message" instead of "SMS").

If you're using shield-mfa-dispatcher, its own settings page (account/mfa) needs one extra line to stay in sync. That page has no idea this package - or WhatsApp, or SMS - exists; it only shows a static label per method by default, which would leave it saying "WhatsApp code" regardless of your $channel setting. Wire this package's own ChannelLabel into its $methodLabelResolvers config property to fix that:

// app/Config/MfaDispatcher.php
public array $methodLabelResolvers = [
    'whatsapp' => [\WhatsAppMfa\Libraries\ChannelLabel::class, 'current'],
];

See shield-mfa-dispatcher's own README ("A method's own label can reflect something that changes at runtime") for why this is a config entry you add yourself, rather than something wired in automatically - that package stays deliberately ignorant of what any given method actually is.

Migrating an existing user base from SMS to WhatsApp (or back) safely

The risk, confirmed by a real report: a user's own preference for this method ('whatsapp', the method key) is stored independently of which channel actually delivers it. Someone who set this as their preference while $channel was 'sms' keeps that same preference after you switch to 'whatsapp' - it takes effect at their very next login, with no grace period at all. If their number was only ever confirmed to receive SMS, not WhatsApp specifically, a login attempt can now silently fail: Twilio's WhatsApp API accepts the send request regardless of whether the destination number can actually receive WhatsApp - a real, documented failure mode (Twilio's own error 30013, "Recipient not on WhatsApp") only surfaces later, via an asynchronous delivery-status webhook this package doesn't implement - so from this package's own point of view, the send looked like it succeeded, even though the code never arrived.

The recommended path: test WhatsApp delivery individually, per user, before switching

The self-service settings page (account/whatsapp) has a "Test WhatsApp delivery" action, available whenever $channel is currently 'sms' and the configured sender is TwilioWhatsAppSender (if $channel is already 'whatsapp', or you're using a sender with no channel concept at all, the normal verify flow already tests WhatsApp directly, so this action doesn't appear at all - there's nothing extra to test). It sends a real WhatsApp message - via TwilioWhatsAppSender::sendViaWhatsAppRegardlessOfChannel(), independent of whatever $channel is actually configured to - and, on success, records that the user's number is confirmed to work over WhatsApp specifically. This is a separate record from the main verified number, so a user can test WhatsApp delivery at any time, well before you ever touch $channel, without it affecting their actual login method at all until you switch.

This solves the chicken-and-egg problem the single-channel-switch approach has: rather than switching $channel for everyone at once and hoping their numbers work, users can confirm WhatsApp delivery individually, whenever it suits them, and you only flip the switch once you know how many are actually ready.

php spark whatsapp-mfa:channel-status gives you that visibility - a read-only command listing every user with a verified number, whether WhatsApp delivery has been confirmed for it, and a summary count. Run it with --unconfirmed-only to see just the users who still need to test. It's genuinely read-only: it never sends anything or changes any record, so it's safe to run as often as you like while migrating.

Fixed in the current version, following a real report: this command previously produced no output at all, even with confirmed users on file. The root cause - PhoneNumberStore::listVerificationStatuses() queried the codeigniter4/settings package's own settings table directly, via db_connect() with no database group specified. If your app's actual Settings storage uses a different database group than whichever one db_connect() treats as "default" - or a customized table name - that query would silently return zero rows, no error at all. Rather than continuing to guess at the exact mismatch, this method now reuses getVerifiedPhoneNumber()/hasConfirmedWhatsAppDelivery() directly - the exact same, already-proven service('settings') calls every other part of this class already depends on - so there's no second, separate assumption about the Settings library's own storage details left to get wrong. The trade-off: this loads every user via UserModel::findAll() rather than querying only the ones with a verified number directly, doing more work than strictly necessary for a very large user base - acceptable here specifically because this is an occasional, developer-run diagnostic command, not something called on every request.

Confirming which user's number matches which record deliberately compares the actual number, not just a boolean flag. If a user later changes their verified number after confirming WhatsApp delivery for an earlier one, hasConfirmedWhatsAppDelivery() correctly reports false for the new, untested number - there's no separate step you need to remember to re-run when a number changes.

This is deliberately kept off shield-mfa-dispatcher's own settings page entirely, even if you're using both packages together. This is an operational, developer-facing migration tool for moving your user base between channels in a controlled way - not a normal end-user MFA concept a regular user needs to understand, so it stays specific to this package's own settings page.

The older, still-valid fallback: re-verify via the normal flow

If you're not using TwilioWhatsAppSender's channel toggle at all (a custom sender, or MetaCloudApiSender), or simply prefer not to add the test flow, the normal verify flow still provides a safe path, just requiring the switch to happen first:

beginVerification() only ever writes to a separate, temporary pending record; the permanent verified number (stored via Settings) is only overwritten by confirmVerification(), and only once a code has actually been confirmed. This means starting a fresh verification attempt - even re-entering the exact same number a user already has - never touches their existing, working verified number unless the new attempt actually succeeds. Concretely:

  1. Switch $channel to 'whatsapp'.
  2. Before relying on it for anyone, have each existing user re-verify their number via the self-service settings page (the same "change number" flow, re-entering the number they already have) - while they're still fully logged in via whatever method currently works for them, not during a login challenge.
  3. If the WhatsApp message arrives and they confirm it, their number is now proven to actually work, and their permanent record is safely re-set to the same value.
  4. If it doesn't arrive, nothing about their account changes - their existing, working verified number stays completely intact, since confirmVerification() never ran. They're still logged in, not locked out, and can retry, switch their own MFA preference to a different method, or flag it to you - all while still authenticated, rather than discovering the problem stuck at a login screen.

The test-flow approach above avoids this fallback's own main drawback - everyone being affected by the $channel switch simultaneously, rather than confirmed individually beforehand.

If you're also using shield-mfa-dispatcher's $requiredMethodsForGroups, be extra careful with the order here, regardless of which path you use above. Don't add this method as required for any group until you've confirmed that everyone currently in that group actually has it working. A required method overrides a user's own preference entirely, which removes the fallback safety net this migration path otherwise depends on - if it's required and their number turns out not to work, they have no way to fall back to a different method at all.

The same reasoning applies in reverse if you ever migrate back from WhatsApp to SMS - the test-flow action above is specific to testing WhatsApp, so for that direction, re-verifying via the normal flow (the fallback approach above) is the way to confirm SMS delivery still works for a given number, before an app-wide switch takes effect for everyone at once.

Security notes

  • Codes are hashed with password_hash() before storage — never stored or logged in plaintext.
  • Codes expire ($config->codeLifetime, default 5 minutes) and are single-use (deleted on successful verify, and any older pending code is deleted before a new one is issued).
  • Consider adding rate limiting on the auth/a/handle (resend) and auth/a/verify (guess attempts) routes via CodeIgniter's throttle filter, the same way Shield already rate-limits login attempts.
  • WhatsApp delivery is not end-to-end guaranteed instant — a code should still expire and be resendable rather than assumed to arrive immediately.

A user with an already-verified number was still routed into enrollment - fixed

Fixed in the current version. Carried over from a confirmed, real fix in shield-passkey-mfa: if you're pairing this package with shield-mfa-dispatcher (register = WhatsAppActivator::class, login = MfaDispatcher::class), a user who had already verified a WhatsApp number could still be shown WhatsAppActivator's own enrollment prompt on a later, ordinary login - despite shield-mfa-dispatcher's own resolution logic correctly recognizing them as already enrolled. Log tracing in the passkey package's own investigation confirmed Shield itself was routing straight to the register slot's activator, never reaching the login slot's action at all for that request.

Confirmed against Shield's own official documentation on Auth Actions: a custom action can implement ConditionalActionInterface's appliesTo(User $user): bool to tell Shield directly whether it should be considered pending for a given user at all - "when appliesTo() returns false, Shield does not start the action and ignores stored identities for that action while the condition remains false." WhatsAppActivator didn't implement this. The likely mechanism (not fully traced through Shield's own source - an honest caveat, not a fully root-caused claim): PhoneNumberStore::ID_TYPE_PHONE_ACTIVATE is a temporary marker created before a phone number is even known; if it's never cleaned up once registration completes, Shield could keep finding a match for the register slot's own type indefinitely.

Fixed: WhatsAppActivator now implements ConditionalActionInterface, returning false from appliesTo() once the user already has a verified number. Genuine test coverage here - WhatsAppActivatorTest completes a real verification (using PhoneNumberStore::beginVerification()'s own returned code directly, no message actually needing to be "sent") and confirms appliesTo() correctly returns false afterward.

A sender failure left an orphaned pending record behind - fixed

Fixed in the current version. A real report: a Twilio API error mid-send crashed the whole request, and the pending verification record beginVerification() had already created was left behind indefinitely - still present even after the user later completed verification successfully through a separate attempt.

Root cause, confirmed directly in the code: every place this package calls a configured sender - WhatsAppActivator::handle() (registration), WhatsAppMfa::handle() (login), WhatsAppSettingsController::send() (self-service), and WhatsAppStepUpController::send() (step-up) - created its own pending/temporary record first, then called $sender->send(...) with no try/catch at all. A thrown exception (a real Twilio error, in the reported case, but this applies to any sender failure - network issues, provider outages, a misconfigured API key) propagated straight through, uncaught, crashing the request. The pending record was left orphaned, since confirmVerification() (the only code that would otherwise delete it) never got a chance to run - the user never even received a code to enter.

This explains a related, confusing symptom some installs may have hit: a fully-verified user who still has an old whatsapp_phone_pending and/or whatsapp_phone_activate row left over in auth_identities, from an earlier crashed attempt that was never cleaned up, sitting alongside their (unaffected, working) verified number. Both temporary row types are otherwise correctly deleted on every successful verification (see "Why the verified phone number is stored via Settings, not a Shield identity" above) - this gap was specifically about the crash path, not the success path.

Fixed: all four call sites now wrap the sender call in try { ... } catch (\Throwable $e) { ... }, rolling back whatever was just created (PhoneNumberStore::cancelVerification()/the new cancelStepUp()/the login-time identity directly) and showing WhatsAppMfa.sendFailedMessage instead of crashing. \Throwable is used deliberately, not RuntimeException - several of these files already import CodeIgniter\Shield\Exceptions\RuntimeException under that exact name for other purposes, which would silently fail to catch the plain, global RuntimeException a sender actually throws. Tests\WhatsAppMfa\Support\FakeWhatsAppSender gained a $shouldFail toggle specifically to test this without needing a real provider failure, and each of the four call sites has its own regression test confirming the rollback.

Not fixed by this change - a real, separate cleanup step: any orphaned rows already sitting in an existing installation's database from before this fix won't disappear on their own. If you're affected, find and delete the stale whatsapp_phone_pending/whatsapp_phone_activate rows for the affected user_id directly - they're inert once orphaned (the permanent verified number lives via Settings, untouched by any of this), but harmless clutter is still clutter.

A generic failure message gave no way to tell why - fixed

Fixed in the current version, following a real report. Every one of the five sender-failure catch blocks above (the four listed there, plus WhatsAppSettingsController::testSend() - the WhatsApp channel-test flow's own send call) previously caught the real exception and discarded it entirely, showing only a generic "please try again" message. A real report confirmed this made it genuinely impossible to tell why a send was failing - an invalid or unapproved WhatsApp sender number, a missing Content Template (a real, common cause the first time you test - see "Sending via SMS instead of WhatsApp" above for why WhatsApp specifically requires one outside a 24h session window), a recipient who hasn't joined your Twilio sandbox, bad credentials - without adding temporary debugging code to find out.

Fixed: a new WhatsAppMfa\Libraries\DiagnosticLog class, matching the identical one already used by shield-passkey-mfa and shield-mfa-dispatcher in this same series - gated to only ever write when ENVIRONMENT is 'development', so production logs aren't polluted by this. All five catch blocks now log the actual exception message via this class. The flash message shown to the user also gets a [diagnostic: ...] suffix, but only in a development environment - the same pattern shield-passkey-mfa's own PasskeyMfa::verify() already uses, for the same reason: seeing the real failure reason directly on the page is the fastest way to diagnose a problem while you're actively testing, without needing to go check a log file at all - but genuinely internal detail like this is never something a real user in production should see.

If you're seeing the generic "we couldn't send a test WhatsApp message" message yourself while testing, set CI_ENVIRONMENT=development (or check your app's log with ENVIRONMENT already set to 'development') and try again - the actual reason will now be visible either directly on the page or in your log, rather than hidden entirely.

If the page loads but shows nothing at all

The views in this package wrap their content in a section named 'main', matching what Shield's own layout (setting('Auth.views')['layout']) actually renders - Shield's own login.php uses the same name. An earlier version of these files used a section called 'content' instead, which that layout never displays: the page loads without any error (nothing is actually wrong, syntactically), it just never gets inserted into the page, so you'd see a blank content area with no log entry to explain it. If you still see nothing after updating, check whether you're using a custom Auth.views['layout'] and confirm what section name it renders.

Overriding views

Every view this package renders is looked up through Config\WhatsAppMfa::$views, the same pattern Shield itself uses for Config\Auth::$views. To use your own view instead of a default, override its entry in your app/Config/WhatsAppMfa.php:

public array $views = [
    'whatsapp_mfa_show' => 'App\Views\auth\my_whatsapp_show',
    // any key you don't list keeps using this package's default
];

Your replacement doesn't need to live under any particular namespace - anywhere view() can resolve works. It does need to accept the same variables the default expects; check the matching file under src/Views/ for exactly what's passed. The overridable keys: whatsapp_mfa_show and whatsapp_mfa_verify (login), and whatsapp_settings_index, whatsapp_settings_enroll, and whatsapp_settings_verify (self-service settings).

If you get "Declaration must be compatible" when loading this class

An earlier version of handle() declared a : string return type, written before ActionInterface's actual current contract (confirmed via the shield-totp-mfa package's real test suite) was known: handle() must return Response, not a raw string. That mismatch would fatal the moment this class is ever loaded under a Shield version with that contract - it went unnoticed because this package had never actually been exercised end-to-end until tests were written directly against it. Fixed: handle() now builds the view body and returns it via service('response')->setBody($body), matching show()'s own confirmed-correct pattern in the other packages in this series. If you're on an older copy of this file, replace it.

If you get "Call to undefined function WhatsAppMfa\Libraries\random_string()"

Fixed in the current version - update if you're on an older copy. Both PhoneNumberStore::beginVerification() and WhatsAppMfa::createIdentity() used to call CI4's random_string() text helper to generate the 6-digit code, without ever calling helper('text') first - that helper isn't autoloaded by default, and nothing else in either class loaded it. This is a real bug that broke both methods in a real app, not a hypothetical one.

Worth knowing if you're wondering why the existing test suite didn't catch this despite exercising both methods extensively: PHP function definitions, once loaded via helper(), stay loaded for the rest of that process - something else running earlier in the same shared PHPUnit process most likely loaded the text helper for an unrelated reason, masking the bug there while it broke a real app directly. This is exactly the kind of load-order fragility that's easy to not notice in a test environment - fixed by removing the dependency on the helper entirely (a small, self-contained generateCode() method using only random_int()), not just adding the missing helper('text') call, which would still leave this fragile to whatever happens to run first.

If you get "Can't find a route for 'GET: auth/a/handle'" after entering a code

Fixed in the current version - update if you're on an older copy. This was a real, confirmed bug in WhatsAppMfa::verify() and WhatsAppActivator::verify()'s error handling, not something specific to your setup.

The code-entry page is rendered directly by handle() - a POST response to auth/a/handle, with no redirect in between - so the browser's address bar stays on that POST-only route while the user is looking at the form. redirect()->back() targets wherever the browser was last on, which is that same URL; browsers only ever follow a redirect via GET, and there's no GET route registered at auth/a/handle - hence the error, the moment a wrong or empty code was submitted.

TotpMfa/PasskeyMfa never had this problem, and don't need this fix: their verify forms are rendered by show() itself (a GET route), not by a separate handle() step, so back() correctly lands on a real, GET-accessible page for them. WhatsApp's flow has an extra step (send the code, then show the form) that TOTP/passkey don't, which is exactly what created the gap.

Fixed by redirecting to the named auth-action-show route explicitly instead of back() - already the pattern this same method used correctly for its other error cases (an expired or already-consumed code), just inconsistently missed for a wrong or empty one. Worth knowing about the one UX trade-off this introduces for WhatsAppActivator specifically: since its show() renders the phone entry form (not a code-retry), a wrong code during registration sends the user back to re-entering their number rather than just retrying - correct and safe, if a little more friction than ideal.

Also worth being upfront about why the existing test suite never caught this either: every test in this whole series calls these methods directly rather than through real HTTP, so redirect()->back()'s actual behavior (which depends on session-tracked "previous URL" state that only a real browser request populates) was never once genuinely exercised by any test here. Regression tests have been added that check the redirect target explicitly instead.

Step-up auth for sensitive pages (RequireFreshWhatsApp filter)

Everything above concerns login. This is different: a route filter that forces a fresh WhatsApp challenge before reaching a specific page, even for a user who's already fully logged in - useful for gating sensitive actions (updating payment/API settings, changing an email address, etc.) behind re-confirmed identity, the way Stripe, GitHub, and AWS all do before letting you touch billing or security settings. Mirrors shield-totp-mfa's own RequireFreshTotp filter - see that package's README for more detail on the design; the short version is repeated here, plus what's different about WhatsApp specifically.

Setup

  1. Register the filter alias in app/Config/Filters.php:

    public array $aliases = [
        // ... your existing aliases
        'whatsapp-fresh' => \WhatsAppMfa\Filters\RequireFreshWhatsApp::class,
    ];
  2. Add the step-up challenge routes from routes-snippet.php (already included if you copied the whole snippet earlier).

  3. Apply it to whichever routes need protecting, alongside your normal login-required filter:

    $routes->group('admin/billing', ['filter' => ['session', 'whatsapp-fresh']], static function ($routes) {
        $routes->get('stripe-settings', 'Admin\BillingController::index');
        $routes->post('stripe-settings', 'Admin\BillingController::update');
    });

That's it - a user reaching admin/billing/stripe-settings without a recent-enough WhatsApp challenge gets sent to a short challenge flow first, then bounced back to where they were headed.

Three steps, not two - unlike shield-totp-mfa/shield-passkey-mfa

TOTP and passkey step-up challenges are a single page: show the form, submit, done - there's nothing to "send" first. WhatsApp needs an explicit send step, the same way the login action does: WhatsAppStepUpController::show() ("we're about to send a code to the number ending in...") → send() (generates + sends a fresh code, renders the code-entry form) → verify() (checks it, stamps the step-up session).

This challenges the user's already-verified number specifically - PhoneNumberStore::beginStepUpChallenge()/verifyStepUpChallenge() use a dedicated identity type (ID_TYPE_PHONE_STEP_UP), deliberately separate from the phone-verification flow's own type. A step-up challenge in progress and a "change my number" attempt in progress don't interfere with each other, even if a user somehow has both open at once.

How freshness works

A timestamp is stashed in session the moment a challenge succeeds. Subsequent requests to any whatsapp-fresh-protected route within $config->stepUpFreshnessSeconds (default 15 minutes) pass straight through without asking again; after that window, the next protected page reached asks again.

What happens if the user has no verified number at all

By default, RequireFreshWhatsApp lets them through - there's nothing to challenge them with, so the filter doesn't lock them out of a page they have no way to unlock. If you'd rather force verification before such pages are reachable at all, set:

public bool $stepUpRequiresEnrollment = true;
public string $stepUpEnrollRouteName  = 'whatsapp-settings-enroll'; // or
                                        // 'mfa-settings-whatsapp-enroll'
                                        // if using shield-mfa-dispatcher

This is separate machinery from the login Action, deliberately

RequireFreshWhatsApp/WhatsAppStepUpController don't touch Shield's ActionInterface/pending-login mechanism at all - they're an ordinary CodeIgniter filter and controller operating on auth()->user(). See shield-totp-mfa's README for the fuller explanation of why step-up auth is deliberately kept out of the Action system entirely.

Built with the redirect()->back() fix already in place

WhatsAppStepUpController::verify()'s error path redirects to the named whatsapp-step-up route, never back() - see the confirmed bug this exact pattern caused in the login action's own verify() (documented earlier in this README) for why. The step-up flow has the identical shape (a code-entry page rendered by a POST response, no redirect in between), so it was built with that fix from the start rather than needing it discovered here separately.

Tests

If you're using shield-mfa-dispatcher (or anything else that makes Config\Auth::$actions point at something other than WhatsAppMfa/WhatsAppActivator directly): the confirmed fixes shield-totp-mfa needed for this exact same architecture (session leakage between test methods, resetServices()'s own route-wiping side effect, and reflection-based pending-state simulation for the registration-time activator once 'login' points elsewhere) are applied here too - see shield-totp-mfa's README and its TotpMfaTest/TotpActivatorTest class doc comments for the full, diagnostic-backed account; not repeated here in full since the mechanism is identical. WhatsAppMfaTest and WhatsAppActivatorTest have the complete fix; RequireFreshWhatsAppTest, WhatsAppStepUpControllerTest, and WhatsAppSettingsControllerTest have the defensive Services::routes()->loadRoutes() piece only, since they use actingAs() rather than attempt() and were never affected by the session/pending-state issues specifically.

tests/WhatsAppMfa/ covers WhatsAppMfa (handle()/verify(): code generation and sending via a fake sender, so no real network call ever happens, plus correct/wrong/empty/expired code handling), WhatsAppActivator (the registration-time counterpart - phone entry, send, verify, skip), PhoneNumberStore (including the WhatsApp channel-test flow's own tracking - see "Migrating an existing user base" above), the self-service settings controller (including its own test-flow actions), the step-up auth filter/controller, TwilioWhatsAppSender's own channel-switching field-building logic ($channel = 'whatsapp' vs 'sms') and its forced-WhatsApp sending (sendViaWhatsAppRegardlessOfChannel()), ChannelLabel (the {channel} placeholder substitution that feature relies on), and the whatsapp-mfa:channel-status CLI command itself (via CI4's own StreamFilterTrait, capturing real command output rather than only testing the underlying data method in isolation).

tests/WhatsAppMfa/
  Support/FakeWhatsAppSender.php    <- records what would have been sent, no real network call -
                                        $shouldFail simulates a real provider failure, for testing
                                        the sender-failure rollback fix (see that section above)
  Support/TestableWhatsAppMfa.php   <- fixes the phone number for testing, since Shield's
                                        stock User entity has no phone column
  Support/TestableTwilioWhatsAppSender.php <- exposes TwilioWhatsAppSender's protected
                                                buildFields()/forceWhatsAppChannel() for direct
                                                testing, no HTTP call needed
  Support/FakeTwilioWhatsAppSender.php <- a TwilioWhatsAppSender SUBCLASS (not the more general
                                            FakeWhatsAppSender) overriding only send() itself, so a
                                            test exercises the real sendViaWhatsAppRegardlessOfChannel()/
                                            forceWhatsAppChannel() logic while avoiding a real network
                                            call - needed since the controller's own relevance check
                                            requires an actual TwilioWhatsAppSender subclass
  Authentication/Actions/WhatsAppMfaTest.php
  Authentication/Actions/WhatsAppActivatorTest.php <- registration-time counterpart, including
                                                        the $wasAlreadyActive / forced-setup-reuse fix
  Libraries/PhoneNumberStoreTest.php       <- pending-to-permanent record lifecycle, step-up challenges,
                                               regression tests for the fixed duplicate-key bugs, and
                                               the WhatsApp channel-test tracking (including staleness
                                               when the verified number later changes)
  Libraries/ChannelLabelTest.php           <- {channel} placeholder resolution/substitution
  Controllers/WhatsAppSettingsControllerTest.php <- enroll/send/verify/confirm/disable, plus the
                                                      test-flow actions and their own relevance guard
  Filters/RequireFreshWhatsAppTest.php     <- step-up freshness/enrollment logic
  Controllers/WhatsAppStepUpControllerTest.php <- step-up show/send/verify, including a
                                                    regression test for the back()-vs-route() fix
  Sender/TwilioWhatsAppSenderTest.php      <- WhatsApp vs SMS field-building (To/From prefixing,
                                                Content Template ignored entirely for SMS), plus
                                                forceWhatsAppChannel()'s own override behavior
  Commands/ChannelStatusTest.php           <- the whatsapp-mfa:channel-status CLI command itself

Fixes from running the suite on CodeIgniter 4.7 / PHP 8.5

  • Class "Tests\WhatsAppMfa\Support\FakeWhatsAppSender" not found (and the same for TestableTwilioWhatsAppSender). A typical CodeIgniter app only autoloads Tests\Support\ from tests/_support, and PHPUnit loads only *Test.php files itself. Each test file now uses require_once to load the support classes it needs. If you'd rather autoload them, add "Tests\\": "tests/" to autoload-dev in your app's composer.json. The require_once lines are harmless either way.
  • Table 'users' doesn't exist in ChannelStatusTest. This was the only database test missing protected $namespace = null;, so only the default Tests\Support migrations ran and Shield's tables were never created. It now has that line.
  • A verified number from one test leaking into the next. This showed up in PhoneNumberStoreTest and in the "unverified user passes through" step-up test. PhoneNumberStore keeps numbers in the Settings library, which caches every value it has read on the shared settings service. $refresh resets the database between tests but not that cache, and user ids start at 1 again after each refresh. A number verified for user:1 in one test was therefore still returned for the next test's brand-new user:1. Every database-backed test now calls Services::resetSingle('settings') in setUp(). This only affects tests: a real request gets a fresh service anyway.
  • Username too long. Shield's users.username is VARCHAR(30), and uniqid() adds 13 characters. The longer prefixes are now waactest, wasettest and wasutest.
  • --unconfirmed-only ignored unless typed on the real command line (a real bug, fixed in src/Commands/ChannelStatus.php). The command read the flag only through CLI::getOption(), which sees the actual process's arguments. When the command was run any other way, through command('whatsapp-mfa:channel-status --unconfirmed-only') (as its test does) or $this->call() from another command, the flag was silently dropped and every user was listed. Typing it after php spark always worked. The command now also checks $params, where CodeIgniter passes options in every case.
  • Tests asserting on text the views had since changed. Two tests still looked for the raw {channel} number / You haven't verified a {channel} number yet. strings. The views run those through ChannelLabel::inject(), so the page actually says "WhatsApp" (or the SMS label), and the tests now compare against the same substituted text. Two others looked for 1234567 in pages that deliberately show the number masked to its last four digits (********4567). They now check for the masked form, and that the full number is not on the page.
  • POST data invisible on CodeIgniter 4.7+. From 4.7, a request reads POST data from a shared superglobals snapshot, taken the first time anything touches the request. The tests' request helpers now also call $request->setGlobal('post', $post), which works on 4.6 and 4.7.

Setup

  1. Copy tests/WhatsAppMfa into your app's own tests/ folder, the same way src/ gets installed - see "Installation" above.

  2. Make sure your test database has Shield's own migrations applied (users, auth_identities, etc.) - protected $namespace = null; in the test class triggers this automatically, equivalent to php spark migrate --all, as long as the connection itself works.

  3. Run it the same way as the rest of your suite:

    vendor/bin/phpunit tests/WhatsAppMfa
    

Why two different testing patterns in this one suite

WhatsAppMfaTest and WhatsAppActivatorTest (the login and registration-time actions) use a real Session::attempt() call with real credentials to put the authenticator into a genuinely pending login state - actingAs() puts it into a fully logged in state instead, a different thing from what getPendingUser() checks for; this was worked out through several rounds of trial and error, documented in shield-totp-mfa's own TotpMfaTest. WhatsAppSettingsControllerTest uses actingAs() instead, because that controller is for someone already fully logged in managing their own settings (auth()->user(), not getPendingUser()), which is exactly the state actingAs() produces.

WhatsAppActivatorTest can test something PasskeyActivatorTest can't

Both packages' activator classes gained the same fix for shield-mfa-dispatcher's forced-setup reuse: verify() now checks whether the user was already active before deciding where to redirect (see shield-totp-mfa's README, "Design decisions worth knowing about", for the full explanation). PasskeyActivatorTest can't exercise that branch directly - it only runs after a genuinely valid signed WebAuthn response, which the test suite can't produce (see PasskeyIdentityStoreTest's class doc comment in that package). This package's equivalent check can be tested directly (testVerifyForAnAlreadyActiveUserRedirectsToLoginNotRegistration), since WhatsApp's code verification is plain 6-digit matching, not real cryptography - the same reason shield-totp-mfa's TotpActivatorTest can test it too.

Why a fake sender and a phone-number override

FakeWhatsAppSender replaces whatever's configured in Config\WhatsAppMfa::$sender for the duration of each test, so running this suite never sends a real WhatsApp message or calls a real Meta/Twilio API - used by both WhatsAppMfaTest and WhatsAppSettingsControllerTest. TestableWhatsAppMfa (used only by WhatsAppMfaTest) overrides resolvePhoneNumber() to a fixed test number for the login-action tests specifically - not needed for the settings controller tests, since those exercise PhoneNumberStore directly rather than going through resolvePhoneNumber()'s fallback chain.

If you hit "Declaration must be compatible" loading WhatsAppMfa

See "If you get 'Declaration must be compatible'..." earlier in this README - fixed in the current version of WhatsAppMfa.php.