<?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: Yaroslav</title>
    <description>The latest articles on DEV Community by Yaroslav (@sesly).</description>
    <link>https://dev.to/sesly</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%2F4212943%2F76aec4e7-f513-4541-9e13-20a2ba2ad859.png</url>
      <title>DEV Community: Yaroslav</title>
      <link>https://dev.to/sesly</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sesly"/>
    <language>en</language>
    <item>
      <title>Email scanners were confirming our users' accounts before they clicked</title>
      <dc:creator>Yaroslav</dc:creator>
      <pubDate>Wed, 28 Oct 2026 11:25:52 +0000</pubDate>
      <link>https://dev.to/sesly/email-scanners-were-confirming-our-users-accounts-before-they-clicked-300c</link>
      <guid>https://dev.to/sesly/email-scanners-were-confirming-our-users-accounts-before-they-clicked-300c</guid>
      <description>&lt;p&gt;Our sign-up emails contain a link. You click it, your account is confirmed, you land in the app. That's the whole feature, and for a while it was failing in a way that looked like three different bugs.&lt;/p&gt;

&lt;p&gt;It was one. The email was being opened by a robot before the person got to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;Some people clicked the confirmation link and got an error. Not always, and not for everyone. When the error showed up it said the link had expired or was invalid, although the email was a few minutes old.&lt;/p&gt;

&lt;p&gt;The stack is Next.js (App Router), Supabase Auth and Resend for email. The default Supabase confirmation link points straight at its &lt;code&gt;/auth/v1/verify&lt;/code&gt; endpoint, which verifies the token on any GET request. Keep that sentence in mind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three fixes that fixed nothing
&lt;/h2&gt;

&lt;p&gt;We went after what we could see, one thing at a time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An error that arrived in a URL fragment, which the server never sees, so the app showed nothing useful for it.&lt;/li&gt;
&lt;li&gt;People landing on the wrong screen after confirming.&lt;/li&gt;
&lt;li&gt;A raw, unfriendly error message.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each of those was a real problem and each fix was reasonable. The links still failed. All three were treating the symptom. None of them touched whatever was using up the token.&lt;/p&gt;

&lt;h2&gt;
  
  
  The evidence that mattered
&lt;/h2&gt;

&lt;p&gt;The thing that broke the case open was a timestamp. The account's &lt;code&gt;confirmed_at&lt;/code&gt; was filled in about two minutes after we sent the email, and nobody had clicked anything. We confirmed this against a real Outlook mailbox: send a sign-up email, do not open it, watch the row.&lt;/p&gt;

&lt;p&gt;So something opened the link on its own. Mail systems scan links in incoming messages for security, which means they fetch the URL and follow it. If the URL verifies the token on a plain GET, the scanner has just verified the account. By the time the person clicks, the single-use token is gone and they get &lt;code&gt;otp_expired&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The error message was telling the truth. It just wasn't describing what we thought it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: wait for a real click
&lt;/h2&gt;

&lt;p&gt;Any token delivered by email has to land on a page of your own, and it must be redeemed only by a real user gesture. Not on load. Not in an effect. Not by a form that submits itself.&lt;/p&gt;

&lt;p&gt;A scanner can fetch that page and even render it. It doesn't press the button. So our confirmation link now points at our own page, which shows a button, and only the click calls the server.&lt;/p&gt;

&lt;p&gt;A simplified sketch of the pattern (not our production code):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The email links to /auth/confirm?token_hash=...&amp;amp;type=email&lt;/span&gt;
&lt;span class="c1"&gt;// This page does NOT verify anything on load.&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;confirmToken&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="s2"&gt;./actions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ConfirmInterstitial&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;tokenHash&lt;/span&gt;&lt;span class="p"&gt;,&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;email&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;Props&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&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="nf"&gt;confirmToken&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;tokenHash&lt;/span&gt;&lt;span class="p"&gt;,&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;email&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      Confirm my email
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// actions.ts&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;confirmToken&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;tokenHash&lt;/span&gt;&lt;span class="p"&gt;,&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;email&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;Input&lt;/span&gt;&lt;span class="p"&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;supabase&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;createClient&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;error&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;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verifyOtp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;token_hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tokenHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&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 email template also must not point at &lt;code&gt;/auth/v1/verify&lt;/code&gt; any more, since that endpoint treats any GET as a verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  A second trap on the way
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;verifyOtp&lt;/code&gt; returns the same &lt;code&gt;otp_expired&lt;/code&gt; for a garbage token and for a once-valid token on an account that is already confirmed. So someone who clicks twice, or whose scanner got there first, sees an error even though their account is fine. For sign-up links we added a small check that asks whether the account is already confirmed, and shows "Your email is already confirmed" instead of an error. Password reset and email change don't have that in-between state, so they handle failure as a plain error.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself earlier
&lt;/h2&gt;

&lt;p&gt;Diagnose from timestamps and state, not from the wording of the error screen. We spent four attempts fixing what the screen described. The row in the database already said what had happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I haven't verified
&lt;/h2&gt;

&lt;p&gt;I confirmed this against an Outlook mailbox. Other providers and security tools may behave the same way, and probably some do, but I haven't tested them, so I'm not going to say which. I also haven't counted how many people were affected.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I'm the founder of &lt;a href="https://www.sesly.pro/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=backlinks-2026q4" rel="noopener noreferrer"&gt;Sesly&lt;/a&gt;, a small CRM for solo coaches. This is how our own sign-up flow works.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>supabase</category>
      <category>authentication</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
