Verification Flows

February 22, 2026 · View on GitHub

This document explains when and how the plugin applies verification, and describes every flow a user can go through.


When verification runs

The VerificationComponent listens on Controller.startup (configurable). On every request it checks the authenticated identity against the configured requiredSetupSteps. If any step is pending the user is redirected automatically — they cannot reach any other controller action until all steps are complete.

Actions that must be reachable without a completed verification (e.g. the verify form itself) are whitelisted with allowUnverified():

$this->Verification->allowUnverified([
    'login', 'logout', 'register',
    'verify', 'enroll', 'enrollPhone',
    'pending', 'verifyEmail', 'resendEmailVerification',
    'chooseVerification',
]);

Setup flow (runs once after registration)

This flow runs the first time a user accesses the app after registering.

register()
  └─ save user
  └─ afterRegister()        ← sends email verification link; user NOT logged in
  └─ redirect to login page

login()
  └─ user submits credentials
  └─ Authentication identifies user
  └─ requiresNextStep() → true
  └─ redirect → next pending step

Step 1 — email verification (emailVerify)

emailVerify always runs first and blocks all other steps until confirmed.

User receives email with a signed link
  └─ clicks link → verifyEmail()
       └─ saves email_verified_at
       └─ user is NOT logged in → redirect to login page

User logs in
  └─ next pending step resolved

Step 2 — OTP enrollment

After email is confirmed, the plugin resolves the next pending OTP step.

Single OTP driver configured (requiredSetupSteps = ['emailVerify', 'emailOtp']):

login()
  └─ redirect → verify() with emailOtp step
       └─ code sent automatically on GET
       └─ user enters code → marked as enrolled
       └─ redirect → onVerifiedRoute (app)

Multiple OTP drivers configured (requiredSetupSteps = ['emailVerify', 'emailOtp', 'totp']):

login()
  └─ redirect → chooseVerification()   ← user picks their preferred method
       └─ saves chosen driver to verification_preferences
       └─ redirect → next pending step for chosen driver

  ┌─ emailOtp chosen ──────────────────────────────────────────┐
  │  verify()                                                   │
  │    └─ OTP sent by email                                     │
  │    └─ user enters code → enrolled → onVerifiedRoute        │
  └─────────────────────────────────────────────────────────────┘

  ┌─ smsOtp chosen ────────────────────────────────────────────┐
  │  enrollPhone()                                              │
  │    └─ user enters phone number → saved                      │
  │  verify()                                                   │
  │    └─ OTP sent by SMS                                       │
  │    └─ user enters code → enrolled → onVerifiedRoute        │
  └─────────────────────────────────────────────────────────────┘

  ┌─ totp chosen ──────────────────────────────────────────────┐
  │  enroll()                                                   │
  │    └─ TOTP secret generated, QR code displayed             │
  │    └─ user scans QR with authenticator app                  │
  │    └─ user enters first code → enrolled → onVerifiedRoute  │
  └─────────────────────────────────────────────────────────────┘

Login flow (runs on every subsequent login)

After the setup flow is complete the user has a chosen OTP method stored in verification_preferences. On every login the plugin checks this and requires a fresh OTP code.

login()
  └─ Authentication identifies user
  └─ requiresNextStep() → true (login-time OTP pending)
  └─ redirect → verify() (or enroll() / enrollPhone() if needed)
       └─ user enters OTP code
       └─ verified → redirect → onVerifiedRoute (app)

The login OTP step uses the same driver the user enrolled in during setup. If the user somehow has no driver stored (e.g. data was cleared), the plugin falls back to redirecting to chooseVerification again.


OTP method selection (chooseVerification)

This screen appears when:

  • requiredSetupSteps contains more than one OTP driver (e.g. ['emailVerify', 'emailOtp', 'smsOtp', 'totp']), and
  • the user has not yet chosen a method.

The available choices shown to the user are exactly the OTP drivers listed in requiredSetupSteps. The user picks one; the choice is saved to verification_preferences in your users table and all other OTP drivers are skipped for this user from that point on.

// config/verification.php — offer three methods, user picks one
'requiredSetupSteps' => ['emailVerify', 'emailOtp', 'smsOtp', 'totp'],
// UsersController
public function chooseVerification(): ?Response
{
    return $this->Verification->handleChooseVerification();
}

The handleChooseVerification() handler sets $availableDrivers and $selectedDriver on the view so you can render the selection form. See users_controller.md for the full action and template example.


Summary table

SituationRedirect destination
Email not yet confirmedpendingRoute ("check your inbox")
Multiple OTP drivers, none chosenchooseVerificationRoute
smsOtp chosen, no phone savedenrollPhoneRoute
totp chosen, no secret generatedenrollRoute
OTP pending (setup or login)nextRoute (verify form)
All steps completeonVerifiedRoute (your app)

Documentation

TopicFile
README../README.md
Verification flows (setup, login, OTP choice)verification_flow.md
Installationinstallation.md
Configuration referenceconfiguration.md
Environment variablesenv.md
UsersController actionsusers_controller.md
VerificationComponentverification_component.md
VerificationHelperverification_helper.md
Email verification & Email OTPemail_verification.md
SMS OTPsms_verification.md
TOTPtotp_verification.md
Enable / disable individual stepsverificator_enable_disable.md
API referenceapi/index.md