<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vinicius</title>
    <description>The latest articles on DEV Community by Vinicius (@viniciusbig).</description>
    <link>https://dev.to/viniciusbig</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F783142%2F2e22faf5-40a0-4e98-b137-6384610492ce.jpeg</url>
      <title>DEV Community: Vinicius</title>
      <link>https://dev.to/viniciusbig</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/viniciusbig"/>
    <language>en</language>
    <item>
      <title>CakeDC/Users: why "Remember me" sets a cookie but never logs you back in</title>
      <dc:creator>Vinicius</dc:creator>
      <pubDate>Sat, 31 Oct 2026 17:10:10 +0000</pubDate>
      <link>https://dev.to/viniciusbig/cakedcusers-why-remember-me-sets-a-cookie-but-never-logs-you-back-in-5e7p</link>
      <guid>https://dev.to/viniciusbig/cakedcusers-why-remember-me-sets-a-cookie-but-never-logs-you-back-in-5e7p</guid>
      <description>&lt;p&gt;My users kept complaining that the admin panel logged them out "all the time", even with &lt;strong&gt;Remember me&lt;/strong&gt; checked. I assumed it was just the PHP session expiring. Half of that was right. The other half was a quiet configuration trap in the CakePHP authentication stack. It probably affects other CakePHP 5 apps that use &lt;code&gt;cakedc/users&lt;/code&gt; with the default authenticators.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; the &lt;code&gt;Cookie&lt;/code&gt; authenticator in CakeDC/Users' default config has no &lt;code&gt;identifier&lt;/code&gt; of its own. In &lt;code&gt;cakephp/authentication&lt;/code&gt; 3.3, an authenticator without one inherits the identifier of the &lt;em&gt;previous&lt;/em&gt; authenticator that had one. In the default order, that's the &lt;code&gt;Token&lt;/code&gt; authenticator, which looks users up by &lt;code&gt;api_token&lt;/code&gt;. So the remember-me cookie is written but never matches anyone. Give &lt;code&gt;Cookie&lt;/code&gt; its own &lt;code&gt;identifier&lt;/code&gt; and it works.&lt;/p&gt;

&lt;p&gt;Versions where I hit it: CakePHP 5.4, &lt;code&gt;cakedc/users&lt;/code&gt; 16.0.4, &lt;code&gt;cakedc/auth&lt;/code&gt; (its &lt;code&gt;CookieAuthenticator&lt;/code&gt;), &lt;code&gt;cakephp/authentication&lt;/code&gt; 3.3.7.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptoms
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;You sign in with &lt;strong&gt;Remember me&lt;/strong&gt; checked.&lt;/li&gt;
&lt;li&gt;The browser does get a cookie. It's called &lt;code&gt;CookieAuth&lt;/code&gt;; &lt;code&gt;remember_me&lt;/code&gt; is only the name of the form checkbox. It's &lt;code&gt;httponly&lt;/code&gt; and expires in one month.&lt;/li&gt;
&lt;li&gt;Once the PHP session ends (garbage collection, server restart, deploy, or you delete the session cookie), the next request redirects to &lt;code&gt;/login&lt;/code&gt;. The remember-me cookie is still there, but it is ignored.&lt;/li&gt;
&lt;li&gt;Nothing is logged. To the framework, a cookie that matches no user is just a failed authentication, so it moves on to the next authenticator.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To reproduce it quickly, sign in with Remember me, delete only the session cookie (&lt;code&gt;CAKEPHP&lt;/code&gt; by default) in devtools, and reload. If you land on the login page, you have this problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  How authenticators get their identifier
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;cakephp/authentication&lt;/code&gt; separates &lt;strong&gt;authenticators&lt;/strong&gt; (where the credentials come from: session, form, token, cookie…) from &lt;strong&gt;identifiers&lt;/strong&gt; (how those credentials become a user: password check, token lookup…).&lt;/p&gt;

&lt;p&gt;Since 3.3, the recommended style is to put the identifier inside each authenticator's config. &lt;code&gt;loadIdentifier()&lt;/code&gt; is deprecated. Here is how the collection builds each authenticator (&lt;code&gt;src/Authenticator/AuthenticatorCollection.php&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;_create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;object&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$alias&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;AuthenticatorInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$class&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'identifier'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;_identifiers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IdentifierCollection&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'identifier'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nv"&gt;$class&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;_identifiers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$class&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$this-&amp;gt;_identifiers&lt;/code&gt; is a property of the collection, shared by every authenticator it creates. An authenticator &lt;strong&gt;with&lt;/strong&gt; an &lt;code&gt;identifier&lt;/code&gt; replaces it. An authenticator &lt;strong&gt;without&lt;/strong&gt; one gets whatever the previous one left there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The default CakeDC/Users config
&lt;/h2&gt;

&lt;p&gt;Here is the relevant part of &lt;code&gt;vendor/cakedc/users/config/users.php&lt;/code&gt;, trimmed and in the order it appears:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'Authenticators'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'Session'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;                       &lt;span class="c1"&gt;// no identifier (doesn't need one)&lt;/span&gt;
    &lt;span class="s1"&gt;'Form'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'className'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'CakeDC/Auth.Form'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'identifier'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'Authentication.Password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;   &lt;span class="c1"&gt;// ① sets Password&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'Token'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'className'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Authentication.Token'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'identifier'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'Authentication.Token'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'tokenField'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'api_token'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                         &lt;span class="c1"&gt;// ② replaces it with Token&lt;/span&gt;
        &lt;span class="p"&gt;]],&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'Cookie'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'className'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'CakeDC/Auth.Cookie'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'rememberMeField'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'remember_me'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'cookie'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'expires'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\DateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'+1 month'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'httponly'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="c1"&gt;// ③ no identifier → inherits the Token identifier from ②&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'Social'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The remember-me token is &lt;code&gt;[username, expires, hmac]&lt;/code&gt;. When the cookie authenticator sees one, it asks its identifier for the user with that username and then checks the HMAC against the user's password hash. Its identifier, though, is the &lt;code&gt;TokenIdentifier&lt;/code&gt;, which searches the &lt;code&gt;api_token&lt;/code&gt; column. No user has their username as an API token, so the result is &lt;code&gt;FAILURE_IDENTITY_NOT_FOUND&lt;/code&gt; and you're logged out.&lt;/p&gt;

