<?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: kevindev</title>
    <description>The latest articles on DEV Community by kevindev (@kevindev27).</description>
    <link>https://dev.to/kevindev27</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%2F4012755%2F59157d97-36db-4340-addc-f6c6776820fb.png</url>
      <title>DEV Community: kevindev</title>
      <link>https://dev.to/kevindev27</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kevindev27"/>
    <language>en</language>
    <item>
      <title>Node.js Verification APIs Need a Mailbox Lease</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Wed, 14 Oct 2026 05:23:17 +0000</pubDate>
      <link>https://dev.to/kevindev27/nodejs-verification-apis-need-a-mailbox-lease-1h2f</link>
      <guid>https://dev.to/kevindev27/nodejs-verification-apis-need-a-mailbox-lease-1h2f</guid>
      <description>&lt;p&gt;Email verification looks small until a service must handle retries, delayed delivery, duplicate clicks, and automated tests. The usual implementation sends a message, stores a token, and hopes the user clicks it once. That model breaks down.&lt;/p&gt;

&lt;p&gt;I prefer treating the verification mailbox as a lease. A lease has an owner, an expiry time, a purpose, and a clear release or consumption rule. Thinking in those terms gives a Node.js REST API enough structure to behave predictably when the client retries or the email provider is slow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a verification mailbox is a lease
&lt;/h2&gt;

&lt;p&gt;The address used for a verification flow should be associated with one account and one attempt. It should not be a shared bucket where any recent message can satisfy any request. Otherwise a delayed message can verify the wrong session.&lt;/p&gt;

&lt;p&gt;The lease can be represented with four values. This small contract is easy to skip during a hurried deploy, but it keeps the workflow deterministic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;lease_id&lt;/code&gt;: unique identifier for this verification attempt&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;user_id&lt;/code&gt;: the account that owns the attempt&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;expires_at&lt;/code&gt;: the last time the attempt may be consumed&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;status&lt;/code&gt;: &lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;consumed&lt;/code&gt;, or &lt;code&gt;expired&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The disposable email generator or other test mailbox is only a delivery dependency. It is not proof that the token belongs to the current request. That proof should come from the database record and the token hash.&lt;/p&gt;

&lt;p&gt;For CI, each run should have an isolated inbox. This makes it easier to connect a message to a test. See these &lt;a href="https://dev.to/jasonmills94/a-low-noise-aws-alarm-email-check-for-cicd-pipelines-1epk"&gt;low-noise CI email checks&lt;/a&gt; for treating email automation as an observable pipeline step.&lt;/p&gt;

&lt;h2&gt;
  
  
  The database contract
&lt;/h2&gt;

&lt;p&gt;Do not store the raw verification token. Store a hash, just as you would store a password reset token. A compact PostgreSQL table might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;email_verification_leases&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;token_hash&lt;/span&gt; &lt;span class="n"&gt;bytea&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'consumed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'expired'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;consumed_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;verification_leases_active_idx&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;email_verification_leases&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The partial index keeps the common lookup focused on active leases. The expiry check still belongs in the query, because an index does not change the meaning of time. When creating a new lease, invalidate an older pending lease or explicitly select the current attempt.&lt;/p&gt;

&lt;p&gt;That decision matters for retries. A second &lt;code&gt;POST /verification/send&lt;/code&gt; should not create five valid tokens because a browser retried after a timeout. The request needs an idempotency key, or the service needs a deduplication window based on user and purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Node.js endpoint with idempotent behavior
&lt;/h2&gt;

&lt;p&gt;The endpoint can accept an idempotency key and keep its side effects inside a transaction. The example leaves the mail provider behind an interface so the database decision is testable without sending a real message.&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/v1/email-verification&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;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&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;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&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="nx"&gt;userId&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Idempotency-Key&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;key&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="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&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="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idempotency_key_required&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="k"&gt;try&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&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;tx&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;previous&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;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verificationRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&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="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&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;lease&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;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verificationLease&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createPending&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;ttlSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;900&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;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;leaseId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lease&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lease&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expiresAt&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verificationRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;saveResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;mailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueueVerification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lease&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&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="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;202&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="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &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="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;next&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="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;In a real service, the mail enqueue operation needs an outbox record in the same transaction. Calling a provider directly before the transaction commits can produce a message for a lease that was rolled back. The outbox worker can claim unsent records, publish them, and record a delivery attempt.&lt;/p&gt;

&lt;p&gt;The request replay behavior is worth keeping boring. The same key returns the same lease response, even if the first HTTP response was lost. These &lt;a href="https://dev.to/pong1965/cli-guardrails-for-signup-email-checks-3ebb"&gt;CLI guardrails for signup email checks&lt;/a&gt; are useful background for applying the same discipline to automated verification flows. It is occassionally tempting to just resend, but that creates a dependancy on timing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Polling and evidence at the API boundary
&lt;/h2&gt;

&lt;p&gt;Consumers should poll for a message with a stop rule, not forever. Record the lease id, inbox address, trigger time, message id, and why a message was accepted. If no message arrives before expiry, the failure should say whether delivery was late, the subject was wrong, or ownership evidence was missing.&lt;/p&gt;

&lt;p&gt;This also prevents a common debugging shortcut: searching for phrases such as fake e mail com and then opening the newest message by hand. That can hide a routing bug while making the test appear intermittently healthy. Exact lease ownership is stronger evidence than a familiar subject line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational checks for production
&lt;/h2&gt;

&lt;p&gt;Before shipping the flow, I check the following:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Token values are hashed and never written to ordinary logs.&lt;/li&gt;
&lt;li&gt;Expired and consumed leases cannot be used a second time.&lt;/li&gt;
&lt;li&gt;A unique idempotency key prevents duplicate send side effects.&lt;/li&gt;
&lt;li&gt;The outbox has retry limits and a visible dead-letter state.&lt;/li&gt;
&lt;li&gt;Metrics separate API acceptance, provider handoff, delivery, and token consumption.&lt;/li&gt;
&lt;li&gt;Test inboxes are isolated per run and cleaned up after the lease ends.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It is usefull to keep those stages separate in dashboards. A successful &lt;code&gt;202 Accepted&lt;/code&gt; only proves that the API accepted work; it does not prove that the email arrived or that the token was consumed. The metrics should stay seperated even when the first version of the dashboard is small.&lt;/p&gt;

&lt;p&gt;For development, a &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;temp mail so&lt;/a&gt; address can help check a flow manually, but it is not an authentication factor. Keep production verification tied to the user-controlled address and enforce the same token rules everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should every resend create a new token?
&lt;/h3&gt;

&lt;p&gt;Usually, no. Within a short window, return the active lease or deliberately invalidate it before creating a replacement. Either policy is fine if it is explicit and observable.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long should a lease last?
&lt;/h3&gt;

&lt;p&gt;Choose a duration that matches the product journey and provider latency. Fifteen minutes is a reasonable starting point for many signup flows, but the important part is enforcing &lt;code&gt;expires_at&lt;/code&gt; on consumption rather than trusting the client.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use the same inbox across parallel tests?
&lt;/h3&gt;

&lt;p&gt;Only with strong message ownership and a deterministic claim rule. Separate inboxes are simpler, cheaper to troubleshoot, and usually worth the small setup cost.&lt;/p&gt;

&lt;p&gt;The central idea is modest: email verification is a state transition, not a message-shaped side effect. Model ownership and expiry in PostgreSQL, make retries idempotent in Node.js, and preserve delivery evidence. Correctness no longer depends on which email arrives last.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>authentication</category>
    </item>
    <item>
      <title>Node.js Auth Requests Need an Idempotency Ledger</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Tue, 13 Oct 2026 17:23:48 +0000</pubDate>
      <link>https://dev.to/kevindev27/nodejs-auth-requests-need-an-idempotency-ledger-2ghj</link>
      <guid>https://dev.to/kevindev27/nodejs-auth-requests-need-an-idempotency-ledger-2ghj</guid>
      <description>&lt;p&gt;An email-verification endpoint looks simple until a client times out after the server has accepted the request. The browser retries, the mobile app retries again, and suddenly one user has three valid links in an inbox.&lt;/p&gt;

&lt;p&gt;The request are not the email. They are a durable command to create or reuse a delivery attempt. Treating that command as state in PostgreSQL makes a Node.js REST API easier to retry, inspect, and operate. It also gives you a clean boundary between authentication state and the provider that eventually delivers a message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why sending verification email is a state transition
&lt;/h2&gt;

&lt;p&gt;Consider &lt;code&gt;POST /verification-emails&lt;/code&gt;. A client may send the same logical request more than once because of a timeout, a lost response, or a user tapping twice. If every HTTP request creates a fresh token, the service has made the client responsible for deduplication. That is a poor contract.&lt;/p&gt;

&lt;p&gt;The endpoint should accept an idempotency key and record a small state machine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;accepted&lt;/code&gt;: the request was recorded, but no delivery has been claimed&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;queued&lt;/code&gt;: a worker can deliver the message&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sent&lt;/code&gt;: the provider accepted the message&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;failed&lt;/code&gt;: delivery stopped after a retry policy&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;superseded&lt;/code&gt;: a newer verification request invalidated this one&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The token itself can have a separate lifecycle. Keeping the request and token concepts distinct is useful: one request may produce one token, while multiple delivery attempts may be made for that token. That distinction pay off when support needs to answer whether a message was never queued, failed at the provider, or was delivered but never used.&lt;/p&gt;

&lt;p&gt;For test environments, an isolated &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;disposable email&lt;/a&gt; inbox can help validate the delivery edge without touching a real personal mailbox. The inbox is a test dependency, not the source of truth for whether a request was accepted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the request ledger in PostgreSQL
&lt;/h2&gt;

&lt;p&gt;The database needs one unique constraint that represents the client-visible operation. A compact table could look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;verification_requests&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;idempotency_key&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;token_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'accepted'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;last_error&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;verification_requests_queue_idx&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;verification_requests&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'accepted'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The unique key should be scoped to the user or account. A global key can accidentally make two unrelated users share a result if a poorly generated client key is reused. Store a hash of a sensitive key when the key itself could reveal information; the important property is stable equality, not the original text.&lt;/p&gt;

&lt;p&gt;When the same user sends the same key again, return the existing request. Do not silently create a new token. If the payload differs, return a conflict such as &lt;code&gt;409 Conflict&lt;/code&gt;; otherwise a client could reuse a key while changing the destination or verification purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make retries idempotent in a Node.js REST API
&lt;/h2&gt;

