Email Verification & Email OTP
July 11, 2026 · View on GitHub
Two email-based verification methods are available:
| Key | Type | Description |
|---|---|---|
emailVerify | Setup only | Send a signed link by email; user clicks to confirm ownership |
emailOtp | Setup / Login | Send a numeric one-time code by email |
Database columns
emailVerify uses email, email_verification_token,
email_verification_token_expires, and email_verified_at. emailOtp uses
only email. Column types and an example migration are in
installation.md.
The OTP code itself is not stored in the database; it lives in the CakePHP Cache (see otp_storage.md).
Configuration
// config/verification.php
'requiredSetupSteps' => ['emailVerify', 'emailOtp'],
'drivers' => [
'emailVerify' => [
'enabled' => true,
'options' => [
'delivery' => null, // null = use App\Mailer\UserMailer (see below)
],
],
'emailOtp' => [
'enabled' => true,
'options' => [
'ttl' => 600, // code valid for 10 minutes
'delivery' => null, // null = use App\Mailer\UserMailer (see below)
],
],
],
Custom column names:
'drivers' => [
'emailVerify' => ['fields' => ['email' => 'user_email', 'emailVerified' => 'email_confirmed_at']],
'emailOtp' => ['fields' => ['email' => 'user_email', 'emailVerified' => 'email_confirmed_at']],
],
emailVerify flow
How it works
The plugin handles token generation, storage, and email delivery automatically. Your app only needs to:
- Call
$this->Verification->afterRegister($user)after saving the user inregister(). - Implement
verifyEmail()to consume the token and call$this->Verification->redirectAfterEmailVerify($user).
afterRegister() generates the token, saves it to the users table, and
sends the verification email. The user is not logged in at this point.
Email delivery
The plugin calls App\Mailer\UserMailer::emailVerify($user, $verifyUrl) automatically
if the class exists. No extra configuration needed.
// src/Mailer/UserMailer.php
public function emailVerify(object $user, string $verifyUrl): void
{
$this->setTo($user->email)
->setSubject(__('Confirm your email address'))
->setViewVars(compact('user', 'verifyUrl'));
}
To use a custom delivery instead, set a callable under drivers.emailVerify.options.delivery:
'emailVerify' => [
'options' => [
'delivery' => function (
\Psr\Http\Message\ServerRequestInterface $request,
\Authentication\IdentityInterface $identity,
array $config,
): void {
// token is already saved; send the email your own way
},
],
],
Controller actions
register()
public function register(): ?Response
{
$this->request->allowMethod(['get', 'post']);
$user = $this->Users->newEmptyEntity();
if ($this->request->is('post')) {
$user = $this->Users->patchEntity($user, $this->request->getData());
if ($this->Users->save($user)) {
// Plugin generates token, saves it, sends verification email
$this->Verification->afterRegister($user);
$this->Flash->success(__('Check your email to complete registration.'));
return $this->redirect(['action' => 'login']);
}
$this->Flash->error(__('Could not save the account.'));
}
$this->set(compact('user'));
}
verifyEmail()
public function verifyEmail(string $token): ?Response
{
$this->request->allowMethod('get');
$user = $this->Users->find()
->where(['email_verification_token' => $token])
->firstOrFail();
// Plugin validates expiry, saves email_verified_at, clears token
return $this->Verification->redirectAfterEmailVerify($user);
}
redirectAfterEmailVerify() handles two cases:
- User is logged in → sets the login-flow flag and redirects to the next pending step.
- User is not logged in → redirects to the login page.
pending() — "check your inbox" page
public function pending(): ?Response
{
$identity = $this->request->getAttribute('identity');
if ($identity === null) {
return $this->redirect(['action' => 'login']);
}
$verification = $this->Verification->result();
if (!$verification->hasStep('email_verify')) {
return $this->redirect($this->Verification->getNextUrl() ?? '/');
}
$this->set(compact('verification'));
}
resendEmailVerification() — optional
public function resendEmailVerification(): ?Response
{
$this->request->allowMethod('post');
$identity = $this->request->getAttribute('identity');
if ($identity === null) {
return $this->redirect(['action' => 'login']);
}
// Plugin clears old token, generates new one, resends email
$this->Verification->resendEmailVerificationLink();
$this->Flash->success(__('Verification email resent.'));
return $this->redirect($this->Verification->getNextUrl() ?? '/');
}
emailOtp flow
How it works
The component auto-sends the code on the GET request to verify() and
verifies the submitted code on POST. Your controller action is a single call:
public function verify(?string $step = null): ?Response
{
$this->request->allowMethod(['get', 'post']);
$response = $this->Verification->handleVerify($step);
if ($response !== null) {
return $response;
}
// view variables $verification and $step are set automatically
}
Email delivery
The plugin calls App\Mailer\UserMailer::emailOtp($user, $code) automatically
if the class exists.
// src/Mailer/UserMailer.php
public function emailOtp(object $user, string $code): void
{
$this->setTo($user->email)
->setSubject(__('Your login code'))
->setViewVars(compact('user', 'code'));
}
To use a custom delivery instead:
'emailOtp' => [
'options' => [
'ttl' => 600,
'delivery' => function (
\Psr\Http\Message\ServerRequestInterface $request,
\Authentication\IdentityInterface $identity,
string $code,
array $config,
): void {
$user = $identity->getOriginalData();
// send $code to $user->email your own way
},
],
],
Email templates
Place templates in your app to override the plugin defaults:
templates/email/text/email_verify.php
templates/email/html/email_verify.php
templates/email/text/email_otp.php
templates/email/html/email_otp.php
In your mailer, point the view builder at the Verification plugin to use the
bundled templates:
$this->viewBuilder()
->setPlugin('CakeVerification')
->setTemplate('email_otp');
Omit setPlugin() to use your own templates instead.
Notes
emailVerifyalways runs first and blocks all other setup steps until confirmed.emailOtpdoes not markemail_verified_at; it is a standalone OTP step. If you want email OTP to double as email ownership proof, mapdrivers.emailOtp.fields.emailVerifiedto theemail_verified_atcolumn.- Rate limiting (resend cooldown, lockout) applies to
emailOtp. See otp_storage.md for details.
Documentation
Full documentation index: index.md