&lt;p&gt;Nothing in the remember-me code itself is broken. The cookie authenticator just ends up with the wrong identifier, because of where it sits in the array.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Give the &lt;code&gt;Cookie&lt;/code&gt; authenticator an identifier of its own, in your app's users config. That's the file you pass in &lt;code&gt;Users.config&lt;/code&gt;, loaded after the plugin's, for example &lt;code&gt;config/users.php&lt;/code&gt; or &lt;code&gt;config/app_users.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'Auth'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'Authenticators'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="c1"&gt;// "Remember me": the Cookie authenticator needs its OWN identifier.&lt;/span&gt;
            &lt;span class="c1"&gt;// Without one, cakephp/authentication 3.3 hands it the identifier&lt;/span&gt;
            &lt;span class="c1"&gt;// of the previous authenticator that had one (Token / api_token),&lt;/span&gt;
            &lt;span class="c1"&gt;// and the cookie never finds the user.&lt;/span&gt;
            &lt;span class="s1"&gt;'Cookie'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'identifier'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                    &lt;span class="s1"&gt;'Authentication.Password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                        &lt;span class="s1"&gt;'fields'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                            &lt;span class="s1"&gt;'username'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'username'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                            &lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'password'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="p"&gt;],&lt;/span&gt;
                        &lt;span class="s1"&gt;'resolver'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                            &lt;span class="s1"&gt;'className'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Authentication.Orm'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                            &lt;span class="s1"&gt;'finder'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="p"&gt;],&lt;/span&gt;
                    &lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="c1"&gt;// ... the rest of your Auth config&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some notes on this block:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It merges with the plugin's config.&lt;/strong&gt; You only add the &lt;code&gt;identifier&lt;/code&gt; key; &lt;code&gt;className&lt;/code&gt;, cookie options and &lt;code&gt;rememberMeField&lt;/code&gt; stay as the plugin defines them. Check with &lt;code&gt;debug(Configure::read('Auth.Authenticators.Cookie'))&lt;/code&gt; if your config loading is unusual.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;username&lt;/code&gt; field must be what the cookie stores.&lt;/strong&gt; The token is built from the cookie authenticator's &lt;code&gt;fields.username&lt;/code&gt;, which defaults to &lt;code&gt;username&lt;/code&gt;. If your users sign in by e-mail and the &lt;code&gt;username&lt;/code&gt; column holds the e-mail, this works as is. If you changed the cookie authenticator's &lt;code&gt;fields&lt;/code&gt;, use the same column here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the &lt;code&gt;active&lt;/code&gt; finder,&lt;/strong&gt; so a disabled user can't come back in through an old cookie.&lt;/li&gt;
&lt;li&gt;You don't need to clear existing cookies. Tokens that were already issued start working, as long as they haven't expired.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A regression test
&lt;/h2&gt;

&lt;p&gt;This bug fails silently, so it's worth a test that would have caught it. With &lt;code&gt;IntegrationTestTrait&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;testCookieSignsBackInAfterTheSessionEnds&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;enableCsrfToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;enableSecurityToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/login'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'username'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'staff@example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'secret'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'remember_me'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertRedirectContains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/dashboard'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Pull the remember-me cookie out of the response&lt;/span&gt;
    &lt;span class="nv"&gt;$cookie&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;_response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Set-Cookie'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$header&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str_starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'CookieAuth='&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$cookie&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;urldecode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;explode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;';'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'CookieAuth='&lt;/span&gt;&lt;span class="p"&gt;)))[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertNotEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cookie&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// "The session died": drop it, keep only the cookie&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;_session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nv"&gt;$_SESSION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'CookieAuth'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$cookie&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/dashboard'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertResponseOk&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a second test with &lt;code&gt;remember_me =&amp;gt; '0'&lt;/code&gt; that expects a redirect to &lt;code&gt;/login&lt;/code&gt; after the session is dropped, so you know the cookie is the only thing letting the user back in.&lt;/p&gt;

&lt;p&gt;Two notes on the test:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;$this-&amp;gt;_response-&amp;gt;getCookie('remember_me')&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt;, because the cookie isn't called &lt;code&gt;remember_me&lt;/code&gt;. Read the &lt;code&gt;Set-Cookie&lt;/code&gt; headers, or use the configured &lt;code&gt;cookie.name&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The test failed before the fix and passed after it. Check that it fails without the fix (&lt;code&gt;git stash&lt;/code&gt;), or it isn't proving anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The same trap elsewhere
&lt;/h2&gt;