&lt;p&gt;The handler should validate the request, begin a transaction, insert the ledger row, and commit before asking a worker to deliver anything. In pseudocode, the shape is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tx&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;tx&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;existing&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;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;oneOrNone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`SELECT id, status, token_id
       FROM verification_requests
      WHERE user_id = $1 AND idempotency_key = $2`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idempotencyKey&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="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;existing&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;token&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;createVerificationToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`INSERT INTO verification_requests
       (id, user_id, idempotency_key, token_id)
     VALUES ($1, $2, $3, $4)
     RETURNING id, status, token_id`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;return&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;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;202&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="na"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In production, the insert and queue publish need an outbox or another durable handoff. Otherwise the process can commit the database row and crash before publishing the job. This is where many implementations gets their "accepted" response wrong: accepted must mean the system has a recoverable path to delivery, not merely that an in-memory function returned.&lt;/p&gt;

&lt;p&gt;An outbox row in the same transaction lets a worker claim work with &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt;. The worker increments &lt;code&gt;attempts&lt;/code&gt;, records provider results, and uses a bounded retry schedule. It should be safe for the same job to be claimed twice, because the provider call and the local state update can still race.&lt;/p&gt;

&lt;p&gt;For operational consistency, the response should be boring and clear, which is a feature for maintaners. A repeat request can return &lt;code&gt;200&lt;/code&gt; with the existing status, while a newly accepted request returns &lt;code&gt;202&lt;/code&gt;. Pick a policy and document it; clients should not have to infer whether a timeout created a second token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate database success from email delivery
&lt;/h2&gt;

&lt;p&gt;Do not mark a verification token as consumed when the email is sent. Sending proves only that a provider accepted a message. Consumption happens when the user presents the token and the API atomically changes it from &lt;code&gt;issued&lt;/code&gt; to &lt;code&gt;consumed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The delivery worker should keep enough evidence to answer three questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Was the request accepted and committed?&lt;/li&gt;
&lt;li&gt;Was a delivery job claimed and attempted?&lt;/li&gt;
&lt;li&gt;Did the provider return an accepted, rejected, or unknown result?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Those facts are more useful than a single &lt;code&gt;email_sent = true&lt;/code&gt; flag. They also make alerting possible: a growing &lt;code&gt;accepted&lt;/code&gt; backlog points to queue health, while repeated provider rejections point to a different boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test duplicate and delayed requests
&lt;/h2&gt;

&lt;p&gt;A good test suite should also check the unhappy pathes. At minimum, cover these cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;two concurrent requests with the same user and idempotency key create one token&lt;/li&gt;
&lt;li&gt;the same key with a changed payload returns a conflict&lt;/li&gt;
&lt;li&gt;a committed row survives a worker restart before delivery&lt;/li&gt;
&lt;li&gt;a second delivery attempt does not create a second verification token&lt;/li&gt;
&lt;li&gt;a consumed token cannot be replayed after another request arrives&lt;/li&gt;
&lt;li&gt;an expired request is visible to cleanup and support queries&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Include realistic malformed input in the test fixtures, such as &lt;code&gt;temp gamil com&lt;/code&gt; or &lt;code&gt;tem email&lt;/code&gt;, but keep those strings out of production validation rules unless they represent an explicitly supported test case. The spelling is not the interesting part; the boundary between user input, token state, and delivery work is.&lt;/p&gt;

&lt;p&gt;If browser tests poll for the message, use a clear stop rule and fresh evidence. This complements &lt;a href="https://dev.to/silviutech/stop-guessing-in-playwright-email-waits-21og"&gt;less guesswork in Playwright email waits&lt;/a&gt;: the test should know which request ID and token it is waiting for, rather than accepting the first message it sees.&lt;/p&gt;

&lt;h2&gt;
  
  
  A short implementation checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Require an idempotency key for the verification command.&lt;/li&gt;
&lt;li&gt;Enforce uniqueness per user and key in PostgreSQL.&lt;/li&gt;
&lt;li&gt;Return an existing result for a safe retry.&lt;/li&gt;
&lt;li&gt;Reject a reused key whose payload differs.&lt;/li&gt;
&lt;li&gt;Commit an outbox event with the request state.&lt;/li&gt;
&lt;li&gt;Keep delivery attempts separate from token consumption.&lt;/li&gt;
&lt;li&gt;Add metrics for accepted, queued, sent, failed, and superseded states.&lt;/li&gt;
&lt;li&gt;Retain enough history to investigate delayed or duplicate requests.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern is intentionally small. It does not remove every email provider failure, but it makes each failure observable and each retry predictable. That is the real goal of an idempotency ledger.&lt;/p&gt;

&lt;h2&gt;
  
  
  Questions engineers usually ask
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should every verification request get a new token?
&lt;/h3&gt;

&lt;p&gt;No. A new logical request can get a new token, but a retry of the same idempotency key should return the original request. A separate endpoint or an explicit new key can represent "send again."&lt;/p&gt;

&lt;h3&gt;
  
  
  How long should the ledger be retained?
&lt;/h3&gt;

&lt;p&gt;Keep terminal rows for the period needed for support, security review, and metrics. Then archive or delete them with a separate retention job. Do not let cleanup remove the evidence needed to explain a delivery incident.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is an idempotency key enough without an outbox?
&lt;/h3&gt;

&lt;p&gt;No. The key prevents duplicate logical requests, but it does not bridge a successful database commit and a failed queue publish. Use a transactional outbox or a queue with a commit-aware integration.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if the provider result is unknown?
&lt;/h3&gt;

&lt;p&gt;Record &lt;code&gt;unknown&lt;/code&gt; and retry according to the provider's guidance. Do not claim certainty from a network timeout. A small amount of ambiguity in the ledger is safer than telling a user that a message definitely was not delivered.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>authentication</category>
    </item>
    <item>
      <title>PostgreSQL Token Cleanup for REST APIs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Tue, 13 Oct 2026 14:22:53 +0000</pubDate>
      <link>https://dev.to/kevindev27/postgresql-token-cleanup-for-rest-apis-57fn</link>
      <guid>https://dev.to/kevindev27/postgresql-token-cleanup-for-rest-apis-57fn</guid>
      <description>&lt;p&gt;Verification tokens are usually designed as if the only important event is a successful click. In a real REST API, the harder work happens around that click: a user requests another email, a worker retries delivery, an old browser tab submits a token, or a cleanup job removes evidence before support can inspect it.&lt;/p&gt;

&lt;p&gt;The database should make those cases explicit. A small PostgreSQL token table, a clear state transition, and a bounded cleanup worker can keep an authentication flow predictable without turning it into a large distributed system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why token cleanup is part of the API contract
&lt;/h2&gt;

&lt;p&gt;An email verification token is temporary state, not just a random string. It has an owner, an intended action, an issued time, an expiration time, and usually a consumption time. If those values are left implicit, different parts of the service will invent their own rules.&lt;/p&gt;

&lt;p&gt;For example, the REST API may consider a token valid for 15 minutes while the worker retries for an hour. A cleanup query might delete rows after 10 minutes, and the Node.js handler might still return a generic success response when the token is already gone. The user sees a confusing loop, while the logs show only "invalid token".&lt;/p&gt;

&lt;p&gt;I treat the lifecycle as a contract with four useful states:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;issued&lt;/code&gt;: the token exists but has not been used&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;consumed&lt;/code&gt;: the intended action completed successfully&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;expired&lt;/code&gt;: the validity window ended without consumption&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;revoked&lt;/code&gt;: a newer request or a security action invalidated it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This also fits a &lt;a href="https://dev.to/sophiax99/oauth-signup-needs-more-than-email-proof-3lg8"&gt;broader threat model for OAuth signup&lt;/a&gt;, where proof of email ownership is only one part of the trust boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model the token lifecycle in PostgreSQL
&lt;/h2&gt;

&lt;p&gt;Store a hash of the token rather than the raw value. The email link can carry the raw secret, but a database leak should not immediately turn every unexpired link into a working credential. A minimal table can look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;verification_tokens&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;token_hash&lt;/span&gt; &lt;span class="n"&gt;bytea&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;purpose&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'issued'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;issued_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;consumed_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;revoked_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_request_id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;verification_tokens_expiry_idx&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;verification_tokens&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'issued'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The partial index keeps the cleanup path focused on live rows. I also like &lt;code&gt;created_request_id&lt;/code&gt; because it connects a token to an idempotency key or request trace. That makes a duplicate-send investigation much less guessy.&lt;/p&gt;

&lt;p&gt;Do not use a cleanup delete as the validity check. The API should validate &lt;code&gt;status = 'issued'&lt;/code&gt; and &lt;code&gt;expires_at &amp;gt; now()&lt;/code&gt; in one transaction. Cleanup is maintenance; it should never be the authority for whether a link works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use a bounded cleanup worker
&lt;/h2&gt;

&lt;p&gt;A cleanup job should do small batches instead of scanning and deleting every expired row in one transaction. Large deletes create lock pressure, make vacuum work harder, and can delay unrelated authentication requests.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;expired&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;verification_tokens&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'issued'&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;
  &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;verification_tokens&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'expired'&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;expired&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;expired&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Marking rows as expired preserves useful history. A later retention job can remove old terminal rows after the support and audit window. The exact retention period depends on the product, but mixing "not valid anymore" with "physically absent" is a common source of observabilty gaps.&lt;/p&gt;

&lt;p&gt;Run this statement repeatedly with a short delay, and stop when the batch is empty. Multiple workers can safely share the queue because &lt;code&gt;SKIP LOCKED&lt;/code&gt; avoids waiting on rows another worker already claimed. Keep the transaction short; a cleanup worker is a dependancy of the system, not the system itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep retries and consumption observable
&lt;/h2&gt;

&lt;p&gt;Token consumption must be atomic. The handler should update only an issued, unexpired token and return the result of that update:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;verification_tokens&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'consumed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;consumed_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;token_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'issued'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;purpose&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If no row is returned, the API can distinguish an already-used token from an expired one by doing a safe follow-up lookup, or it can deliberately expose one generic response to reduce information leakage. The important point is that two requests cannot both consume the same token.&lt;/p&gt;

&lt;p&gt;Record counters for issued, resent, consumed, expired, and revoked tokens. Add the token purpose and a request identifier, but never log the raw token. When a test note says &lt;code&gt;temp mailid&lt;/code&gt;, that may be harmless shorthand, but production logs still need a stable, non-secret correlation value.&lt;/p&gt;

&lt;p&gt;For end-to-end checks, capture the message timestamp and the token request ID rather than merely selecting the newest email. The same principle appears in &lt;a href="https://dev.to/silviutech/playwright-email-polling-needs-fresh-evidence-2m8i"&gt;fresh evidence when polling an email inbox&lt;/a&gt;. It prevents a retry from quietly consuming an older message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the expiry boundary
&lt;/h2&gt;

&lt;p&gt;Time-based tests become brittle when they use the wall clock directly. Inject a clock into the Node.js service and test at exact boundaries:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A token one second before &lt;code&gt;expires_at&lt;/code&gt; is accepted.&lt;/li&gt;
&lt;li&gt;A token at or after &lt;code&gt;expires_at&lt;/code&gt; is rejected.&lt;/li&gt;
&lt;li&gt;Two concurrent consumption requests produce one success.&lt;/li&gt;
&lt;li&gt;A resend revokes or supersedes the previous issued token.&lt;/li&gt;
&lt;li&gt;Cleanup does not change a consumed or revoked token.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Also test a delayed worker and a retry after a database timeout. The API response should not claim a new token was sent unless the outbox or delivery contract recorded that attempt. Otherwise a temporary database error can result in an email the user never receives, occured alongside a misleading success message.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Hash verification secrets at rest.&lt;/li&gt;
&lt;li&gt;Store explicit &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;issued_at&lt;/code&gt;, and &lt;code&gt;expires_at&lt;/code&gt; values.&lt;/li&gt;
&lt;li&gt;Enforce one-time consumption with a conditional update.&lt;/li&gt;
&lt;li&gt;Keep cleanup in bounded batches with &lt;code&gt;SKIP LOCKED&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Preserve terminal rows long enough for support and audit needs.&lt;/li&gt;
&lt;li&gt;Correlate resends, messages, and API requests without logging secrets.&lt;/li&gt;
&lt;li&gt;Inject time so expiry and retry tests are deterministic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern is deliberately modest. PostgreSQL can own token state, the REST API can own the contract, and a small worker can handle retention. That separation keeps authentication behavior understandable when the happy path is no longer the only path.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>authentication</category>
    </item>
    <item>
      <title>REST API Verification State Machines in PostgreSQL</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Mon, 12 Oct 2026 08:22:44 +0000</pubDate>
      <link>https://dev.to/kevindev27/rest-api-verification-state-machines-in-postgresql-np4</link>
      <guid>https://dev.to/kevindev27/rest-api-verification-state-machines-in-postgresql-np4</guid>
      <description>&lt;p&gt;Email verification looks like a small feature, but its reliability depends on clear state transitions, database constraints, and idempotent API behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why verification needs an explicit state machine
&lt;/h2&gt;

&lt;p&gt;Many implementations start with one &lt;code&gt;verified&lt;/code&gt; boolean and a token column. That is enough for a demo, but it becomes hard to reason about when a user requests a second email, clicks an old link, or retries after a network timeout.&lt;/p&gt;

&lt;p&gt;I prefer modelling the verification flow as a small state machine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pending&lt;/code&gt;: a verification request exists and has not been completed.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sent&lt;/code&gt;: a message was scheduled for delivery.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;verified&lt;/code&gt;: the token was consumed successfully.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;expired&lt;/code&gt;: the token can no longer be used.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;revoked&lt;/code&gt;: a newer request invalidated this one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The state is not only documentation. It gives the REST API something concrete to enforce. An expired token cannot move back to &lt;code&gt;pending&lt;/code&gt;, and a verified token cannot be consumed twice. This sounds obvious, but vague states are where auth bugs usually begin.&lt;/p&gt;

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

&lt;p&gt;A separate row for each verification attempt is more useful than overwriting a single token. It preserves an audit trail and lets the application reject an older request without guessing which email was most recent.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;email_verifications&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;              &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt;         &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;token_digest&lt;/span&gt;     &lt;span class="n"&gt;bytea&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;state&lt;/span&gt;            &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sent'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'verified'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'expired'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'revoked'&lt;/span&gt;
    &lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="n"&gt;expires_at&lt;/span&gt;       &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;consumed_at&lt;/span&gt;      &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;       &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;email_verifications_user_pending_idx&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;email_verifications&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sent'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store a digest of the token, not the raw token. The link can contain a random value, while the database only receives its hash for lookup. When a user requests a new link, revoke previous &lt;code&gt;pending&lt;/code&gt; and &lt;code&gt;sent&lt;/code&gt; rows in the same transaction, then insert the new row.&lt;/p&gt;

&lt;p&gt;The partial index keeps the common query small. It also makes the intended active states visible to the next engineer. A little explicitness here saves a lot of debugging later, specially when traffic grows.&lt;/p&gt;

&lt;p&gt;For the email side, an outbox row can represent the delivery request. The &lt;a href="https://dev.to/kevindev27/auth-email-outbox-checks-in-postgresql-3lo9"&gt;queryable PostgreSQL outbox checks&lt;/a&gt; pattern is useful when the API must commit user state and delivery intent together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the REST API transitions idempotent
&lt;/h2&gt;

&lt;p&gt;The verification endpoint should be safe to retry. A client may receive a timeout after the server commits, so returning an error for the second request can make a successful flow look broken.&lt;/p&gt;

&lt;p&gt;For example, the endpoint can follow this sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Hash the presented token.&lt;/li&gt;
&lt;li&gt;Start a transaction and select the matching row with a lock.&lt;/li&gt;
&lt;li&gt;Reject it if it is missing, expired, or revoked.&lt;/li&gt;
&lt;li&gt;If it is already &lt;code&gt;verified&lt;/code&gt;, return the same success shape as a first successful request.&lt;/li&gt;
&lt;li&gt;Otherwise update it to &lt;code&gt;verified&lt;/code&gt;, set &lt;code&gt;consumed_at&lt;/code&gt;, and mark the user as verified.&lt;/li&gt;
&lt;li&gt;Commit before sending the response.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The important part is the conditional update. In a busy system, two requests can present the same token at almost the same time. A lock or a guarded &lt;code&gt;UPDATE ... WHERE state IN ('pending', 'sent')&lt;/code&gt; ensures only one request performs the transition.&lt;/p&gt;

&lt;p&gt;The response should not reveal whether a token was valid for a particular account. Use a stable public result such as &lt;code&gt;verified&lt;/code&gt;, &lt;code&gt;already_verified&lt;/code&gt;, or a generic failure, depending on the threat model. Error details in logs can be richer than details returned to an unknown caller.&lt;/p&gt;

&lt;p&gt;If test data uses a &lt;a href="https://dev.to/mrdapperx/cron-writers-need-a-frozen-plan-5154"&gt;frozen plan for repeatable automation&lt;/a&gt;, add the verification state and token identifier to the run manifest. That makes a flaky test easier to replay. A dummy e mail address is fine for a fixture, but it should never be treated as proof that delivery happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep email delivery outside the verification transaction
&lt;/h2&gt;

&lt;p&gt;Sending an email while holding a database transaction creates an awkward failure window. The provider can be slow, the request can time out, and the database lock can remain open longer than expected.&lt;/p&gt;

&lt;p&gt;Use a transactional outbox instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The API transaction creates the verification row and an outbox event.&lt;/li&gt;
&lt;li&gt;A worker claims the event with a short lease.&lt;/li&gt;
&lt;li&gt;The worker sends the message and records success or a retryable failure.&lt;/li&gt;
&lt;li&gt;A cleanup job expires old verification rows and abandoned events.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This also separates authentication correctness from provider availability. A provider outage should delay a message, not make the user record half-updated. The worker needs bounded retries, because retrying a permanently rejected address forever is just noise and cost.&lt;/p&gt;

&lt;p&gt;Be careful with expiration. Checking &lt;code&gt;expires_at&lt;/code&gt; in application code is necessary, but the database transition should check it too. Otherwise a request that waited in a queue can consume a token after the application made its first decision. The two checks are slightly repetitive, and thats intentional.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational checklist
&lt;/h2&gt;

&lt;p&gt;Before shipping the endpoint, I check the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every legal state transition has a test.&lt;/li&gt;
&lt;li&gt;Two concurrent requests cannot consume one token twice.&lt;/li&gt;
&lt;li&gt;A newer request revokes older active requests.&lt;/li&gt;
&lt;li&gt;Token values are never logged in raw form.&lt;/li&gt;
&lt;li&gt;Expiration is checked during the write, not only during the read.&lt;/li&gt;
&lt;li&gt;Outbox retries have a maximum or a dead-letter path.&lt;/li&gt;
&lt;li&gt;Metrics separate invalid, expired, already-used, and successful attempts.&lt;/li&gt;
&lt;li&gt;Cleanup queries use indexes and are safe to run more than once.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The wording in a few errors may look small, but it affects support and incident response. Keep the public API stable even if internal reasons are refined later. In practise, a predictable contract is more valuable than a clever one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;An email verification flow is an authentication workflow with a delivery dependency. Treating it as a state machine makes the rules visible; PostgreSQL transactions make the transitions enforceable; and an outbox keeps delivery from controlling account correctness.&lt;/p&gt;

&lt;p&gt;The same design works whether the message goes to a corporate inbox, a &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;temp mail so&lt;/a&gt; address used for a controlled test, or another mailbox provider. The important boundary is that the system records what it knows: a token was created, a delivery was requested, and a token was consumed. Those are different facts, and keeping them seperate makes the API easier to operate.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>authentication</category>
    </item>
    <item>
      <title>REST API Verification Links Need a Token Ledger</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Sun, 11 Oct 2026 02:23:31 +0000</pubDate>
      <link>https://dev.to/kevindev27/rest-api-verification-links-need-a-token-ledger-3302</link>
      <guid>https://dev.to/kevindev27/rest-api-verification-links-need-a-token-ledger-3302</guid>
      <description>&lt;p&gt;Email verification often starts as a small endpoint: create a token, send a link, and mark the account as verified when the link is opened. The hard part appears later, when users click twice, mail clients prefetch links, workers retry delivery, or two requests arrive at the same time.&lt;/p&gt;

&lt;p&gt;A verification link is not just a random string. It is a state transition in an authentication workflow. Treating it as a short-lived ledger entry makes the REST API easier to reason about, and PostgreSQL can enforce several of the rules that application code tends to miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  The verification link is a state transition
&lt;/h2&gt;

&lt;p&gt;The useful states are small and explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;issued -&amp;gt; consumed
issued -&amp;gt; expired
issued -&amp;gt; revoked
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only an &lt;code&gt;issued&lt;/code&gt; token should be accepted. A consumed token must not become valid again just because the same URL was loaded in a second browser tab. An expired token should produce a response that tells the client to request a new one, not a generic server error.&lt;/p&gt;

&lt;p&gt;The distinction matters for retries. A mobile client may retry a request after a network timeout even though the first request committed successfully. If the endpoint does not define its behavior for that case, authentication bugs will look random. They arent random; the state model is incomplete.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store token state, not only a hash
&lt;/h2&gt;

&lt;p&gt;Never store a raw verification token when a digest is enough. Return the opaque value only in the email link, then store a hash and the metadata needed to validate it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;email_verification_tokens&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;           &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;      &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;token_hash&lt;/span&gt;   &lt;span class="n"&gt;bytea&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;issued_at&lt;/span&gt;    &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;expires_at&lt;/span&gt;   &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;consumed_at&lt;/span&gt;  &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;revoked_at&lt;/span&gt;   &lt;span class="n"&gt;timestamptz&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;expires_at&lt;/code&gt; value is part of the record, rather than an assumption in the handler. This makes expiration visible during incident review. Keep the token lifetime short enough for the product risk, and make sure clocks are managed consistently across API and worker hosts.&lt;/p&gt;

&lt;p&gt;The hash also reduces the impact of a database snapshot leak. It does not solve every account-security problem, but it prevents a copied token column from becoming an immediate set of usable links. A temp mailid used in a test should be treated as disposable data too, and should never be mixed with production identities.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the REST API replay-safe
&lt;/h2&gt;

&lt;p&gt;The consuming endpoint can be &lt;code&gt;POST /v1/email-verifications/consume&lt;/code&gt;. It should hash the presented value, select the matching row, and consume it inside one transaction. The critical operation is conditional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;email_verification_tokens&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;consumed_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;token_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;consumed_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;revoked_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If one row returns, the request can mark the user verified. If no row returns, the token is invalid, already consumed, revoked, or expired. The API may expose a safe, stable error category, but it should not reveal whether a token once belonged to a real account.&lt;/p&gt;

&lt;p&gt;For concurrent requests, the database update is the boundary that decides the winner. Do not perform a read followed by an unconditional write; both requests can observe the same &lt;code&gt;issued&lt;/code&gt; state. The endpoint should be boring about retries. Boring is good here.&lt;/p&gt;

&lt;p&gt;For a wider operational view, &lt;a href="https://dev.to/mrdapperx/audit-trails-for-ai-writing-cron-jobs-ha2"&gt;auditable background-job runs&lt;/a&gt; show the same principle: a durable receipt is more useful than a log line when work is retried. Record a token ID, user ID, outcome, and request correlation ID, while keeping the secret itself out of logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use PostgreSQL to enforce the contract
&lt;/h2&gt;

&lt;p&gt;Application validation is necessary, but constraints provide the last line of defense. Add indexes for the lookup and for cleanup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;email_verification_tokens_expiry_idx&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;email_verification_tokens&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;consumed_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;revoked_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the product permits only one active token per user, enforce that rule with a partial unique index. If it permits several, attach a generation number or issuance reason so a newer request can revoke older tokens deliberately. The answer should be a product decision, not an accidental side effect of whichever worker ran first.&lt;/p&gt;

&lt;p&gt;Cleanup can delete old rows asynchronously. It doesnt need to run in the request path. Keep consumed records for a bounded audit period, then remove them with a scheduled job. A short retention policy makes storage predictable and limits the amount of authentication history exposed by a future incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing with isolated inboxes
&lt;/h2&gt;

&lt;p&gt;End-to-end tests should verify the API contract, not depend on a shared mailbox. An isolated inbox or a &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;disposable email generator&lt;/a&gt; can provide a recipient for a short-lived test account, while the test records the message ID and token consumption result. Do not put the service link in a reusable fixture; each run needs a fresh token.&lt;/p&gt;

&lt;p&gt;Include cases for an expired token, a second click, two concurrent consumes, a revoked token, and a delivery retry. A &lt;a href="https://dev.to/silviutech/playwright-email-tests-need-a-failure-taxonomy-575n"&gt;useful email-test failure taxonomy&lt;/a&gt; helps keep delivery failures separate from API-state failures. The label temp org mail may appear in old test data, but the system should normalize fixture names and avoid letting labels affect security decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should a second click return success?
&lt;/h3&gt;

&lt;p&gt;Usually no. Return a stable &lt;code&gt;already_consumed&lt;/code&gt; category so the client can show a useful recovery path. Making every old link look successful hides replay attempts and makes debugging harder.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should token consumption and user verification share a transaction?
&lt;/h3&gt;

&lt;p&gt;Yes, when the user update depends on that token. Commit both changes together, or the system can consume a link while leaving the account unverified after a partial failure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a queue for verification emails?
&lt;/h3&gt;

&lt;p&gt;Not for the token state itself. A queue is useful for delivery, but issuance and consumption still need transactional boundaries in the API and database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Generate high-entropy opaque values and store only their hashes.&lt;/li&gt;
&lt;li&gt;Give every token an explicit expiry, consumed state, and revocation state.&lt;/li&gt;
&lt;li&gt;Consume with one conditional database update inside a transaction.&lt;/li&gt;
&lt;li&gt;Make concurrent requests deterministic and keep secrets out of logs.&lt;/li&gt;
&lt;li&gt;Test retries, expiry, replay, revocation, and delivery separately.&lt;/li&gt;
&lt;li&gt;Retain audit data for a defined period, then clean it up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The main design payoff is clarity. The email is only the transport; PostgreSQL-backed state is what makes the verification decision safe, repeatable, and explainable.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>authentication</category>
    </item>
    <item>
      <title>Node.js Email Verification Needs Atomic Tokens</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Sat, 10 Oct 2026 02:22:57 +0000</pubDate>
      <link>https://dev.to/kevindev27/nodejs-email-verification-needs-atomic-tokens-dpc</link>
      <guid>https://dev.to/kevindev27/nodejs-email-verification-needs-atomic-tokens-dpc</guid>
      <description>&lt;p&gt;Email verification looks like a small feature: create a token, send a message, and accept the token when the user clicks it. In a real Node.js service, the difficult part is not generating the token. It is deciding what happens when the user clicks twice, the link expires during a retry, or two API workers process the same request at nearly the same time.&lt;/p&gt;

&lt;p&gt;The reliable design is to treat verification as a state transition backed by one atomic database operation. The email is only the delivery channel. PostgreSQL remains the authority for whether a token is valid and whether it has already been consumed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The race behind a familiar verification bug
&lt;/h2&gt;

&lt;p&gt;A naive endpoint usually does this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the verification row.&lt;/li&gt;
&lt;li&gt;Check that &lt;code&gt;used_at&lt;/code&gt; is empty and &lt;code&gt;expires_at&lt;/code&gt; is in the future.&lt;/li&gt;
&lt;li&gt;Mark the row as used.&lt;/li&gt;
&lt;li&gt;Activate the user.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each step seems fine in isolation. The problem is that two requests can both finish step 2 before either request reaches step 3. A double-click, a browser retry, or two load-balanced workers can then accept the same token twice. The result may be duplicate welcome actions, repeated audit events, or an account activated by a request that should have failed.&lt;/p&gt;

&lt;p&gt;Email delivery creates a similar ambiguity. A disposable temporary email inbox or a create temp mail workflow may expose delayed messages, duplicates, or an old verification link. Test coverage should therefore assert both token behavior and message selection. &lt;a href="https://dev.to/silviutech/how-to-stop-playwright-email-tests-from-flaking-across-parallel-workers-ok8"&gt;Parallel email test isolation&lt;/a&gt; is useful context when several workers exercise the same signup flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store a digest, not the raw token
&lt;/h2&gt;

&lt;p&gt;The URL can contain a random, single-use value, but the database does not need to store that value directly. Store a cryptographic digest instead. If a database snapshot is exposed, the attacker should not immediately have usable verification links.&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;createHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;randomBytes&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;node:crypto&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;function&lt;/span&gt; &lt;span class="nf"&gt;issueToken&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;randomBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;base64url&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;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;digest&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;Store the digest, user ID, expiry time, and consumption time in a dedicated table. Keep the raw value only long enough to construct the link and hand it to the mail provider. Do not log it, even at debug level. That logging habit is easy to miss and painful to remove later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;email_verification_tokens&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;token_digest&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;used_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;email_verification_tokens_active_idx&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;email_verification_tokens&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_digest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;used_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The partial index is not a security boundary, but it keeps the common lookup focused on tokens that could still be consumed. A cleanup job can remove old rows later; cleanup should never decide whether a current request is valid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Consume the token in one PostgreSQL statement
&lt;/h2&gt;

&lt;p&gt;The key operation is a conditional &lt;code&gt;UPDATE&lt;/code&gt;. It checks the digest, expiry, and unused state while taking the row lock. Only a request that updates one row has won the token.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;email_verification_tokens&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;used_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;token_digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;used_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Node.js, treat an empty result as a normal rejected verification, not as a database error. The query can return zero rows because the token is unknown, expired, or already consumed. Those causes can be logged internally with a safe reason, while the public response stays deliberately broad.&lt;/p&gt;

&lt;p&gt;After the token is consumed, activate the user in the same transaction. If activation fails, roll back the token update too; otherwise the user could receive a “try again” response while their only token is already gone.&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;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BEGIN&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;try&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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;consumeSql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;digest&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="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowCount&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="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ROLLBACK&lt;/span&gt;&lt;span class="dl"&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;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rejected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;UPDATE users SET email_verified_at = now() WHERE id = $1&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="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&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="nx"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;COMMIT&lt;/span&gt;&lt;span class="dl"&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;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;verified&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ROLLBACK&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&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;The transaction must use the same connection for every statement. Returning a client to the pool between &lt;code&gt;BEGIN&lt;/code&gt; and &lt;code&gt;COMMIT&lt;/code&gt; breaks the boundary, a mistake that can be suprisingly hard to spot in local tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the REST API state machine explicit
&lt;/h2&gt;

&lt;p&gt;The endpoint should expose a small, documented contract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;200 OK&lt;/code&gt; when this request verifies the account.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;400 Bad Request&lt;/code&gt; when the token format is invalid.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;410 Gone&lt;/code&gt; when a well-formed token is expired or already consumed, if that distinction is useful to the client.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;500 Internal Server Error&lt;/code&gt; only for an actual service failure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not let the client infer success from a redirect alone. Return a stable response body and an internal request ID for support diagnostics. If the product wants repeated clicks to be harmless, it can render the same confirmation page after a &lt;code&gt;410&lt;/code&gt;; that is a UX choice, not a reason to accept the token a second time.&lt;/p&gt;

&lt;p&gt;When testing the mail side, assert recipient, run marker, subject, and link before consuming the token. &lt;a href="https://dev.to/silviutech/playwright-email-tests-catch-the-wrong-message-4d26"&gt;Matching the intended verification message&lt;/a&gt; matters because an old message can contain a perfectly valid-looking URL for a different test run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test expiry and concurrent clicks
&lt;/h2&gt;

&lt;p&gt;The high-value tests are small and deterministic:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A fresh token updates exactly one user and cannot be used again.&lt;/li&gt;
&lt;li&gt;An expired token updates zero rows.&lt;/li&gt;
&lt;li&gt;Two concurrent requests produce one success and one rejection.&lt;/li&gt;
&lt;li&gt;A failure during user activation rolls back token consumption.&lt;/li&gt;
&lt;li&gt;A malformed token never reaches a database lookup.&lt;/li&gt;
&lt;li&gt;A repeated mail message cannot make a test choose the wrong verification link.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Use a transaction-capable PostgreSQL test instance for the concurrency case. A mock that returns a row twice will not prove that row locking and the conditional update work together. Also test the boundary where &lt;code&gt;expires_at&lt;/code&gt; equals the current database time; application and database clocks should not quietly disagree.&lt;/p&gt;

&lt;p&gt;Some support searches contain labels like &lt;code&gt;temp org mail&lt;/code&gt; or &lt;code&gt;tepm mail com&lt;/code&gt;. Keep those terms out of token logic and analytics dimensions; they are just misspelled descriptions, not a security category. This little distinction saves confusion in incident reports.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Hash random tokens before persistence.&lt;/li&gt;
&lt;li&gt;Never log raw verification URLs or token values.&lt;/li&gt;
&lt;li&gt;Consume and activate inside one PostgreSQL transaction.&lt;/li&gt;
&lt;li&gt;Use one pooled connection for the transaction.&lt;/li&gt;
&lt;li&gt;Give tokens a clear expiry and cleanup policy.&lt;/li&gt;
&lt;li&gt;Make the one-time behavior visible in API documentation.&lt;/li&gt;
&lt;li&gt;Record safe rejection reasons and a request ID.&lt;/li&gt;
&lt;li&gt;Test two workers clicking the same link at once.&lt;/li&gt;
&lt;li&gt;Assert that email tests select the message for their own run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The main design decision is simple: delivery can be delayed or duplicated, but token consumption must be atomic. Once that boundary is explicit, the Node.js handler, REST API responses, PostgreSQL schema, and test fixtures all become easier to reason about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Questions that come up in review
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should the token be deleted instead of marked used?
&lt;/h3&gt;

&lt;p&gt;Usually, keeping &lt;code&gt;used_at&lt;/code&gt; is more useful. It supports audit queries and makes a second click distinguishable from an unknown token. A retention job can delete old rows after the audit window.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should verification be idempotent?
&lt;/h3&gt;

&lt;p&gt;The user-facing confirmation page can be repeatable, but token consumption should stay one-time. Keep those concepts separate: a repeated request may render a friendly result without changing the account again.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a short token lifetime enough?
&lt;/h3&gt;

&lt;p&gt;No. Expiry limits the window, while hashing, TLS, redaction, atomic consumption, and rate limiting address different risks. They work together, they dont replace one another.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>authentication</category>
    </item>
    <item>
      <title>Designing a Verification Inbox Contract for Node.js APIs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Fri, 09 Oct 2026 11:22:54 +0000</pubDate>
      <link>https://dev.to/kevindev27/designing-a-verification-inbox-contract-for-nodejs-apis-5bik</link>
      <guid>https://dev.to/kevindev27/designing-a-verification-inbox-contract-for-nodejs-apis-5bik</guid>
      <description>&lt;p&gt;Email verification is often treated as a side effect of account creation: send a message, wait for a link, and mark the user as verified. In a real REST API, that side effect has its own delivery delay, retries, database state, and test inbox. The design gets much easier when the email is treated as a contract with an explicit receipt.&lt;/p&gt;

&lt;h2&gt;
  
  
  The inbox is part of the API contract
&lt;/h2&gt;

&lt;p&gt;An authentication endpoint usually has at least two independent outcomes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The user record is created or updated.&lt;/li&gt;
&lt;li&gt;A verification message is accepted for delivery.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Those outcomes should be connected by a correlation ID, not by the assumption that the newest message in a mailbox belongs to the newest request. A request can be retried by a client, a worker can retry a provider call, and a test runner can poll while an older message is still visible.&lt;/p&gt;

&lt;p&gt;The API response can expose a non-sensitive &lt;code&gt;verification_id&lt;/code&gt; while keeping the actual token out of logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"8f3a4c1e"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"verification_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ver_01JY7K8Q"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pending"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The receipt become useful when every later action can refer to it. A test using a free temporary email address can wait for the matching recipient and correlation ID instead of guessing from message order. Search noise such as temp org mail or tempail should not become part of that domain model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model verification as a receipt
&lt;/h2&gt;

&lt;p&gt;Keep a verification record separate from the user row. It should describe what was issued, when it expires, and whether it was consumed. The important fields is small and deliberately boring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;verification_id
user_id
token_hash
correlation_id
expires_at
consumed_at
created_at
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store a hash of the token, not the raw token. The raw value belongs only in the link sent to the user. A &lt;code&gt;correlation_id&lt;/code&gt; can be copied into provider metadata or a test-only header, but it should not contain an email address or secret.&lt;/p&gt;

&lt;p&gt;The endpoint that consumes a token should be safe to call more than once. If the token was already consumed, return a stable result such as &lt;code&gt;already_verified&lt;/code&gt; rather than changing the user back to an unverified state. The original request may have succeeded even when the client never received its response, so retries are normal behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  PostgreSQL constraints that make retries boring
&lt;/h2&gt;

&lt;p&gt;The database should enforce the rules that application code is likely to forget under concurrency. A simplified schema might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;email_verifications&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;              &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt;         &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;token_hash&lt;/span&gt;      &lt;span class="n"&gt;bytea&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;correlation_id&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;expires_at&lt;/span&gt;      &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;consumed_at&lt;/span&gt;     &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;      &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;one_active_verification_per_user&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;email_verifications&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;consumed_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whether one active token per user is the right policy depends on the product. The useful part is deciding the policy explicitly. A retry can arrive after the original request was successful, so the row must not gets a second meaning.&lt;/p&gt;

&lt;p&gt;When issuing a token, use a transaction to retire or replace the previous active record and insert the new one. When consuming it, update only a row that is unconsumed and unexpired:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;email_verifications&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;consumed_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;consumed_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If no row is returned, the token is expired, unknown, or already used. Do not returns a detailed reason to an unauthenticated caller; that can make account enumeration easier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the message with a cursor
&lt;/h2&gt;

&lt;p&gt;The mailbox client needs a message cursor or a provider message ID. “Read the latest email” is not a reliable assertion when multiple jobs share infrastructure. Filter by recipient, subject, correlation ID, and a bounded time window. Then record which message was consumed.&lt;/p&gt;

&lt;p&gt;For browser-driven tests, the same principle applies to waiting. Use &lt;a href="https://dev.to/silviutech/stop-guessing-in-playwright-email-waits-21og"&gt;bounded email waits&lt;/a&gt; with a deadline and a useful failure receipt, rather than adding another fixed sleep. A test that only checks a 200 response miss the most important part of the flow: whether the right message was delivered and consumed.&lt;/p&gt;

&lt;p&gt;Keep the test inbox scoped to the run when possible. &lt;a href="https://dev.to/silviutech/playwright-checks-for-facebook-signup-emails-ba5"&gt;Isolated inbox checks&lt;/a&gt; are especially valuable when retries and parallel jobs are involved. The inbox is test data, not a shared dumping ground.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expiry and cleanup are part of correctness
&lt;/h2&gt;

&lt;p&gt;Expiration belongs in both the token query and the cleanup process. The API must reject an expired token immediately. A scheduled worker can later delete old verification rows and remove test messages after their retention window. The cleanup worker run independently, because a request finishing successfully does not prove that every temporary resource was removed.&lt;/p&gt;

&lt;p&gt;Keep operational records small: verification ID, user ID, state, provider message ID, and timestamps. Do not log the token, full message body, or private links. This makes incident review safer and makes it possible to answer whether the message was sent, received, and consumed.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical verification checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Generate a cryptographically random token and store only its hash.&lt;/li&gt;
&lt;li&gt;Attach a correlation ID to the verification record and delivery request.&lt;/li&gt;
&lt;li&gt;Make issuance and consumption idempotent.&lt;/li&gt;
&lt;li&gt;Enforce expiry in PostgreSQL, not only in application code.&lt;/li&gt;
&lt;li&gt;Poll a run-scoped inbox by cursor, recipient, and correlation ID.&lt;/li&gt;
&lt;li&gt;Set a clear timeout and return a failure receipt from tests.&lt;/li&gt;
&lt;li&gt;Delete or redact message data after the retention period.&lt;/li&gt;
&lt;li&gt;Measure delivery latency separately from API response latency.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last distinction matters. A fast endpoint can still hide a slow worker or an unavailable mail provider. It also make capacity problems visible before users report missing verification links.&lt;/p&gt;

&lt;h2&gt;
  
  
  Questions worth answering before shipping
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What happens when the user requests a second link?&lt;/strong&gt; Decide whether the first token is revoked, whether both remain valid, and how the message test identifies the newest valid receipt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can a replay change account state?&lt;/strong&gt; It should not. Consumption needs an atomic condition and a stable response for already-used tokens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who owns failed cleanup?&lt;/strong&gt; Name the worker, metric, and alert. A temporary inbox with no owner is just hidden state waiting to become a flaky test.&lt;/p&gt;

&lt;p&gt;An email verification flow is more reliable when its message, token, and cleanup behavior are explicit parts of the backend design. The result is not only better tests; it is an authentication path that stays understandable when networks, clients, and workers retry at the same time.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>authentication</category>
    </item>
    <item>
      <title>REST API Verification Needs an Email Lease</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Fri, 09 Oct 2026 02:24:27 +0000</pubDate>
      <link>https://dev.to/kevindev27/rest-api-verification-needs-an-email-lease-239f</link>
      <guid>https://dev.to/kevindev27/rest-api-verification-needs-an-email-lease-239f</guid>
      <description>&lt;p&gt;Email verification looks like a simple two-request flow: create an account, send a message, then accept a token. In a production REST API, it is closer to a distributed workflow. The send can be retried, delivered twice, or consumed twice.&lt;/p&gt;

&lt;p&gt;The failure mode I see most often is not a bad token. It is an ownership problem. Two requests believe they are allowed to consume the same verification message, or a delayed message arrives after the user has already requested a replacement. A lease gives the backend a clear answer to a useful question: which attempt currently owns this verification step?&lt;/p&gt;

&lt;h2&gt;
  
  
  Why verification retries need ownership
&lt;/h2&gt;

&lt;p&gt;Suppose a mobile client calls &lt;code&gt;POST /verification/email&lt;/code&gt; and times out. The server may have committed the request even though the client never saw the response. The client retries, and the API creates a second message. Later, both links are valid, or the older link unexpectedly wins.&lt;/p&gt;

&lt;p&gt;The same issue appears in automated environments that use a &lt;a href="https://dev.to/silviutech/stop-cross-test-inbox-pollution-5fd4"&gt;temp mailbox&lt;/a&gt; or a free temporary email address for test accounts. A mailbox can contain several plausible messages, but plausibility is not identity. The test needs to know which request created which message and which attempt is allowed to claim it.&lt;/p&gt;

&lt;p&gt;An email lease is a short-lived ownership record tied to a user, a verification purpose, and an idempotency key. It should have an owner, an expiry time, and a state. The state makes the workflow visible instead of hiding it inside a polling loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model the email as a leased resource
&lt;/h2&gt;

&lt;p&gt;I use four states for a verification delivery:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;requested -&amp;gt; leased -&amp;gt; consumed
                 \-&amp;gt; expired
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;requested&lt;/code&gt; means the API accepted the intent but has not assigned a delivery attempt. &lt;code&gt;leased&lt;/code&gt; means one worker owns the attempt for a bounded period. &lt;code&gt;consumed&lt;/code&gt; means the token was accepted successfully. &lt;code&gt;expired&lt;/code&gt; means another attempt may replace it, subject to the product's rate limits.&lt;/p&gt;

&lt;p&gt;The lease should not be the token itself. Keep the token hash separate, and store only enough metadata to correlate the delivery:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;verification_deliveries&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;purpose&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;idempotency_key&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;token_hash&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="s1"&gt;'requested'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;lease_owner&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;lease_until&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;consumed_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="k"&gt;unique&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;purpose&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idempotency_key&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;The unique constraint is important. It lets a retry find the original delivery instead of creating another one. The &lt;code&gt;lease_owner&lt;/code&gt; can be a worker ID or request trace ID; it should be safe to log, unlike the raw verification token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implement the lease with PostgreSQL
&lt;/h2&gt;

&lt;p&gt;Claiming a delivery must be atomic. A worker can select an unleased row, verify that its lease is available, and update it in one transaction. &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; is useful when several delivery workers share a queue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;verification_deliveries&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'requested'&lt;/span&gt;
   &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'leased'&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;lease_until&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="n"&gt;skip&lt;/span&gt; &lt;span class="n"&gt;locked&lt;/span&gt;
&lt;span class="k"&gt;limit&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="n"&gt;verification_deliveries&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'leased'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lease_owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lease_until&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'2 minutes'&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The worker should renew only while it is making progress. A lease that never expires becomes a stuck job. A lease that is too short causes duplicate delivery when a slow provider is still processing the first attempt. The right duration depends on provider latency, but the decision should be explicit and observable.&lt;/p&gt;

&lt;p&gt;The send operation also needs a receipt. Record the provider message ID, response status, and attempt timestamp after the provider accepts the message. If the provider call times out, the result is unknown; dont immediately release the lease and send again. Mark the attempt as uncertain, then reconcile it with a provider lookup or a controlled retry policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the REST API retry-safe
&lt;/h2&gt;

&lt;p&gt;The API endpoint should accept an idempotency key from the client or generate one at the boundary. Repeating the same key should return the existing delivery status, not create a new token. A different key can represent a deliberate new request, subject to a cooldown.&lt;/p&gt;

&lt;p&gt;For token consumption, use a conditional update:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="n"&gt;verification_deliveries&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'consumed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;consumed_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'leased'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;lease_until&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;token_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="n"&gt;returning&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If no row is returned, the API should return a stable error such as &lt;code&gt;verification_expired&lt;/code&gt; or &lt;code&gt;verification_already_used&lt;/code&gt;. Do not leak whether a token hash was close to matching. Authentication responses are easier to operate when their error vocabulary is small and predictable.&lt;/p&gt;

&lt;p&gt;Keep token consumption and the account activation update in the same transaction when possible. That way, a successful response means both facts were committed. If they must be separate, use an explicit state transition and a repair job; a vague &lt;code&gt;200 OK&lt;/code&gt; thats emitted before activation is durable will create support cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing expiry and cleanup
&lt;/h2&gt;

&lt;p&gt;Test the boundary cases as first-class scenarios:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Two workers try to claim the same delivery. Exactly one gets the lease.&lt;/li&gt;
&lt;li&gt;A worker pauses past &lt;code&gt;lease_until&lt;/code&gt;. A replacement can claim the row.&lt;/li&gt;
&lt;li&gt;The same idempotency key is submitted repeatedly. The delivery count stays one.&lt;/li&gt;
&lt;li&gt;The provider times out after accepting the message. Reconciliation does not create an uncontrolled duplicate.&lt;/li&gt;
&lt;li&gt;A consumed token is submitted again. The response is stable and no account state changes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For browser tests, keep the correlation ID and provider message ID in the test receipt. &lt;a href="https://dev.to/bitheirstake/temporary-inbox-tests-need-deletion-proof-3flb-temp-slug-2710229?preview=9152b7ee64c3479fee91f6b480017ca87ed59e4cebb850a22f8f412aab4040b7f1714580b71d82c0ef2ec06ed996a3ec2f64f420f4f2e723695f1f0a"&gt;Proving temporary inbox deletion&lt;/a&gt; is part of cleanup, but deletion should happen after the receipt is stored. A test value like &lt;code&gt;tempail&lt;/code&gt; or &lt;code&gt;dummy e mail&lt;/code&gt; can cover input validation; it should never be accepted as a real delivery address.&lt;/p&gt;

&lt;p&gt;It is also worth testing the cleanup worker. Expired leases need a clear owner, and old token hashes should have a retention policy. The cleanup job doesnt need to delete every row immediately; it does need metrics for expired, reconciled, and permanently failed deliveries. The receipt is durable evidence; logs alone isnt enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final takeaways
&lt;/h2&gt;

&lt;p&gt;Email verification becomes more predictable when delivery attempts have ownership, expiry, and an idempotent identity. PostgreSQL can enforce the important parts: one request key maps to one delivery, one transaction claims a lease, and one conditional update consumes a token.&lt;/p&gt;

&lt;p&gt;The lease is not a replacement for provider reliability or client-side retries. It is the boundary that makes those behaviors safe to reason about. When a request fails, the system can say whether it was still leased, expired, delivered, consumed, or awaiting reconciliation. Thats much more useful than simply sending another email.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>authentication</category>
    </item>
    <item>
      <title>REST APIs Need a Verification Token Inbox</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Thu, 08 Oct 2026 08:23:30 +0000</pubDate>
      <link>https://dev.to/kevindev27/rest-apis-need-a-verification-token-inbox-40mp</link>
      <guid>https://dev.to/kevindev27/rest-apis-need-a-verification-token-inbox-40mp</guid>
      <description>&lt;p&gt;Email verification is usually described as a send operation: create a token, send an email, and return success. In a real backend, that description hides the hardest part. The application must know which token is current, whether it has been consumed, and what to do when a client retries while the mail provider is slow.&lt;/p&gt;

&lt;p&gt;I prefer treating verification as an inbox with a small, explicit state machine. The REST API writes an intent to PostgreSQL, a worker delivers the message, and a confirmation endpoint consumes the token exactly once. This separates authentication state from provider behaviour and makes failures easier to explain.&lt;/p&gt;

&lt;p&gt;The same boundary is useful in staging. A burner email can help a developer inspect a real confirmation message, but test systems still need an isolated record and a cleanup policy. A string such as temp org mail may appear in old fixtures, so it is worth keeping fixture naming seperate from production identity data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why verification tokens need an inbox
&lt;/h2&gt;

&lt;p&gt;An email provider call has an uncertain result. The connection can fail before the response arrives, or a worker can crash just after sending. If the API only stores a token in memory, a retry may create a second valid token and leave the user unsure which message to trust.&lt;/p&gt;

&lt;p&gt;An inbox record gives each request a durable identity. It can contain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;verification_id
user_id
token_digest
status: pending | sent | consumed | expired
attempt_count
expires_at
created_at
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store a digest instead of the raw token. The raw value belongs in the email link; the database only needs to compare a hash supplied by the confirmation request. This limits the impact of a database read leak, and it keeps the token lifecycle visible to operators.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model the token lifecycle explicitly
&lt;/h2&gt;

&lt;p&gt;There are three decisions that should not be mixed together:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Issuing&lt;/strong&gt; creates a new verification attempt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delivering&lt;/strong&gt; moves an attempt through the mail provider workflow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consuming&lt;/strong&gt; accepts a token once and changes the account state.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For an account that requests another message, mark older pending tokens as superseded before inserting the new one. The client can safely ask for a resend, while the server makes only the newest token valid. This is less suprising than accepting several links and hoping the user clicks the intended one.&lt;/p&gt;

&lt;p&gt;I also put a short expiration on the token and a longer retention period on the audit row. The token should stop working quickly; the operational record can remain long enough to answer support questions without retaining the secret itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claim tokens safely in PostgreSQL
&lt;/h2&gt;

&lt;p&gt;A worker can claim pending deliveries with a transaction and row locking. &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; lets multiple workers scan the same queue without waiting on rows already owned by another worker.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;next_delivery&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;verification_deliveries&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;available_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;
  &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;verification_deliveries&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'sending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lease_until&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'60 seconds'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attempt_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;attempt_count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;next_delivery&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;next_delivery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;d&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lease is important because a process may die while sending. A reaper can return rows with an expired lease to &lt;code&gt;pending&lt;/code&gt;, with a retry limit and backoff. Do not hold the database transaction open while calling the provider; claim the row, commit, send, then record the result.&lt;/p&gt;

&lt;p&gt;For deeper audit concerns, I keep the write path consistent with the &lt;a href="https://dev.to/kevindev27/postgresql-audits-for-email-change-apis-3nl"&gt;PostgreSQL audits for email change APIs&lt;/a&gt;. The key idea is that delivery history is append-friendly evidence, not a mutable guess about the current account state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the REST API idempotent
&lt;/h2&gt;

&lt;p&gt;The issue endpoint should accept an idempotency key supplied by the client. Repeating the same request returns the existing verification attempt instead of creating another one. The database needs a unique constraint scoped to the account and key, plus a clear policy for an expired key.&lt;/p&gt;

&lt;p&gt;The confirmation endpoint has a different contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /v1/email-verifications/confirm
Idempotency-Key: confirm-8f2...
Content-Type: application/json

{"token":"..."}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In one transaction, find the unexpired digest, lock it, verify the hash, mark it consumed, and update the account. A second request with the same token should return a stable result such as &lt;code&gt;already_confirmed&lt;/code&gt;, not perform the state change twice. The response can be boring; boring is good for clients and support tooling.&lt;/p&gt;

&lt;p&gt;When the provider response is ambiguous, the worker should retry according to a bounded policy. The authentication API should not pretend that a message was delivered merely because a job was claimed. That distinction prevents a green API response from hiding a dead inbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the failure paths
&lt;/h2&gt;

&lt;p&gt;Unit tests cover token hashing and expiration, but the valuable checks are integration tests around transaction boundaries. Force a worker crash after claiming a row, then verify that the lease expires and another worker can retry it. Run two confirmation requests concurrently and assert that only one transaction consumes the token.&lt;/p&gt;

&lt;p&gt;It is also useful to preserve a small receipt for each CI run. The approach described in these &lt;a href="https://dev.to/pong1965/turn-api-tests-into-useful-ci-receipts-3731"&gt;useful CI receipts for API tests&lt;/a&gt; makes it easier to tell a provider timeout from a database race. In my experience, this is where flaky email tests become diagnosable instead of just re-run until green.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small production checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Hash tokens at rest and never log the raw value.&lt;/li&gt;
&lt;li&gt;Enforce one current token per account, or document why not.&lt;/li&gt;
&lt;li&gt;Add expiration, attempt limits, and delivery leases.&lt;/li&gt;
&lt;li&gt;Use idempotency keys for issue and confirmation requests.&lt;/li&gt;
&lt;li&gt;Make token consumption a single transactional state change.&lt;/li&gt;
&lt;li&gt;Track provider ambiguity separately from API acceptance.&lt;/li&gt;
&lt;li&gt;Test concurrent confirmation and worker recovery.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An email verification flow becomes much easier to maintain when the inbox is treated as a backend component, not as a side effect of an HTTP request. PostgreSQL gives the system durable state, the REST API gives clients a predictable contract, and the worker can retry without inventing a new identity each time. There are still edge cases, but they are named edge cases now—and that is a good place to start.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>authentication</category>
    </item>
    <item>
      <title>Email Verification Workers Need a Delivery Lease</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Thu, 08 Oct 2026 05:22:42 +0000</pubDate>
      <link>https://dev.to/kevindev27/email-verification-workers-need-a-delivery-lease-370i</link>
      <guid>https://dev.to/kevindev27/email-verification-workers-need-a-delivery-lease-370i</guid>
      <description>&lt;p&gt;Email verification is often implemented as a single request: create a token, send a message, and return success. That model breaks when the mail provider times out after accepting the request, a worker crashes halfway through delivery, or two workers pick the same job.&lt;/p&gt;

&lt;p&gt;The useful boundary is a delivery lease. The API records the verification intent in PostgreSQL, and a worker temporarily claims the resulting outbox row. A crash then leaves work available for another worker, while the lease prevents two active workers from sending the same message at the same time.&lt;/p&gt;

&lt;p&gt;This is the pattern I reach for when an authentication flow must be boring under failure. It also keeps test data separate from real customer data; a disposable email address or free temporary email inbox can be useful for a controlled staging check, but neither replaces a server-side delivery contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a verification worker needs a lease
&lt;/h2&gt;

&lt;p&gt;A queue acknowledgement is not the same as email delivery. Imagine a worker calls the provider and the network connection drops before the provider's response reaches your service. The worker cannot know whether the message was accepted. Retrying may send a duplicate, while refusing to retry may lose the verification email.&lt;/p&gt;

&lt;p&gt;There is no universal way to remove that ambiguity across an arbitrary email provider. The practical goal is to make the duplicate safe and the state observable. Store one logical delivery per verification request, assign it a bounded retry budget, and record provider message IDs when they are available.&lt;/p&gt;

&lt;p&gt;The lease handles a different problem: concurrent ownership. It should have an owner, an expiry timestamp, and a version or attempt number. A worker may process a row only while its lease is current. If the process dies, the expiry makes the row claimable again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the outbox and lease states
&lt;/h2&gt;

&lt;p&gt;Keep the state machine small. A typical row in an &lt;code&gt;email_outbox&lt;/code&gt; table contains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;id&lt;/code&gt; and &lt;code&gt;verification_id&lt;/code&gt;, with a unique constraint on the logical verification delivery&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;status&lt;/code&gt;, such as &lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;sending&lt;/code&gt;, &lt;code&gt;sent&lt;/code&gt;, or &lt;code&gt;failed&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;lease_owner&lt;/code&gt; and &lt;code&gt;lease_until&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;attempts&lt;/code&gt;, &lt;code&gt;next_attempt_at&lt;/code&gt;, and &lt;code&gt;last_error&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;provider_message_id&lt;/code&gt; and timestamps for creation and completion&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The verification record should own the token and its expiration. The outbox row owns delivery attempts. Mixing those concerns makes it easy for a mail retry to accidentally create a new token, or for a token refresh to reset a delivery counter.&lt;/p&gt;

&lt;p&gt;I also keep the token value out of ordinary logs. Log a verification ID and a short correlation ID instead. A temp mailid appearing in a debug line is not harmless if the log retention and access policy were never designed for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claim work safely in PostgreSQL
&lt;/h2&gt;

&lt;p&gt;Workers can claim a batch with a short transaction. &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; is a good fit when several workers poll the same table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;email_outbox&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;next_attempt_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lease_until&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;lease_until&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;next_attempt_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;
  &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;email_outbox&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'sending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lease_owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lease_until&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'2 minutes'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;e&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The transaction should commit before the worker performs the network call. Holding row locks while waiting on a provider creates unnecessary contention. The two-minute value is only an example; choose it from measured provider latency and worker shutdown behavior, then renew it for genuinely long sends.&lt;/p&gt;

&lt;p&gt;When updating the result, include both &lt;code&gt;id&lt;/code&gt; and &lt;code&gt;lease_owner&lt;/code&gt; in the &lt;code&gt;WHERE&lt;/code&gt; clause. A stale worker must not mark a row as sent after another worker has reclaimed it. This small condition prevents a late response from corrupting the current state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make retries and expiration explicit
&lt;/h2&gt;

&lt;p&gt;Use exponential backoff with a ceiling, for example one minute, five minutes, and fifteen minutes, instead of retrying in a tight loop. Classify provider responses into transient failures, permanent failures, and unknown outcomes. Unknown outcomes deserve a retry policy plus an alert when the same logical delivery keeps oscillating.&lt;/p&gt;

&lt;p&gt;The token expiration and delivery retry deadline should be related but not identical. A token can expire while its final message is still being attempted. In that case, stop sending it and let the API issue a fresh verification request. Otherwise users receive links that were already unusable when the message arrived.&lt;/p&gt;

&lt;p&gt;At the API layer, accept an idempotency key for the create-verification operation. Repeating the same request should return the existing verification result, not create another outbox row. A different email payload with an existing key should be rejected as a conflict. This gives clients a stable REST API even when their timeout handling is imperfect.&lt;/p&gt;

&lt;p&gt;For end-to-end coverage, combine database assertions with a mailbox check. A &lt;a href="https://dev.to/silviutech/playwright-checks-for-facebook-signup-emails-ba5"&gt;browser-level email check&lt;/a&gt; can validate the user-visible link, while a &lt;a href="https://dev.to/bitheirstake/privacy-reviews-need-email-sandboxes-1l7i"&gt;privacy review for email fixtures&lt;/a&gt; helps keep test addresses and message content inside the right boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational checklist
&lt;/h2&gt;

&lt;p&gt;Before shipping this worker, I verify the following:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The logical verification delivery has a unique database constraint.&lt;/li&gt;
&lt;li&gt;Claims use short transactions and skip rows already leased.&lt;/li&gt;
&lt;li&gt;Result updates reject expired or foreign leases.&lt;/li&gt;
&lt;li&gt;Retry counts, lease age, provider latency, and permanent failures are measurable.&lt;/li&gt;
&lt;li&gt;Expired tokens cannot be sent, even if an old job is retried.&lt;/li&gt;
&lt;li&gt;Shutdown extends or releases leases in a predictable way.&lt;/li&gt;
&lt;li&gt;Staging uses isolated mailboxes and redacted logs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The important design choice is not the queue product. It is separating verification state from delivery state, then giving every worker attempt a bounded ownership period. Once that contract is in the database, provider timeouts and process crashes become recoverable cases instead of mysterious authentication bugs.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>authentication</category>
    </item>
    <item>
      <title>Idempotent Email Verification APIs Need Expiring Keys</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Thu, 08 Oct 2026 02:23:11 +0000</pubDate>
      <link>https://dev.to/kevindev27/idempotent-email-verification-apis-need-expiring-keys-29e4</link>
      <guid>https://dev.to/kevindev27/idempotent-email-verification-apis-need-expiring-keys-29e4</guid>
      <description>&lt;p&gt;Email verification looks small until a mobile client retries a request, a worker delivers the same message twice, or a user clicks an old link. The important question is not only whether the token is valid. It is whether the whole operation is safe to repeat.&lt;/p&gt;

&lt;p&gt;In backend work, I treat verification as a state transition with an explicit database contract. A request should be replayable without creating several active tokens, sending an unbounded number of messages, or turning an already completed verification back into a pending state. The design is less flashy than adding another queue, but it prevents a lot of operational noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why verification retries need a database contract
&lt;/h2&gt;

&lt;p&gt;Consider a client calling &lt;code&gt;POST /v1/email-verifications&lt;/code&gt;. The request can time out after the server commits, so the client retries even though the original operation succeeded. If the endpoint generates a fresh token for every call, the last email may invalidate the previous links. If the endpoint has no limit, a button double-click can become a mail sending incident.&lt;/p&gt;

&lt;p&gt;An idempotency key gives the request a stable identity. The server stores that key with the user, the requested email, the resulting verification record, and an expiry time. A retry with the same key can return the original result. A different payload with the same key must be rejected, because silently changing the meaning of a key makes debugging much harder.&lt;/p&gt;

&lt;p&gt;This is the same kind of boundary I use when designing other API fixtures: the request, its side effects, and its response need one durable receipt. A &lt;a href="https://dev.to/silviutech/playwright-email-tests-catch-the-wrong-message-4d26"&gt;Playwright email assertion&lt;/a&gt; can verify the user-visible result, but it cannot replace the server-side uniqueness rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model the verification request as an idempotent command
&lt;/h2&gt;

&lt;p&gt;Keep the API model small: an email address and a client-supplied idempotency key. Return a request ID and generic status without revealing whether an account exists.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /v1/email-verifications
Idempotency-Key: 01JZ7KQ7Q8M6N4Y2X0P3R8T5VA
Content-Type: application/json

{"email":"person@example.com"}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The handler should follow a predictable order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Normalize the email for the product's documented policy.&lt;/li&gt;
&lt;li&gt;Validate the idempotency key format and length.&lt;/li&gt;
&lt;li&gt;Look up an existing key for the same user and endpoint.&lt;/li&gt;
&lt;li&gt;Return the saved response if the payload fingerprint matches.&lt;/li&gt;
&lt;li&gt;Otherwise, create the verification request and its token in one transaction.&lt;/li&gt;
&lt;li&gt;Commit before handing work to email delivery.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fingerprint matters. Comparing only the key means a buggy client could reuse it for another address and receive a misleading success response. It doesnt need the raw email; a keyed digest or scoped hash is enough for comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use PostgreSQL constraints and expiring keys
&lt;/h2&gt;

&lt;p&gt;Application checks are useful, but PostgreSQL should enforce the invariant during concurrent requests. A simplified schema might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;verification_requests&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;email_digest&lt;/span&gt; &lt;span class="n"&gt;bytea&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;token_digest&lt;/span&gt; &lt;span class="n"&gt;bytea&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'verified'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'expired'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;idempotency_key&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;request_fingerprint&lt;/span&gt; &lt;span class="n"&gt;bytea&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idempotency_key&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;The unique constraint turns a race into a recoverable conflict. Store only a token digest, not the usable token. On verification, require &lt;code&gt;status = 'pending'&lt;/code&gt; and &lt;code&gt;expires_at &amp;gt; now()&lt;/code&gt; in the update predicate. If zero rows are updated, return a generic expired-or-invalid response.&lt;/p&gt;

&lt;p&gt;Idempotency records also need retention. Expiring keys too quickly causes a late network retry to create a second request; retaining them forever bloats the table. The right window depends on client retry behaviour, but it should be longer than the maximum expected request timeout and queue delay. A scheduled cleanup job can remove old keys after the window, while the verification request itself keeps its audit state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep email delivery separate from API completion
&lt;/h2&gt;

&lt;p&gt;Do not send an email halfway through a transaction and then report success only after the database commit. A crash between those steps creates an email with no durable request, or a retry that sends a second message.&lt;/p&gt;

&lt;p&gt;The outbox pattern is a better boundary: write the verification row and an &lt;code&gt;email.send&lt;/code&gt; event in one transaction, then let a worker deliver the message. The worker should have its own delivery idempotency key. Email providers may still retry, so the message path needs a provider receipt and a bounded retry policy.&lt;/p&gt;

&lt;p&gt;That separation also makes rate limiting clearer. Limit new commands per user and address, then separately limit delivery attempts. A disposable email address or a &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;throwaway email generator&lt;/a&gt; can help isolated staging flows, but it should not bypass production abuse controls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing retries without inbox collisions
&lt;/h2&gt;

&lt;p&gt;Test the state machine, not only the happy path. The minimum suite should cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;two concurrent requests with one idempotency key;&lt;/li&gt;
&lt;li&gt;a retry with the same payload after a simulated timeout;&lt;/li&gt;
&lt;li&gt;the same key with a different email;&lt;/li&gt;
&lt;li&gt;a token used twice;&lt;/li&gt;
&lt;li&gt;an expired token;&lt;/li&gt;
&lt;li&gt;an outbox worker retry after a provider timeout;&lt;/li&gt;
&lt;li&gt;a verification request for a previously verified address.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For browser coverage, &lt;a href="https://dev.to/ryanlee91/how-to-test-passwordless-login-emails-in-javascript-without-inbox-chaos-56d0"&gt;passwordless login email tests&lt;/a&gt; are a useful companion pattern. Give each run its own mailbox and message cursor. Never assert that “the newest message” is the right one, because parallel runs make that assumption false pretty quick.&lt;/p&gt;

&lt;p&gt;Legacy test data sometimes contains search strings such as &lt;code&gt;tepm mail com&lt;/code&gt; or &lt;code&gt;tamp mail com&lt;/code&gt;. Keep those as plain fixture text, not as links or API rules. It is easy to confuse a typo keyword with a valid domain when a test is written in a hurry.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical checklist
&lt;/h2&gt;

&lt;p&gt;Before shipping the endpoint, check the following:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is the idempotency key scoped to the user and operation?&lt;/li&gt;
&lt;li&gt;Does PostgreSQL enforce uniqueness under concurrency?&lt;/li&gt;
&lt;li&gt;Is the request fingerprint checked on replay?&lt;/li&gt;
&lt;li&gt;Are tokens hashed, single-use, and time limited?&lt;/li&gt;
&lt;li&gt;Can the API commit without waiting for the provider?&lt;/li&gt;
&lt;li&gt;Does the outbox worker expose a durable delivery receipt?&lt;/li&gt;
&lt;li&gt;Are response messages generic enough for account privacy?&lt;/li&gt;
&lt;li&gt;Are old records cleaned without deleting useful audit history?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One small detail is often missed: a verified request should remain verified when a duplicate confirmation arrives. The handler must be monotonic; it shouldnt move the record backward just because the second click used stale state. With these constraints, retries become an expected part of the system rather than an exceptional path, and the email flow stays understandable when production gets busy.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>authentication</category>
    </item>
    <item>
      <title>A State Machine for Reliable Email Verification APIs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Sun, 04 Oct 2026 05:23:25 +0000</pubDate>
      <link>https://dev.to/kevindev27/a-state-machine-for-reliable-email-verification-apis-51a9</link>
      <guid>https://dev.to/kevindev27/a-state-machine-for-reliable-email-verification-apis-51a9</guid>
      <description>&lt;p&gt;Email verification is often implemented as two columns: an address and a &lt;code&gt;verified&lt;/code&gt; boolean. That is enough for a demo, but it becomes vague when a message is delayed, a user requests a second code, or a worker retries after a timeout.&lt;/p&gt;

&lt;p&gt;In backend systems I prefer treating verification as a small state machine. The state records what happened, while an attempt record explains when and why it happened. This makes a REST API easier to operate and keeps PostgreSQL as the source of truth instead of scattering decisions through Node.js handlers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a boolean verified flag is not enough
&lt;/h2&gt;

&lt;p&gt;With only &lt;code&gt;verified = false&lt;/code&gt;, the API cannot distinguish a new signup from an expired token, a blocked attempt, or a message that was sent but never consumed. Those cases need different responses and different metrics.&lt;/p&gt;

&lt;p&gt;There is also a race. A user can click an old link at the same time that a newer verification email is issued. If both requests only update the user row, the older token may win. The result looks valid in the database but does not match the latest user action.&lt;/p&gt;

&lt;p&gt;I use an immutable verification attempt for each issued token. The user account keeps the current status, but the attempt keeps its own creation time, expiry time, consumption time, and request identifier. This is a little more data, and a lot less guesswork.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model the verification lifecycle
&lt;/h2&gt;

&lt;p&gt;A practical lifecycle is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;created -&amp;gt; sent -&amp;gt; verified
            |         
            +-------&amp;gt; expired
            +-------&amp;gt; revoked
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The transitions should be explicit. &lt;code&gt;created&lt;/code&gt; means the API accepted the request, while &lt;code&gt;sent&lt;/code&gt; means the mail provider accepted the delivery request. Those are not the same event. A provider timeout must not silently become &lt;code&gt;verified&lt;/code&gt;, and a duplicate send should not create an untraceable second token.&lt;/p&gt;

&lt;p&gt;An attempt table can contain fields like these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="n"&gt;verification_status&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;ENUM&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sent'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'verified'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'expired'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'revoked'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;verification_attempts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;token_digest&lt;/span&gt; &lt;span class="n"&gt;bytea&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="n"&gt;verification_status&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;request_id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;verified_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&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;Store a digest of the token, not the raw token. The raw value belongs in the link sent to the user and should not be recoverable from a database backup. It is also worth indexing &lt;code&gt;(user_id, status, created_at DESC)&lt;/code&gt; for the lookup used by support and cleanup jobs.&lt;/p&gt;

&lt;p&gt;For email tests, an isolated &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;temporary email account generator&lt;/a&gt; can help inspect delivery without using a personal inbox. Keep that fixture separate from production identity data, and give it a short expiry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enforce transitions in PostgreSQL
&lt;/h2&gt;

&lt;p&gt;Do not rely on every application path remembering the same rules. A verification transaction should lock the selected attempt, check its status and expiry, then update it atomically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;verification_attempts&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'verified'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verified_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'sent'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this returns no row, the caller must treat the token as already used, expired, or unknown. That single result is safer than reading first and updating later. The user status update should happen in the same transaction, so a crash cannot leave the attempt verified while the account remains pending.&lt;/p&gt;

&lt;p&gt;When several workers process delivery callbacks, use a request ID with a unique constraint. The notes on &lt;a href="https://dev.to/pong1965/concurrency-keys-for-email-api-checks-5fd2"&gt;concurrency keys for email checks&lt;/a&gt; are relevant here: concurrency control needs an owner and a durable key, not only a timing assumption.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the REST API retry-safe
&lt;/h2&gt;

&lt;p&gt;The Node.js endpoint should return a stable outcome for a repeated request. For example, &lt;code&gt;POST /v1/email-verifications&lt;/code&gt; can accept an &lt;code&gt;Idempotency-Key&lt;/code&gt;, store the request result, and return the original attempt when the same key is retried. The handler should not send a second message just because the client did not receive the first HTTP response.&lt;/p&gt;

&lt;p&gt;The confirmation endpoint can use these categories:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;204&lt;/code&gt; when the token is accepted.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;409&lt;/code&gt; when the attempt was already consumed or revoked.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;410&lt;/code&gt; when the token is expired.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;404&lt;/code&gt; when the token is unknown, without revealing whether an account exists.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The exact contract is a product decision, but it should be documented and tested. My earlier approach to &lt;a href="https://dev.to/kevindev27/idempotent-signup-emails-in-nodejs-apis-2k4m"&gt;idempotent signup email handling&lt;/a&gt; follows the same boundary: retries should replay a known result, not create side effects again.&lt;/p&gt;

&lt;p&gt;Some old test fixtures still contain the phrase &lt;code&gt;temp org mail&lt;/code&gt;. Keep that as plain text if it is needed to identify legacy data; it should never be a URL, a token, or a backlink anchor. Small naming mistakes in fixtures are easy to chase for hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  A production checklist
&lt;/h2&gt;

&lt;p&gt;Before shipping, check that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every token has an expiry and a digest, not a plaintext value.&lt;/li&gt;
&lt;li&gt;State transitions are guarded by the current status and performed transactionally.&lt;/li&gt;
&lt;li&gt;Send requests have an idempotency key and a durable request result.&lt;/li&gt;
&lt;li&gt;Repeated confirmation requests have documented, stable responses.&lt;/li&gt;
&lt;li&gt;Cleanup marks stale attempts as &lt;code&gt;expired&lt;/code&gt; and records counts for monitoring.&lt;/li&gt;
&lt;li&gt;Logs contain request IDs and attempt IDs, but not email tokens.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This design is not a large workflow engine. It is a narrow state machine around a security-sensitive action. That boundary gives the API clear behavior under retries, delayed mail, and concurrent clicks, which is what a boolean flag cannot provide.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>authentication</category>
    </item>
  </channel>
</rss>