&lt;p&gt;The underlying rule is general: &lt;strong&gt;in cakephp/authentication 3.3, an authenticator without &lt;code&gt;identifier&lt;/code&gt; uses the identifier of the last authenticator above it that had one.&lt;/strong&gt; So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you add or enable an authenticator that identifies users (a custom one, LDAP, JWT…), give it an explicit &lt;code&gt;identifier&lt;/code&gt;, even if it "obviously" uses the same one as Form.&lt;/li&gt;
&lt;li&gt;If you reorder &lt;code&gt;Auth.Authenticators&lt;/code&gt;, or remove &lt;code&gt;Token&lt;/code&gt; because you don't use API keys, which identifier each one inherits can change without anything telling you.&lt;/li&gt;
&lt;li&gt;When an authentication "just fails", look at the &lt;code&gt;Result&lt;/code&gt; status. &lt;code&gt;FAILURE_IDENTITY_NOT_FOUND&lt;/code&gt; with valid credentials points at the wrong identifier, not at a bad password. You can inspect it with &lt;code&gt;$this-&amp;gt;Authentication-&amp;gt;getResult()&lt;/code&gt; in a controller (&lt;code&gt;-&amp;gt;getStatus()&lt;/code&gt;, &lt;code&gt;-&amp;gt;getErrors()&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The other half: the PHP session itself
&lt;/h2&gt;

&lt;p&gt;Once remember-me works, a short session is only a small annoyance. It's still worth fixing, because it was the reason people noticed the bug in the first place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PHP's default &lt;code&gt;session.gc_maxlifetime&lt;/code&gt; is &lt;strong&gt;1440 seconds (24 minutes)&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;On shared hosting, sessions often live in a shared directory where &lt;em&gt;another&lt;/em&gt; site's lower &lt;code&gt;gc_maxlifetime&lt;/code&gt;, or a system cron, can delete yours early.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In &lt;code&gt;config/app.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'Session'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'defaults'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'cake'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// files in TMP/sessions, not the server-wide dir&lt;/span&gt;
    &lt;span class="s1"&gt;'timeout'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// minutes of inactivity (sets gc_maxlifetime)&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;'defaults' =&amp;gt; 'cake'&lt;/code&gt; keeps the session files inside your app, out of the shared directory. Two things to keep in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tmp/&lt;/code&gt; usually isn't in git, so create &lt;code&gt;tmp/sessions&lt;/code&gt; on deploy, or with a guarded &lt;code&gt;mkdir&lt;/code&gt; in &lt;code&gt;config/bootstrap.php&lt;/code&gt;. It has to be writable by the web server user.&lt;/li&gt;
&lt;li&gt;With Deployer or similar, make sure &lt;code&gt;tmp&lt;/code&gt; is a shared dir, or every deploy logs everyone out.&lt;/li&gt;
&lt;li&gt;Moving the session directory invalidates the current sessions once. With remember-me fixed, users who checked the box won't even notice.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Checklist for your own CakePHP apps
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;composer show cakedc/users cakephp/authentication&lt;/code&gt;. Are you on &lt;code&gt;cakephp/authentication&lt;/code&gt; 3.3+ with CakeDC's default &lt;code&gt;Authenticators&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;debug(Configure::read('Auth.Authenticators'))&lt;/code&gt;. Does &lt;code&gt;Cookie&lt;/code&gt; have an &lt;code&gt;identifier&lt;/code&gt;? Which authenticator with an identifier comes before it?&lt;/li&gt;
&lt;li&gt;Manual test: sign in with Remember me, delete only the session cookie, reload.&lt;/li&gt;
&lt;li&gt;Add the &lt;code&gt;Cookie.identifier&lt;/code&gt; block above. Use the same &lt;code&gt;username&lt;/code&gt; column the cookie uses and keep the &lt;code&gt;active&lt;/code&gt; finder.&lt;/li&gt;
&lt;li&gt;Add the regression test, and make sure it fails without the fix.&lt;/li&gt;
&lt;li&gt;Optionally, set &lt;code&gt;Session.defaults = 'cake'&lt;/code&gt; and a &lt;code&gt;timeout&lt;/code&gt;, and make &lt;code&gt;tmp/sessions&lt;/code&gt; writable and shared between deploys.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;If you know whether this also happens in older &lt;code&gt;cakephp/authentication&lt;/code&gt; versions, or whether CakeDC plans to add an identifier to the default &lt;code&gt;Cookie&lt;/code&gt; config, let me know in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cakephp</category>
      <category>php</category>
      <category>authentication</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Per-user two-factor auth in CakePHP with CakeDC/Users (opt-in, one method)</title>
      <dc:creator>Vinicius</dc:creator>
      <pubDate>Tue, 25 Aug 2026 00:52:51 +0000</pubDate>
      <link>https://dev.to/viniciusbig/per-user-two-factor-auth-in-cakephp-with-cakedcusers-opt-in-one-method-4n6d</link>
      <guid>https://dev.to/viniciusbig/per-user-two-factor-auth-in-cakephp-with-cakedcusers-opt-in-one-method-4n6d</guid>
      <description>&lt;p&gt;CakeDC/Users gives you TOTP two-factor authentication almost for free: flip one config key and every login grows a "enter your 6-digit code" step. The catch is that word &lt;strong&gt;every&lt;/strong&gt;. The built-in flow is all-or-nothing — turn it on and &lt;em&gt;all&lt;/em&gt; your users are forced through the OTP challenge on their next login, whether they ever set up an authenticator app or not. Lock yourself out on a fresh install and you'll find out fast.&lt;/p&gt;

&lt;p&gt;What most apps actually want is the model you see everywhere else: 2FA is &lt;strong&gt;off by default&lt;/strong&gt;, and each user opts in from their own account settings. This post shows how to get there with a surprisingly small change — one overridden method — plus a self-service enrolment screen and one QR-code gotcha that will bite you on modern dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one insight: &lt;code&gt;isRequired()&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;CakeDC/Users decides whether to demand the OTP step through an&lt;br&gt;
&lt;code&gt;OneTimePasswordAuthenticationCheckerInterface&lt;/code&gt;. The default implementation,&lt;br&gt;
&lt;code&gt;DefaultOneTimePasswordAuthenticationChecker&lt;/code&gt;, answers "is 2FA required for this&lt;br&gt;
request?" — and once the authenticator is enabled in the login flow, it answers&lt;br&gt;
&lt;strong&gt;yes for everybody&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That checker is a swappable dependency. So "per-user 2FA" reduces to: &lt;em&gt;keep the&lt;br&gt;
default behaviour, but also require that this specific user has opted in.&lt;/em&gt; One&lt;br&gt;
method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Authentication&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;CakeDC\Auth\Authentication\DefaultOneTimePasswordAuthenticationChecker&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PerUserOneTimePasswordAuthenticationChecker&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;DefaultOneTimePasswordAuthenticationChecker&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/**
     * @param array&amp;lt;mixed&amp;gt;|null $user User data.
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;isRequired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;?array&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Default rules AND the user enrolled.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;isRequired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'two_steps'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;parent::isRequired()&lt;/code&gt; keeps every rule CakeDC already applies (the authenticator&lt;br&gt;
is on, the user has a verified secret, remember-me isn't skipping it, …). We just&lt;br&gt;
&lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; a per-user flag on top. Users who never enrolled fail the &lt;code&gt;two_steps&lt;/code&gt; check&lt;br&gt;
and log in normally; users who did get challenged. That's the whole hook.&lt;/p&gt;
&lt;h2&gt;
  
  
  The &lt;code&gt;two_steps&lt;/code&gt; flag
&lt;/h2&gt;

&lt;p&gt;CakeDC's own initial migration already gives the &lt;code&gt;users&lt;/code&gt; table the columns the TOTP&lt;br&gt;
flow needs — &lt;code&gt;secret&lt;/code&gt; (the shared TOTP secret) and &lt;code&gt;secret_verified&lt;/code&gt; (proof the&lt;br&gt;
user typed a valid code at least once). The only thing it doesn't have is a column&lt;br&gt;
that means &lt;em&gt;"this user wants 2FA."&lt;/em&gt; Add it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Migrations\BaseMigration&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AddTwoStepsToUsers&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;BaseMigration&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'users'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addColumn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'two_steps'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'boolean'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'default'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'null'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;down&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'users'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;removeColumn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'two_steps'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;default =&amp;gt; false&lt;/code&gt; is the important part: &lt;strong&gt;existing users are untouched&lt;/strong&gt; and new&lt;br&gt;
users start with 2FA off. Nobody is forced into anything.&lt;/p&gt;
&lt;h2&gt;
  
  
  Wiring the checker (and the QR gotcha)
&lt;/h2&gt;

&lt;p&gt;Point the OTP authenticator at your checker in &lt;code&gt;config/users.php&lt;/code&gt;. This is also&lt;br&gt;
where you enable the authenticator in the login flow (&lt;code&gt;login =&amp;gt; true&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Authentication\PerUserOneTimePasswordAuthenticationChecker&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;RobThree\Auth\Providers\Qr\BaconQrCodeProvider&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'OneTimePasswordAuthenticator'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'checker'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;PerUserOneTimePasswordAuthenticationChecker&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'login'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'issuer'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'My App'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// the label users see in their authenticator app&lt;/span&gt;
    &lt;span class="s1"&gt;'qrcodeprovider'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BaconQrCodeProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'svg'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is the gotcha. CakeDC defaults the QR provider to robthree's&lt;br&gt;
&lt;code&gt;EndroidQrCodeProvider&lt;/code&gt;, which calls &lt;code&gt;QrCode::setSize()&lt;/code&gt; — a method &lt;strong&gt;removed in&lt;br&gt;
&lt;code&gt;endroid/qr-code&lt;/code&gt; v6&lt;/strong&gt;. On a modern install you'll get a fatal the moment you try&lt;br&gt;
to render the enrolment QR. Swap in the &lt;code&gt;BaconQrCodeProvider&lt;/code&gt; with &lt;code&gt;format: 'svg'&lt;/code&gt;:&lt;br&gt;
it's pure PHP (no &lt;code&gt;ext-imagick&lt;/code&gt;), and the SVG embeds cleanly as a &lt;code&gt;data:&lt;/code&gt; URI, so&lt;br&gt;
the QR drops straight into your template with no file writes. &lt;code&gt;digits&lt;/code&gt;, &lt;code&gt;period&lt;/code&gt;&lt;br&gt;
and &lt;code&gt;algorithm&lt;/code&gt; inherit from CakeDC's defaults.&lt;/p&gt;
&lt;h2&gt;
  
  
  Self-service enrolment
&lt;/h2&gt;

&lt;p&gt;Now the part that makes it per-user in practice: a screen where a signed-in user&lt;br&gt;
turns 2FA &lt;strong&gt;on for themselves&lt;/strong&gt;, no logout round-trip. The flow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;GET&lt;/strong&gt; — make sure a secret exists (unverified), show the QR + the manual key +
a 6-digit code field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;POST&lt;/strong&gt; — verify the typed code against the secret; if it checks out, flip
&lt;code&gt;secret_verified&lt;/code&gt; and &lt;code&gt;two_steps&lt;/code&gt; on and refresh the identity.
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;twoFactor&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;?Response&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRequest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'identity'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getIdentifier&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'action'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'login'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getUsersTable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'secret_verified'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'two_steps'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nc"&gt;Flash&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;success&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Two-factor authentication is already enabled.'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'action'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'profile'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Create a secret to display, but keep it unverified until the user proves&lt;/span&gt;
    &lt;span class="c1"&gt;// they can generate a valid code from it.&lt;/span&gt;
    &lt;span class="nv"&gt;$secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'secret'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$secret&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nc"&gt;OneTimePasswordAuthenticator&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;createSecret&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'secret'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'secret_verified'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'two_steps'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;saveOrFail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRequest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;is&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'post'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'put'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRequest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'code'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nc"&gt;OneTimePasswordAuthenticator&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;verifyCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'secret_verified'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'two_steps'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;saveOrFail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nc"&gt;Authentication&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setIdentity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// so this session sees the change&lt;/span&gt;
            &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nc"&gt;Flash&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;success&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Two-factor authentication is now enabled.'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'action'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'profile'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nc"&gt;Flash&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'That code is not valid. Please try again.'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$secretDataUri&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nc"&gt;OneTimePasswordAuthenticator&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getQRCodeImageAsDataUri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nv"&gt;$secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;compact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'secret'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'secretDataUri'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Two details worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The secret is written before it's verified, but &lt;code&gt;two_steps&lt;/code&gt; stays &lt;code&gt;false&lt;/code&gt;
until the code checks out.&lt;/strong&gt; So a half-finished enrolment never arms the login
challenge — the user can't lock themselves out by navigating away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;$this-&amp;gt;Authentication-&amp;gt;setIdentity($user)&lt;/code&gt;&lt;/strong&gt; after saving refreshes the
identity in the current session, so the new flags are live immediately instead of
on the next request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;OneTimePasswordAuthenticator&lt;/code&gt; component gives you &lt;code&gt;createSecret()&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;verifyCode()&lt;/code&gt; and &lt;code&gt;getQRCodeImageAsDataUri()&lt;/code&gt; — load it (&lt;code&gt;CakeDC/Auth.OneTimePasswordAuthenticator&lt;/code&gt;)&lt;br&gt;
in the controller that hosts this action.&lt;/p&gt;
&lt;h2&gt;
  
  
  Let users turn it off, too
&lt;/h2&gt;

&lt;p&gt;Opt-in implies opt-out. Clear the secret and both flags, refresh the identity, and&lt;br&gt;
the next login skips the challenge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;disableTwoFactor&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;?Response&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRequest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;allowMethod&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'post'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'delete'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="nv"&gt;$userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRequest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'identity'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getIdentifier&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getUsersTable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'secret'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'secret_verified'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'two_steps'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nc"&gt;Authentication&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setIdentity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nc"&gt;Flash&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;success&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Two-factor authentication has been disabled.'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'action'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'profile'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Render it as a POST link on the profile so it can't be triggered by a stray GET.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the whole thing behind a switch
&lt;/h2&gt;

&lt;p&gt;Because the wiring adds an authenticator to the login flow, guard it so apps that&lt;br&gt;
don't want 2FA are completely unaffected. A single config flag, read in both&lt;br&gt;
&lt;code&gt;config/users.php&lt;/code&gt; (to decide whether to register the authenticator) and the&lt;br&gt;
controller (to 404 the enrolment action when off), keeps it a clean opt-in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Configure&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'MyApp.TwoFactor.enable'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'OneTimePasswordAuthenticator'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="cm"&gt;/* …as above… */&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;p&gt;Per-user 2FA on CakeDC/Users comes down to four small pieces:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A &lt;code&gt;two_steps&lt;/code&gt; boolean on &lt;code&gt;users&lt;/code&gt;, defaulting to &lt;code&gt;false&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A checker subclass that &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;s that flag onto &lt;code&gt;parent::isRequired()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A self-service enrol/disable pair that flips the flag after verifying a code.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;BaconQrCodeProvider(format: 'svg')&lt;/code&gt; to dodge the &lt;code&gt;endroid/qr-code&lt;/code&gt; v6 breakage.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No forked plugin, no reimplemented TOTP — you keep every rule CakeDC already&lt;br&gt;
enforces and add exactly one: &lt;em&gt;did this user ask for it?&lt;/em&gt; That's the difference&lt;br&gt;
between "2FA is on" and "2FA is available," which is almost always what you&lt;br&gt;
actually want.&lt;/p&gt;

</description>
      <category>cakephp</category>
      <category>php</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Active Flag Trap: unvalidated-but-logged-in in CakeDC/Users</title>
      <dc:creator>Vinicius</dc:creator>
      <pubDate>Thu, 20 Aug 2026 21:44:16 +0000</pubDate>
      <link>https://dev.to/viniciusbig/the-active-flag-trap-unvalidated-but-logged-in-in-cakedcusers-c8a</link>
      <guid>https://dev.to/viniciusbig/the-active-flag-trap-unvalidated-but-logged-in-in-cakedcusers-c8a</guid>
      <description>&lt;p&gt;If you ship email validation with &lt;a href="https://github.com/CakeDC/users" rel="noopener noreferrer"&gt;CakeDC/Users&lt;/a&gt;, you eventually hit a question the plugin quietly hands back to you: &lt;em&gt;what should happen when someone registers, never clicks the validation link, and then tries to log in?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The honest answer is that CakeDC/Users doesn't decide for you. Out of the box you get a database column, a couple of behaviors, and a set of events — but the &lt;em&gt;experience&lt;/em&gt; is yours to assemble. Get it wrong and you land in one of two bad places: a user silently logged in without ever validating, or a user who typed the right password and is told &lt;em&gt;"username or password is incorrect."&lt;/em&gt; Neither is what you want.&lt;/p&gt;

&lt;p&gt;This post walks through why that happens in v16, and a clean way to wire the flow using the events the plugin already dispatches — no core hacks, no schema surgery.&lt;/p&gt;

&lt;h2&gt;
  
  
  One flag, two meanings
&lt;/h2&gt;

&lt;p&gt;Everything starts with a single boolean column on the &lt;code&gt;users&lt;/code&gt; table: &lt;code&gt;active&lt;/code&gt;. When email validation is on, registration creates the account with &lt;code&gt;active = 0&lt;/code&gt; and only flips it to &lt;code&gt;1&lt;/code&gt; when the user clicks the link in the validation email. You can trace it in &lt;code&gt;BaseTokenBehavior::_updateActive()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// $user['validated'] is a transient flag set to false during register()&lt;/span&gt;
&lt;span class="nv"&gt;$emailValidated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'validated'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$emailValidated&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$validateEmail&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// registered → inactive + token emailed&lt;/span&gt;
    &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;updateToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tokenExpiration&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// clicked the link → active&lt;/span&gt;
    &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'activation_date'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice there is no separate &lt;code&gt;validated&lt;/code&gt; column in the database — &lt;code&gt;$user['validated']&lt;/code&gt; is a transient property used only during registration. The &lt;em&gt;persisted&lt;/em&gt; truth is &lt;code&gt;active&lt;/code&gt;, and it is doing two jobs at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Has this person confirmed their email?"&lt;/strong&gt; — set by the validation flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Is this account enabled?"&lt;/strong&gt; — the thing an admin toggles to ban or suspend someone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That conflation is the root of everything below. Hold onto it; we'll come back to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the finder decides who exists
&lt;/h2&gt;

&lt;p&gt;Login in CakeDC/Users runs through &lt;code&gt;cakephp/authentication&lt;/code&gt;'s password identifier, which resolves the user with a &lt;em&gt;finder&lt;/em&gt;. The finder you choose changes the entire shape of the problem:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;finder&lt;/th&gt;
&lt;th&gt;Behaviour for an inactive account (&lt;code&gt;active = 0&lt;/code&gt;)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;'active'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;WHERE active = 1&lt;/code&gt; clause means the row is &lt;strong&gt;never found&lt;/strong&gt;. The password is never checked. The user gets the generic &lt;em&gt;"username or password is incorrect"&lt;/em&gt; — even with the &lt;strong&gt;correct&lt;/strong&gt; password.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;'all'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The row &lt;strong&gt;is found&lt;/strong&gt; and the password &lt;strong&gt;is&lt;/strong&gt; verified. With a correct password the identifier succeeds — so an unvalidated user is technically &lt;strong&gt;authenticated&lt;/strong&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here's the counter-intuitive part. It's tempting to reach for &lt;code&gt;finder =&amp;gt; 'active'&lt;/code&gt; — surely inactive users just shouldn't log in? But that choice produces the second bad experience: a real user, with the right password, whose only sin is not clicking an email that probably landed in spam, is told their credentials are wrong. They'll try again, reset their password, and file a support ticket. They have no way of knowing the real problem.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Common myth:&lt;/strong&gt; &lt;em&gt;"&lt;code&gt;finder =&amp;gt; 'all'&lt;/code&gt; is there to prevent user enumeration."&lt;/em&gt; It isn't. The form login failure maps &lt;em&gt;both&lt;/em&gt; "user not found" and "wrong password" to the same generic message anyway (&lt;code&gt;LoginComponentLoader::forForm&lt;/code&gt; wires no distinct not-active message). So &lt;code&gt;'active'&lt;/code&gt; leaks nothing — it just also blocks the legitimate not-yet-validated user with a confusing message.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The better flow: authenticate, then decide
&lt;/h2&gt;

&lt;p&gt;The move that fixes both bad outcomes: keep &lt;code&gt;finder =&amp;gt; 'all'&lt;/code&gt; so the password &lt;em&gt;is&lt;/em&gt; verified, then intercept the result &lt;strong&gt;after&lt;/strong&gt; authentication and branch on &lt;code&gt;active&lt;/code&gt;. That's the only point where you can tell "wrong password" apart from "correct password, not validated" — and give each a truthful message.&lt;/p&gt;

&lt;p&gt;CakeDC/Users hands you the exact hook. &lt;code&gt;LoginComponent::afterIdentifyUser()&lt;/code&gt; dispatches &lt;code&gt;Users.Authentication.afterLogin&lt;/code&gt;, and &lt;strong&gt;if a listener returns an array, that array becomes the redirect&lt;/strong&gt;, short-circuiting the normal login redirect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;afterLogin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;EventInterface&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                     &lt;span class="c1"&gt;// active → let normal login proceed&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Inactive account must never keep a session. The auth middleware&lt;/span&gt;
    &lt;span class="c1"&gt;// already persisted the identity — drop it right back.&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nc"&gt;Authentication&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;logout&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRequest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getSession&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Flash'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// drop the "Welcome" flash&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Configure&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Users.Email.validate'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nc"&gt;Flash&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'Your account is not validated yet. Click the link in the '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;
            &lt;span class="s1"&gt;'email we sent you — check spam, or request a new one below.'&lt;/span&gt;
        &lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'action'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'resendTokenValidation'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// validation off ⇒ active=0 can only mean an admin disabled the account&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nc"&gt;Flash&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Your account is disabled. Please contact an administrator.'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'action'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'login'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details make this safe. First, the auth middleware has &lt;em&gt;already&lt;/em&gt; written the identity to the session by the time your action runs, so simply redirecting isn't enough — you must call &lt;code&gt;$this-&amp;gt;Authentication-&amp;gt;logout()&lt;/code&gt; to drop it. An unvalidated account should never hold a session, not even for one request. Second, &lt;code&gt;LoginComponent&lt;/code&gt; queues a "Welcome" success flash &lt;em&gt;before&lt;/em&gt; this event fires, so you clear it before setting your own.&lt;/p&gt;

&lt;p&gt;Here are the three outcomes at &lt;code&gt;/login&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Credentials&lt;/th&gt;
&lt;th&gt;&lt;code&gt;active&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;correct password&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Logged in.&lt;/strong&gt; Normal redirect to the dashboard.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;correct password&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Session dropped.&lt;/strong&gt; Redirected to the resend page with a specific "validate your account" message.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;wrong password / no such user&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Generic error.&lt;/strong&gt; &lt;em&gt;"Username or password is incorrect."&lt;/em&gt; No session, no enumeration leak.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why this doesn't leak:&lt;/strong&gt; the specific "not validated" message only ever appears &lt;em&gt;after&lt;/em&gt; a correct password — i.e. to someone who has already proven the account exists. Wrong-password and unknown-user still collapse into one generic message. You get honesty for real users and opacity for attackers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  After register: tell them to check their email
&lt;/h2&gt;

&lt;p&gt;The other half of the problem is that a fresh registrant has no idea a validation email is even coming. By default the plugin flashes &lt;em&gt;"please validate your account before log in"&lt;/em&gt; and drops them on the login screen — where the resend option is nowhere in sight. So send them somewhere that explains the next step.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;RegisterTrait::_afterRegister()&lt;/code&gt; dispatches &lt;code&gt;Users.Global.afterRegister&lt;/code&gt;. Careful: unlike the login hook, this one only honours a return value that is a &lt;strong&gt;&lt;code&gt;Response&lt;/code&gt;&lt;/strong&gt; instance — an array is ignored. So return a redirect response, not a URL array:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;afterRegister&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;EventInterface&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?Response&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nc"&gt;Configure&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Users.Email.validate'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// nothing to validate → default "log in" redirect stands&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nc"&gt;Flash&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;success&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'Almost done! We sent a validation link to your email. Click it '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;
        &lt;span class="s1"&gt;'to activate your account — check spam, or request a new link below.'&lt;/span&gt;
    &lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'action'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'resendTokenValidation'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both listeners land on the same destination: &lt;code&gt;resendTokenValidation&lt;/code&gt;. That action already ships with the plugin (in &lt;code&gt;UserValidationTrait&lt;/code&gt;) — it takes an email or username, regenerates the token, and re-sends the validation email, refusing if the account is already active. You just need a themed template for it. Wiring both events is a two-line job in your controller's &lt;code&gt;initialize()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getEventManager&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;UsersPlugin&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;EVENT_AFTER_LOGIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;EventInterface&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;afterLogin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getEventManager&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;UsersPlugin&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;EVENT_AFTER_REGISTER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;EventInterface&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;afterRegister&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Mind the return contracts:&lt;/strong&gt; &lt;code&gt;afterLogin&lt;/code&gt; must return an &lt;strong&gt;array&lt;/strong&gt; (&lt;code&gt;afterIdentifyUser&lt;/code&gt; checks &lt;code&gt;is_array()&lt;/code&gt;). &lt;code&gt;afterRegister&lt;/code&gt; must return a &lt;strong&gt;&lt;code&gt;Response&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;_afterRegister&lt;/code&gt; checks &lt;code&gt;instanceof Response&lt;/code&gt;). Same pattern, two different types — mixing them up gives you a listener that silently does nothing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The caveat you've only postponed
&lt;/h2&gt;

&lt;p&gt;Remember the two jobs &lt;code&gt;active&lt;/code&gt; is doing? This flow leans on the "email confirmed" meaning, but the column can't tell that apart from the "admin disabled this account" meaning. Both are &lt;code&gt;active = 0&lt;/code&gt;. Which means:&lt;/p&gt;

&lt;p&gt;A user an admin &lt;em&gt;deliberately banned&lt;/em&gt; can visit the resend page, request a "validation" email, click the link, and &lt;strong&gt;re-activate their own account&lt;/strong&gt;. The &lt;code&gt;resendTokenValidation&lt;/code&gt; action only checks that the account isn't already active — it can't know &lt;em&gt;why&lt;/em&gt; it's inactive.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The real fix:&lt;/strong&gt; split the concepts. Add a dedicated &lt;code&gt;enabled&lt;/code&gt; column (admin on/off), keep &lt;code&gt;active&lt;/code&gt; for email confirmation, and require &lt;em&gt;both&lt;/em&gt; in your login finder. Now "never validated" and "banned" are distinct states, and a resend can't undo a ban. Until you do that, only offer resend when email validation is on — and bounce a disabled account (validation off) straight back to login with a neutral message, as the listener above does.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For an existing app, adding a column is a migration and a finder tweak. For a greenfield build it's nearly free — so if you're starting fresh, separate them from day one and skip the trap entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep &lt;code&gt;finder =&amp;gt; 'all'&lt;/code&gt;.&lt;/strong&gt; You need the password verified to distinguish "wrong password" from "not validated." It costs you no enumeration protection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Branch after authentication, not before.&lt;/strong&gt; The &lt;code&gt;Users.Authentication.afterLogin&lt;/code&gt; event is where the truthful decision lives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always drop the session&lt;/strong&gt; for an inactive account — the middleware already created it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guide the registrant.&lt;/strong&gt; Redirect after register to a page that explains the email and offers a resend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't trust one flag with two meanings.&lt;/strong&gt; An &lt;code&gt;enabled&lt;/code&gt; column is the difference between "restart validation" and "let a banned user back in."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this requires forking the plugin. CakeDC/Users gives you the seams — the events, the finder switch, the built-in resend action. It just leaves the last mile, the part your users actually feel, for you to build.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built against CakeDC/Users v16 on CakePHP 5. The event names and method signatures referenced here are from that line; earlier majors dispatch similar events under different names. Spot something I got wrong? Drop a comment.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cakephp</category>
      <category>php</category>
      <category>authentication</category>
      <category>webdev</category>
    </item>
    <item>
      <title>FormValidation is discontinued — here's the TypeScript successor I built</title>
      <dc:creator>Vinicius</dc:creator>
      <pubDate>Thu, 18 Jun 2026 22:32:52 +0000</pubDate>
      <link>https://dev.to/viniciusbig/formvalidation-is-discontinued-heres-the-typescript-successor-i-built-4804</link>
      <guid>https://dev.to/viniciusbig/formvalidation-is-discontinued-heres-the-typescript-successor-i-built-4804</guid>
      <description>&lt;p&gt;If you've ever worked on a complex form without a framework, there's a good chance you've used or at least heard of &lt;a href="https://formvalidation.io/" rel="noopener noreferrer"&gt;FormValidation&lt;/a&gt;. It was the go-to solution for plugin-based, framework-agnostic form validation for years.&lt;/p&gt;

&lt;p&gt;Then it went silent. No updates, no responses to issues. Just an archived library slowly rotting against modern JavaScript.&lt;/p&gt;

&lt;p&gt;I needed a replacement for a project. I couldn't find one that matched the flexibility of the plugin model, so I built &lt;strong&gt;Validare&lt;/strong&gt; — a modern TypeScript rewrite of the same architecture, from scratch.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Validare is
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;@validare/core&lt;/code&gt;&lt;/strong&gt; is a form validation library with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;51 built-in validators&lt;/strong&gt; — from &lt;code&gt;notEmpty&lt;/code&gt; and &lt;code&gt;email&lt;/code&gt; to &lt;code&gt;iban&lt;/code&gt;, &lt;code&gt;creditCard&lt;/code&gt;, &lt;code&gt;uuid&lt;/code&gt;, &lt;code&gt;vin&lt;/code&gt;, and more&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;17 plugins&lt;/strong&gt; — UI feedback, accessibility, CSS framework integrations, async flows&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TypeScript-first&lt;/strong&gt; — full type safety, no &lt;code&gt;@types/*&lt;/code&gt; packages needed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero dependencies&lt;/strong&gt; — no jQuery, no frameworks, no polyfills&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ESM + CJS + UMD&lt;/strong&gt; — works everywhere&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The core idea is the same as FormValidation: a small engine that does nothing on its own, extended entirely through plugins. You add exactly what you need.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick start
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @validare/core
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"myForm"&lt;/span&gt; &lt;span class="na"&gt;novalidate&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"vd-plugins-message-container"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Submit&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;validare&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Trigger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SubmitButton&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@validare/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;myForm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;validators&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;notEmpty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Email is required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Please enter a valid email address&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Trigger&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blur&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;submitButton&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SubmitButton&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;myForm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;submit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;fv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// 'Valid' | 'Invalid' | 'NotValidated'&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Valid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// safe to submit&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. The &lt;code&gt;Trigger&lt;/code&gt; plugin wires up DOM events, &lt;code&gt;Message&lt;/code&gt; renders error messages, and &lt;code&gt;SubmitButton&lt;/code&gt; disables the button during validation.&lt;/p&gt;




&lt;h2&gt;
  
  
  The plugin model
&lt;/h2&gt;

&lt;p&gt;Plugins are where Validare earns its keep. Each one is a focused, composable unit:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plugin&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Trigger&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Validates on DOM events (&lt;code&gt;blur&lt;/code&gt;, &lt;code&gt;input&lt;/code&gt;, &lt;code&gt;change&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Message&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Renders error messages in the DOM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Icon&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Shows ✓ / ✗ icons next to fields&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Bootstrap5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Applies &lt;code&gt;is-valid&lt;/code&gt; / &lt;code&gt;is-invalid&lt;/code&gt; classes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Tailwind&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Applies configurable Tailwind utility classes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Aria&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Adds &lt;code&gt;aria-invalid&lt;/code&gt; and &lt;code&gt;aria-describedby&lt;/code&gt; automatically&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Sequence&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Stops at the first failing validator per field&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Excluded&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Skips disabled or hidden fields&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Declarative&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Configures validators via &lt;code&gt;data-vd-*&lt;/code&gt; HTML attributes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PasswordStrength&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Scores password strength (0–4)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CharCounter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Live character counter with overflow state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Summary&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Renders a consolidated error list in a container&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Transformer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Normalises field values before validation (trim, strip, etc.)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You compose only what your project needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Trigger&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nx"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Bootstrap5&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nx"&gt;aria&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Aria&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nx"&gt;sequence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Sequence&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nx"&gt;excluded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Excluded&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  51 validators, organized by category
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Core (23):&lt;/strong&gt; &lt;code&gt;notEmpty&lt;/code&gt;, &lt;code&gt;blank&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;creditCard&lt;/code&gt;, &lt;code&gt;date&lt;/code&gt;, &lt;code&gt;digits&lt;/code&gt;, &lt;code&gt;integer&lt;/code&gt;, &lt;code&gt;numeric&lt;/code&gt;, &lt;code&gt;regexp&lt;/code&gt;, &lt;code&gt;uri&lt;/code&gt;, &lt;code&gt;identical&lt;/code&gt;, &lt;code&gt;different&lt;/code&gt;, &lt;code&gt;between&lt;/code&gt;, &lt;code&gt;greaterThan&lt;/code&gt;, &lt;code&gt;lessThan&lt;/code&gt;, &lt;code&gt;stringLength&lt;/code&gt;, &lt;code&gt;stringCase&lt;/code&gt;, &lt;code&gt;choice&lt;/code&gt;, &lt;code&gt;file&lt;/code&gt;, &lt;code&gt;callback&lt;/code&gt;, &lt;code&gt;promise&lt;/code&gt;, &lt;code&gt;remote&lt;/code&gt;, &lt;code&gt;ip&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Format &amp;amp; Encoding:&lt;/strong&gt; &lt;code&gt;base64&lt;/code&gt;, &lt;code&gt;hex&lt;/code&gt;, &lt;code&gt;mac&lt;/code&gt;, &lt;code&gt;bic&lt;/code&gt;, &lt;code&gt;uuid&lt;/code&gt;, &lt;code&gt;color&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Financial:&lt;/strong&gt; &lt;code&gt;iban&lt;/code&gt;, &lt;code&gt;vat&lt;/code&gt;, &lt;code&gt;cusip&lt;/code&gt;, &lt;code&gt;isin&lt;/code&gt;, &lt;code&gt;sedol&lt;/code&gt;, &lt;code&gt;grid&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Publication:&lt;/strong&gt; &lt;code&gt;ean&lt;/code&gt;, &lt;code&gt;isbn&lt;/code&gt;, &lt;code&gt;ismn&lt;/code&gt;, &lt;code&gt;issn&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Device &amp;amp; Vehicle:&lt;/strong&gt; &lt;code&gt;imei&lt;/code&gt;, &lt;code&gt;imo&lt;/code&gt;, &lt;code&gt;meid&lt;/code&gt;, &lt;code&gt;step&lt;/code&gt;, &lt;code&gt;vin&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tax &amp;amp; Business:&lt;/strong&gt; &lt;code&gt;ein&lt;/code&gt;, &lt;code&gt;rtn&lt;/code&gt;, &lt;code&gt;siren&lt;/code&gt;, &lt;code&gt;siret&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Identity &amp;amp; Geographic:&lt;/strong&gt; &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;phone&lt;/code&gt;, &lt;code&gt;zipCode&lt;/code&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Async validation and remote checks
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;remote&lt;/code&gt; validator hits an endpoint and expects &lt;code&gt;{ valid: boolean }&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;validators&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;notEmpty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Username is required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/check-username&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;This username is already taken&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or write your own async validator with &lt;code&gt;callback&lt;/code&gt; or &lt;code&gt;promise&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;validators&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid value&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/validate?q=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Writing a custom validator
&lt;/h2&gt;

&lt;p&gt;Every validator is a zero-argument factory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ValidatorFactory&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@validare/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;noSpaces&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ValidatorFactory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;No spaces allowed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use it directly in your config — no registration needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;noSpaces&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./validators/noSpaces&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nl"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;validators&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;notEmpty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;noSpaces&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;noSpaces&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Coming from FormValidation?
&lt;/h2&gt;

&lt;p&gt;The architecture is intentionally familiar. The main differences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Package name: &lt;code&gt;@validare/core&lt;/code&gt; (not &lt;code&gt;@form-validation/cjs&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;CSS classes: &lt;code&gt;vd-plugins-*&lt;/code&gt; instead of &lt;code&gt;fv-plugins-*&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;HTML attributes (Declarative plugin): &lt;code&gt;data-vd-*&lt;/code&gt; instead of &lt;code&gt;data-fv-*&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Native TypeScript — no extra type packages&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A full migration guide is available in &lt;a href="https://github.com/validarejs/validare/blob/main/MIGRATING.md" rel="noopener noreferrer"&gt;MIGRATING.md&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;npm: &lt;code&gt;npm install @validare/core&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://validare.js.org" rel="noopener noreferrer"&gt;validare.js.org&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/validarejs/validare" rel="noopener noreferrer"&gt;github.com/validarejs/validare&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;CDN: &lt;code&gt;https://unpkg.com/@validare/core/dist/index.umd.js&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Feedback, issues, and PRs are very welcome. This is an active project and I'm happy to prioritize validators or plugins based on what people actually need.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
