<?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>PostgreSQL Queues for Email Risk Reviews</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Wed, 02 Sep 2026 23:24:19 +0000</pubDate>
      <link>https://dev.to/kevindev27/postgresql-queues-for-email-risk-reviews-1fod</link>
      <guid>https://dev.to/kevindev27/postgresql-queues-for-email-risk-reviews-1fod</guid>
      <description>&lt;p&gt;In one signup system I maintained, the hardest part of email risk checks was not the classifier. It was the handoff between the API, the reviewer job, and the audit trail. We had valid reasons to flag domains, aliases, and patterns like &lt;code&gt;facebook temp email&lt;/code&gt;, but the operational flow got noisy fast when several workers looked at the same review item at once.&lt;/p&gt;

&lt;p&gt;That noise usually shows up in boring ways: duplicate decisions, retries that hide the first failure, and support notes that do not match what the &lt;code&gt;Authentication&lt;/code&gt; service actually decided. It sounds small, but it creates a very real maintenance tax. Backend teams dont lose time on the final SQL statement, they lose time on unclear ownership.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why email risk review queues get noisy
&lt;/h2&gt;

&lt;p&gt;The common setup is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the signup API writes a pending review row&lt;/li&gt;
&lt;li&gt;a worker scans for pending rows every few seconds&lt;/li&gt;
&lt;li&gt;another worker retries stale work&lt;/li&gt;
&lt;li&gt;dashboards read counts without enough state detail&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This works for a while, then gets weird. A domain rule changes, a reviewer deploy lands mid-run, and suddenly nobody is sure whether a decision was skipped, retried, or overwritten. If your team already thinks about &lt;a href="https://dev.to/bitheirstake/signup-email-risk-checks-need-data-budgets-347m-temp-slug-8170052?preview=af149e838cfcd5b7e49c0811d4e06da3fdeb092e2af256a0b830c9ea989e8d6d9286fc8326f1abe871a62c3a80a68907d6d6b63a9e0fd37c5639d262"&gt;data budgets for signup risk checks&lt;/a&gt;, the same idea applies here too: keep just enough state to explain decisions, not a giant blob of maybe-useful metadata.&lt;/p&gt;

&lt;p&gt;The tricky part is that engineers often treat the queue as a transport concern only. In practice, the queue is also your explanation layer. When a reviewer asks why a signup hit a manual check because of a domain that looked like temp gamil com, the system should answer from stored state, not from team memory. That is where &lt;code&gt;PostgreSQL&lt;/code&gt; helps more than people expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PostgreSQL model that aged well for us
&lt;/h2&gt;

&lt;p&gt;The pattern that held up best was a single review table plus an append-only decision log:&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_risk_reviews&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;email_address&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;risk_reason&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;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;'claimed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'approved'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'blocked'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;claimed_by&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;claimed_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;available_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;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="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;email_risk_review_events&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;review_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;email_risk_reviews&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;event_type&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;event_payload&lt;/span&gt; &lt;span class="n"&gt;jsonb&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;'{}'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jsonb&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;I like this layout because the current state stays cheap to query, while the event table tells the story after the fact. You do not need event sourcing religion here, just enough structure so incidents are reconstructable later. That distinction matters more than it seems, and teams forget it alot.&lt;/p&gt;

&lt;p&gt;For risk reasons, keep them narrow and reviewable. Examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;domain_on_watchlist&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mx_lookup_failed&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;alias_pattern_collision&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;manual_rule_match&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once those values drift into free-form text, the whole system starts to rot a bit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claim rows with clear ownership
&lt;/h2&gt;

&lt;p&gt;The most useful behavior change was moving from "worker reads pending rows" to "worker claims specific rows." That removes a surprising amount of accidental concurrency.&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_review&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_risk_reviews&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;email_risk_reviews&lt;/span&gt; &lt;span class="n"&gt;r&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;'claimed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;claimed_by&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;claimed_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="n"&gt;updated_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;from&lt;/span&gt; &lt;span class="n"&gt;next_review&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;r&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_review&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;r&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;&lt;code&gt;for update skip locked&lt;/code&gt; is not magic, but it is a very practical default for this kind of worker. One row gets one owner for one attempt. If the worker crashes, you can requeue the row with a timeout job. If the worker succeeds, you append an event and finalize the status. Clean enough, and pretty boring in a good way.&lt;/p&gt;

&lt;p&gt;That boringness is valuable. It also makes it easier to &lt;a href="https://dev.to/ryanlee91/react-mention-emails-without-double-sends-1e62"&gt;avoid duplicate notification sends&lt;/a&gt; in adjacent workflows, because your backend now has a stable record of which review actually finished before downstream mail or account actions begin.&lt;/p&gt;

&lt;p&gt;One caution: do not let the worker directly mutate user access in the same transaction that claims the review. Keep the claim step small. Finish the review, write the event, then let the API or a dedicated command handler apply the account change. Mixing those concerns tends to work untill it really doesnt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the authentication boundary should stay
&lt;/h2&gt;

&lt;p&gt;For me, the most maintainable boundary is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;signup API creates the review row&lt;/li&gt;
&lt;li&gt;reviewer service decides &lt;code&gt;approved&lt;/code&gt; or &lt;code&gt;blocked&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;authentication service consumes the final decision&lt;/li&gt;
&lt;li&gt;audit tools read both current state and events&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This keeps the queue from becoming an all-knowing god object. It is only responsible for review coordination and traceability. The final account policy still belongs to &lt;code&gt;Authentication&lt;/code&gt;, where rate limits, user messaging, and lock rules already live.&lt;/p&gt;

&lt;p&gt;That separation also keeps typo-heavy evidence from leaking into policy rules. If a support agent notes temp org mail in a ticket, that can exist as review context without becoming a permanent domain rule by accident. Small distinction, but it saves you from some silly cleanups later.&lt;/p&gt;

&lt;p&gt;If you need one more field, add a &lt;code&gt;decision_version&lt;/code&gt;. When policies change, you can tell which rule set produced which outcome. I skipped that for too long on one service, and the post-incident analysis was more annoying than it needed to be.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Should every flagged signup become a queued review?
&lt;/h3&gt;

&lt;p&gt;No. Reserve the queue for cases that are ambiguous or operationally sensitive. Hard blocks and clear allows should stay synchronous when possible, otherwise the API gets slower and the queue becomes a dumping ground.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need Kafka for this?
&lt;/h3&gt;

&lt;p&gt;Not always. If review volume is moderate and the team already runs &lt;code&gt;PostgreSQL&lt;/code&gt; well, a relational queue is often enough. Add more infra only when you can point to a real bottleneck, not because the pattern looks more modern.&lt;/p&gt;

&lt;h3&gt;
  
  
  What should I log for each decision?
&lt;/h3&gt;

&lt;p&gt;At minimum: review id, user id, risk reason, worker id, decision, and policy version. If you cannot answer "who claimed this row and why did it end blocked?" from logs plus tables, the design still needs work.&lt;/p&gt;

&lt;p&gt;A review queue like this will never make bad email heuristics good by itself. What it does is make backend behavior legible. For systems that have to explain risky signup decisions later, that legibility is most of the win.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>postgres</category>
      <category>authentication</category>
      <category>restapi</category>
    </item>
    <item>
      <title>REST Idempotency for Signup Email Jobs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Wed, 02 Sep 2026 17:23:38 +0000</pubDate>
      <link>https://dev.to/kevindev27/rest-idempotency-for-signup-email-jobs-241a</link>
      <guid>https://dev.to/kevindev27/rest-idempotency-for-signup-email-jobs-241a</guid>
      <description>&lt;h1&gt;
  
  
  REST Idempotency for Signup Email Jobs
&lt;/h1&gt;

&lt;p&gt;When a signup request times out, most clients retry before a human even notices. If your API creates a user row on the first attempt and enqueues a verification email on the second, you now have duplicate mail and noisy support threads. I have seen this bug show up in otherwise clean systems because the API path looked deterministic, but the side effects were not.&lt;/p&gt;

&lt;p&gt;The fix is not "retry less." The fix is making the signup email workflow idempotent end to end, from the HTTP contract to the worker that actually talks to your provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why signup emails duplicate so easily
&lt;/h2&gt;

&lt;p&gt;A typical failure path looks simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;POST /signup&lt;/code&gt; validates the payload.&lt;/li&gt;
&lt;li&gt;The app creates the user record.&lt;/li&gt;
&lt;li&gt;The app writes a message into a queue.&lt;/li&gt;
&lt;li&gt;The HTTP connection drops before the client gets &lt;code&gt;201 Created&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The client retries with the same intent.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If step 2 committed and step 3 partially succeeded, the second request can produce a second email job. In practice, these duplicates are common when mobile clients retry aggressively or when upstream gateways hide the first timeout. The app logic may still seem "correct", but the workflow isnt stable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API contract that makes retries safe
&lt;/h2&gt;

&lt;p&gt;For signup-triggered email, I prefer an explicit idempotency key on the request. The contract is boring on purpose:&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 /signup
Idempotency-Key: 0e5d9d48-5f74-4c1b-a87a-d9160b1a4d71
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then store one durable record keyed by:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;tenant or app scope&lt;/li&gt;
&lt;li&gt;normalized email&lt;/li&gt;
&lt;li&gt;idempotency key&lt;/li&gt;
&lt;li&gt;request hash&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important rule is that the first accepted request owns the outcome. Retries with the same key return the same result shape, including the same signup status and the same email job reference. Retries with the same key but a different body should fail loudly with &lt;code&gt;409 Conflict&lt;/code&gt;, because that is almost always a client bug.&lt;/p&gt;

&lt;p&gt;In Node.js, the write path can stay pretty small:&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;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;trx&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;signup&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;trx&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;`
    insert into signup_requests (scope_id, email, idem_key, request_hash, status)
    values ($1, $2, $3, $4, 'accepted')
    on conflict (scope_id, email, idem_key)
    do nothing
    returning 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;scopeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idemKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requestHash&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;signup&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;loadExistingResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;scopeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idemKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requestHash&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;user&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;upsertUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;enqueueVerificationOutbox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signup&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="nx"&gt;user&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;created&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;signup_requests&lt;/code&gt; row becomes the source of truth for retries. It also gives you a clean audit trail for auth and support teams, which saves time later, trust me.&lt;/p&gt;

&lt;h2&gt;
  
  
  A queue model that avoids double sends
&lt;/h2&gt;

&lt;p&gt;The API boundary alone is not enough. Workers retry too, providers timeout too, and operators rerun stuck jobs at 2 AM. I usually combine three guards:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An outbox table written in the same transaction as the signup acceptance.&lt;/li&gt;
&lt;li&gt;A unique constraint on the logical email intent, such as &lt;code&gt;(template, user_id, signup_request_id)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A provider delivery key carried through the worker so downstream retries are also deduplicated.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This works better than trying to infer duplicates from send timestamps. Timestamps drift, jobs replay, and somebody will eventually reschedule a batch manualy.&lt;/p&gt;

&lt;p&gt;If you need separate resend behavior, model it as a new intent with a reason code. Do not overload the original signup intent. That is the same lesson behind &lt;a href="https://dev.to/kevindev27/postgresql-leases-for-resend-email-apis-82c"&gt;lease-based resend coordination&lt;/a&gt;: once operational retries mix with product retries, the state machine gets muddy fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where temporary inboxes still help
&lt;/h2&gt;

&lt;p&gt;I would not use a temporary inbox to decide whether a signup request is valid. That belongs in API validation and abuse controls. But temp inboxes are still useful in integration testing, staging review, and support reproduction.&lt;/p&gt;

&lt;p&gt;For example, when QA needs to &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;generate disposable email&lt;/a&gt; accounts during smoke tests, the important backend rule is that mailbox churn must not change your idempotency behavior. Whether the address came from a temp mail generator, a fake e mail com style test alias, or an internal seed account, the retry contract should behave the same. A temp mailid in a bug report should not require special code paths.&lt;/p&gt;

&lt;p&gt;For rollout verification, I also like &lt;a href="https://dev.to/jasonmills94/one-mailbox-per-eks-rollout-3poh"&gt;isolated inboxes during rollout checks&lt;/a&gt; because they make it easier to prove which deploy emitted which message.&lt;/p&gt;

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

&lt;p&gt;When I review this kind of endpoint, I ask these questions first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does the API require an idempotency key for every signup email intent?&lt;/li&gt;
&lt;li&gt;Is the request hash stored so mismatched retries fail deterministically?&lt;/li&gt;
&lt;li&gt;Is the outbox insert in the same database transaction as the user/signup write?&lt;/li&gt;
&lt;li&gt;Can a worker replay send the same logical email twice?&lt;/li&gt;
&lt;li&gt;Can support or ops tell the difference between "already sent" and "never queued"?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If any of those answers are fuzzy, the system is probably one network blip away from duplicate mail.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Should I deduplicate only by email address?
&lt;/h2&gt;

&lt;p&gt;No. A single email address can legitimately trigger different intents over time. Deduplicate the specific intent, not the person.&lt;/p&gt;

&lt;h2&gt;
  
  
  What if the provider does not support idempotency keys?
&lt;/h2&gt;

&lt;p&gt;Keep your own delivery intent identifier and persist provider response metadata. You can still make your worker retry-safe even if the provider API is a bit old-school.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is eventual consistency a problem here?
&lt;/h2&gt;

&lt;p&gt;Not if you make the accepted request record durable before returning success. The exact send time can be eventual; the intent record cannot.&lt;/p&gt;

&lt;p&gt;Reliable signup email flows are mostly about choosing one canonical intent record and refusing to let retries create a second one. The pattern is not flashy, but it keeps auth systems calmer, support inboxes quieter, and deploy nights a lot less weird.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>authentication</category>
      <category>node</category>
    </item>
    <item>
      <title>PostgreSQL Audits for Email Change APIs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Sat, 29 Aug 2026 17:23:54 +0000</pubDate>
      <link>https://dev.to/kevindev27/postgresql-audits-for-email-change-apis-3nl</link>
      <guid>https://dev.to/kevindev27/postgresql-audits-for-email-change-apis-3nl</guid>
      <description>&lt;p&gt;Changing an account email looks like a small feature, but it creates one of the messiest edges in &lt;code&gt;Authentication&lt;/code&gt;. You are replacing an identifier, sending a verification link, preserving recovery trails, and making sure retries do not leave support guessing what happened. I keep solving this with a PostgreSQL audit model instead of treating the mailbox as the source of truth.&lt;/p&gt;

&lt;p&gt;This matters even more when staging checks involve a &lt;code&gt;facebook temp email&lt;/code&gt; or a quick &lt;code&gt;tempmailso&lt;/code&gt; inbox to confirm delivery. Those inboxes are useful, but they should validate the last mile only. The system still needs a durable record of which address was requested, who approved it, and whether the token that reached the inbox still matched the latest request. Without that, the backend gets fuzzy fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why email change APIs are riskier than signup
&lt;/h2&gt;

&lt;p&gt;Signup flows are usualy append-only. Email changes are not. You already have a live account, an old verified address, and a new pending address that might never complete verification. If the client retries twice, or a user clicks an old link after requesting a new one, the API can drift into confusing states unless the model is explicit.&lt;/p&gt;

&lt;p&gt;The cases I watch most are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;repeated &lt;code&gt;PATCH /account/email&lt;/code&gt; calls during slow mobile networks&lt;/li&gt;
&lt;li&gt;stale verification links being opened after a newer request&lt;/li&gt;
&lt;li&gt;support needing to explain which request actually won&lt;/li&gt;
&lt;li&gt;staging notes that mention things like &lt;code&gt;temp gamil com&lt;/code&gt; or &lt;code&gt;tempail&lt;/code&gt;, which tells me the verification trail is too manual&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last point sounds small, but it often reveals weak observability. If engineers are searching inboxes before they can answer a database question, the design is probly upside down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PostgreSQL records I keep for every email change
&lt;/h2&gt;

&lt;p&gt;I prefer a dedicated table for change intents rather than overloading the user row with pending fields only:&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_change_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;old_email&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;new_email&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="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="n"&gt;requested_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;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;superseded_by&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;request_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="k"&gt;unique&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;request_key&lt;/code&gt; is the important guardrail. I generate it from the authenticated user id plus a client operation id. That gives me one durable handle for retries, similar in spirit to &lt;a href="https://dev.to/mrdapperx/keep-publish-retries-immutable-2pcd"&gt;immutable retry receipts&lt;/a&gt;. If the same request is replayed, PostgreSQL gives me the existing row instead of creating competing email-change attempts.&lt;/p&gt;

&lt;p&gt;I also keep the old email on the request row. That looks redundant until a support ticket lands three weeks later and somebody needs to confirm exactly what was replaced.&lt;/p&gt;

&lt;h2&gt;
  
  
  A REST API flow that survives retries and stale links
&lt;/h2&gt;

&lt;p&gt;The request path is simple on purpose:&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;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;trx&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="nf"&gt;findByRequestKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requestKey&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;req&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;insertEmailChangeRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&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="na"&gt;oldEmail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;currentEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;newEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;tokenHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;requestKey&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="nf"&gt;addMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;20&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="nf"&gt;enqueueVerificationEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&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;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;req&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;On verification, I do not update the user row from the token alone. I first load the request by token hash, confirm it is still &lt;code&gt;pending&lt;/code&gt;, confirm &lt;code&gt;superseded_by&lt;/code&gt; is null, and only then swap the email in the same transaction that marks the request &lt;code&gt;verified&lt;/code&gt;. If a newer request exists, the old link resolves to a clear conflict instead of silently mutating the wrong state.&lt;/p&gt;

&lt;p&gt;That is the part many teams skip. They validate expiry, but not freshness. For email changes, freshness matters more than elegance.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I test the flow without trusting inboxes alone
&lt;/h2&gt;

&lt;p&gt;My test checklist has two layers. First I inspect database truth:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;create the email change request through the public &lt;code&gt;REST API&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;confirm one &lt;code&gt;email_change_requests&lt;/code&gt; row exists for the request key&lt;/li&gt;
&lt;li&gt;assert older pending rows are marked superseded when a newer request is made&lt;/li&gt;
&lt;li&gt;verify the user row changes only after the newest token is consumed&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then I inspect delivery truth through a temporary inbox. A &lt;code&gt;facebook temp email&lt;/code&gt; can help when you need to validate content, timing, and whether the right person-facing copy shipped. That is also where &lt;code&gt;tempmailso&lt;/code&gt; can be useful in staging. But I do not let the inbox answer backend questions it cannot answer. For template rendering and delivery smoke coverage, I like pairing this with lightweight &lt;a href="https://dev.to/jasonmills94/docker-smoke-tests-for-aws-ses-template-changes-1f1l"&gt;email template smoke checks&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;When teams rely on inboxes alone, they miss two important facts: whether the request was already superseded, and whether a retry reused an existing operation or created a second one. PostgreSQL is better at both answers, and it does not forget what happend.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Why not store &lt;code&gt;pending_email&lt;/code&gt; directly on the user row?
&lt;/h2&gt;

&lt;p&gt;You can for very small apps, but it hides history. Once retries, support, and stale links show up, a request ledger is much easier to reason about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should the verification token be unique per request?
&lt;/h2&gt;

&lt;p&gt;Yes. One token per request, one request per request key. That keeps invalidation rules obvious and makes audits less messy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about rate limits?
&lt;/h2&gt;

&lt;p&gt;Apply them at the authenticated user boundary and at the destination email boundary. Otherwise attackers can grind through address changes or flood one mailbox with verification messages.&lt;/p&gt;

&lt;p&gt;For me, the win is not just security. It is operational clarity. A PostgreSQL request ledger gives the &lt;code&gt;Authentication&lt;/code&gt; flow a stable memory, keeps the &lt;code&gt;REST API&lt;/code&gt; honest under retries, and turns temporary inboxes into a useful check instead of the only evidence you have.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>postgres</category>
      <category>authentication</category>
      <category>restapi</category>
    </item>
    <item>
      <title>Canonicalize Auth Emails Before You Rate-Limit</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Tue, 25 Aug 2026 23:24:05 +0000</pubDate>
      <link>https://dev.to/kevindev27/canonicalize-auth-emails-before-you-rate-limit-5a2m</link>
      <guid>https://dev.to/kevindev27/canonicalize-auth-emails-before-you-rate-limit-5a2m</guid>
      <description>&lt;p&gt;I keep seeing Authentication systems rate-limit the wrong thing. The code looks reasonable at first: take the email string from the request, hash it, increment a counter, and decide whether the user can ask for another verification or reset email. The bug shows up later, when &lt;code&gt;Jane.Doe+trial@Example.com&lt;/code&gt; and &lt;code&gt;jane.doe@example.com&lt;/code&gt; produce different counters, different logs, and a very confusing support trail.&lt;/p&gt;

&lt;p&gt;That matters even more when your signup flow already deals with disposable temporary email patterns, temp mail so style test traffic, and a mix of user-entered aliases. If the API does not canonicalize before policy and rate-limit checks, abuse controls get oddly soft while legitimate users still hit rough edges. I have seen teams chase notes with phrases like &lt;code&gt;tamp mail com&lt;/code&gt; or &lt;code&gt;temp mailid&lt;/code&gt; in incident docs because nobody persisted the normalized form that the system actually reasoned about. It works, until it kind of doesnt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why raw email strings create auth bugs
&lt;/h2&gt;

&lt;p&gt;A raw email string is user input, not identity. Case, dots, plus-addressing, Unicode normalization, and domain aliases can all change how the same inbox is represented. Some providers ignore dots, some do not. Some teams strip plus tags globally, which is also wrong. The real backend job is to define a small canonicalization policy per provider class and persist the result that downstream services use.&lt;/p&gt;

&lt;p&gt;If you skip that step, four things drift:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;resend cooldowns become inconsistent&lt;/li&gt;
&lt;li&gt;abuse scoring gets split across aliases&lt;/li&gt;
&lt;li&gt;support cannot explain why one request was blocked&lt;/li&gt;
&lt;li&gt;test evidence becomes annoyingly fuzzy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the same reason I like keeping &lt;a href="https://dev.to/pong1965/git-diffs-make-email-checks-reproducible-1o32"&gt;reproducible email checks&lt;/a&gt; around a single normalized artifact. One input form, one decision record, one trail you can review later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Canonicalize before policy and rate limits
&lt;/h2&gt;

&lt;p&gt;My preference is simple: parse, normalize, classify, then write one receipt row before any async send begins. That receipt should include the original input, the canonical address key, the domain classification, and the policy version used to make the decision.&lt;/p&gt;

&lt;p&gt;For example:&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="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;EmailDecisionReceipt&lt;/span&gt; &lt;span class="o"&gt;=&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;rawEmail&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;canonicalKey&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;providerClass&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;generic&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gmail_like&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;enterprise&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;domainCategory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;standard&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;disposable&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;blocked&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;policyVersion&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="nl"&gt;resendWindowSeconds&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="nl"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;allow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;review&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deny&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The useful trick is that &lt;code&gt;canonicalKey&lt;/code&gt; should be the input to both policy lookup and resend throttling. Not the raw address. Not a half-normalized cache key in one service and a different hash in another. One canonical key. Boring, explicit, reviewable.&lt;/p&gt;

&lt;p&gt;For disposable temporary email checks, I usually classify the domain separately from the canonical local part. That keeps domain-level controls independent from user-specific resend logic. It also means you can tighten trial-abuse rules without rewriting the whole auth path, which is nice when product wants a fast change by Friday afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  A PostgreSQL receipt shape that stays reviewable
&lt;/h2&gt;

&lt;p&gt;I like a table like this because it keeps write-time evidence close to the send decision:&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;auth_email_receipts&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;request_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;raw_email&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;canonical_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;provider_class&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;domain_category&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;policy_version&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="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;decision&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;decision&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;'allow'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'review'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'deny'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;resend_window_seconds&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="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;auth_email_receipts_lookup_idx&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;auth_email_receipts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;canonical_key&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not meant to be your forever audit lake. It is a working receipt for backend behavior. The benefit is very practical: when retries happen, or a queue delays a send, engineers can still answer which canonical identity the REST API used and which rule set fired. That answer is often missing in systems that only log the raw request body.&lt;/p&gt;

&lt;p&gt;If you also publish CI evidence, the same idea behind &lt;a href="https://dev.to/pong1965/github-actions-summaries-for-email-checks-3coj"&gt;short CI email summaries&lt;/a&gt; helps here too. Keep the receipt terse enough that another engineer can compare runs quickly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to return from the REST API
&lt;/h2&gt;

&lt;p&gt;The client does not need all of this detail. It needs stable outcomes. My usual response shape is small on purpose:&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;"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;"accepted"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"request_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;"d6f5c4b2-2b36-4b87-8d0d-112233445566"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cooldown_seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"decision_code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email_verification_scheduled"&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;Then the server-side receipt preserves the backend truth. If a user retries with a different casing or alias, you can still tie the cooldown to the same canonical identity when your policy says that is appropriate. For Authentication APIs, that reduces duplicate sends and weird branchy logic in workers. It also makes Postgres queries much saner, which is not glamorous but is honestly where half the reliability comes from.&lt;/p&gt;

&lt;p&gt;One caution: do not over-normalize. Gmail-like dot folding is not universal, and stripping plus tags for enterprise domains can create bad collisions. A small provider-class map is better than a fake universal rule. Overconfidence here causes more mess than slow rollout does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small checks that prevent painful regressions
&lt;/h2&gt;

&lt;p&gt;The most useful checks are not huge:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;test that raw variants map to one canonical key when your provider rules say they should&lt;/li&gt;
&lt;li&gt;test that different enterprise aliases do not collapse by mistake&lt;/li&gt;
&lt;li&gt;log the policy version next to the receipt&lt;/li&gt;
&lt;li&gt;keep retention short and documented&lt;/li&gt;
&lt;li&gt;make the resend query hit &lt;code&gt;canonical_key&lt;/code&gt;, not &lt;code&gt;raw_email&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I would also add one contract test that proves a disposable temporary email domain still gets classified correctly after a policy update. Teams often validate the deny path and forget the review path, which is where real-world edge cases live a bit more often.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Should I canonicalize in the API or in the database?
&lt;/h2&gt;

&lt;p&gt;Usually in the API, then persist the result. PostgreSQL can enforce constraints and help with lookup speed, but the normalization policy is application behavior and should be versioned there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do I need a separate receipt row for every resend attempt?
&lt;/h2&gt;

&lt;p&gt;Yes, if you care about debugging. Keep each decision small and timestamped. Trying to mutate one row into the whole story gets messy fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is this worth it for small apps?
&lt;/h2&gt;

&lt;p&gt;If you send verification or reset emails, yes. The table is tiny, the logic is boring, and the payoff arrives the first time support asks why two "same" addresses behaved differently.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>authentication</category>
      <category>postgres</category>
      <category>restapi</category>
    </item>
    <item>
      <title>Session Proof for Email Change APIs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Sun, 23 Aug 2026 23:24:33 +0000</pubDate>
      <link>https://dev.to/kevindev27/session-proof-for-email-change-apis-5f7a</link>
      <guid>https://dev.to/kevindev27/session-proof-for-email-change-apis-5f7a</guid>
      <description>&lt;p&gt;Email change APIs get safer when proof is tied to the session that asked for it, not just to a token floating around later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why email change APIs fail in quiet ways
&lt;/h2&gt;

&lt;p&gt;The risky part of an email change flow is rarely the happy path. It is the gap between "user asked to change address" and "backend accepted the new address as trusted". I keep seeing systems that issue a verification token, save a pending email, and call it done. That works until a stolen session, a replayed link, or a retried request lands at the wrong time.&lt;/p&gt;

&lt;p&gt;Three failures show up a lot:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The verification link is opened from a different auth state than the one that requested it.&lt;/li&gt;
&lt;li&gt;A second change request silently overwrites the first pending email.&lt;/li&gt;
&lt;li&gt;Support can see that a change happened, but cannot explain which session actually approved it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Those bugs are annoying because the API still looks clean from the outside. Internally, though, the trust boundary is mushy. For Authentication work, I want one narrow rule: the proof for changing an address must stay attached to the session context that initiated it, or at least to a re-auth event derived from that context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bind proof to the active session
&lt;/h2&gt;

&lt;p&gt;My preferred model is simple. When &lt;code&gt;POST /account/email-change&lt;/code&gt; is called, the API creates a pending change record with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;current user id&lt;/li&gt;
&lt;li&gt;current session id or re-auth event id&lt;/li&gt;
&lt;li&gt;old email&lt;/li&gt;
&lt;li&gt;requested new email&lt;/li&gt;
&lt;li&gt;hashed verification token&lt;/li&gt;
&lt;li&gt;expiry time&lt;/li&gt;
&lt;li&gt;state&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That extra session binding looks boring, but it removes a lot of guesswork later. If the link comes back with a valid token but the originating session was revoked, stepped up, or replaced by a more recent request, the API can reject the change cleanly. That is the kind of boring failure I like.&lt;/p&gt;

&lt;p&gt;In practice, I use a short re-auth window before starting the change. Password prompt, passkey assertion, or MFA step are all fine. The important bit is to persist the proof artifact you actually trust, not just the resulting token. Otherwise the token becomes a tiny passport with very little context, and thats where weird account-takeover stories begin.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Node.js flow that survives retries
&lt;/h2&gt;

&lt;p&gt;In Node.js, I treat the request flow as a state machine instead of a pair of loose handlers. Roughly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Require recent re-auth.&lt;/li&gt;
&lt;li&gt;Insert a &lt;code&gt;pending_email_change&lt;/code&gt; row.&lt;/li&gt;
&lt;li&gt;Invalidate older pending rows for the same user.&lt;/li&gt;
&lt;li&gt;Send the verification email with an idempotency key.&lt;/li&gt;
&lt;li&gt;On link open, verify token hash, session proof, expiry, and row state in one transaction.&lt;/li&gt;
&lt;li&gt;Swap the account email and mark the row &lt;code&gt;consumed&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A stripped down service layer might look like this:&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;startEmailChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&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;sessionProofId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nextEmail&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;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="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;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`update pending_email_change
       set state = 'superseded'
       where user_id = $1 and state = 'pending'`&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="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="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`insert into pending_email_change
       (user_id, session_proof_id, next_email, token_hash, state, expires_at)
       values ($1, $2, $3, $4, 'pending', now() + interval '20 minutes')`&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;sessionProofId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nextEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;issueTokenHash&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point is not the exact SQL. The point is that retries should converge on one active intent. If a mobile client resends because the network is flaky, or the browser double-submits, the flow should stay a bit boring. That same idea also helps when you want &lt;a href="https://dev.to/ryanlee91/react-forms-need-one-email-source-of-truth-4g2o"&gt;one email source of truth&lt;/a&gt; across frontend and backend validation.&lt;/p&gt;

&lt;p&gt;On the callback side, I avoid a "token matches, therefore approve" shortcut. I check whether the stored session proof is still valid for the account and whether the pending row is still the latest one. This is one place where engineers sometimes wave away risk because the token was emailed, but email ownership alone is not always enough for sensitive profile changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to persist for operators
&lt;/h2&gt;

&lt;p&gt;If the flow causes an incident, operators need more than a boolean success flag. I normally persist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;requested_at&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;verified_at&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;session_proof_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;superseded_by&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;failure_reason&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;request_ip_hash&lt;/code&gt; or trusted device marker&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That gives support and security teams a usable timeline without turning the system into a surveillance mess. It also makes it easier to answer questions like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did the same user request two different addresses?&lt;/li&gt;
&lt;li&gt;Was the earlier request still pending when the second one started?&lt;/li&gt;
&lt;li&gt;Did verification happen after the re-auth window expired?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also log a stable reason code for each rejection. "expired", "superseded", "stale_session_proof", and "already_consumed" are much better than a fuzzy "invalid token" bucket. Small detail, big payoff later.&lt;/p&gt;

&lt;p&gt;For some teams, statistics help win the argument. Microsoft has repeatedly documented that MFA and stronger sign-in verification drastically reduce account compromise risk, which is why binding sensitive account changes to recent proof is not just security theater (&lt;a href="https://www.microsoft.com/en-us/security/business/security-101/what-is-multi-factor-authentication-mfa" rel="noopener noreferrer"&gt;source&lt;/a&gt;). Numbers like that should guide the control, but the daily work is still about clean backend state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing without fooling yourself
&lt;/h2&gt;

&lt;p&gt;I do not like tests that only assert "some email arrived". For this flow, the useful cases are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Two rapid change requests leave only one pending row.&lt;/li&gt;
&lt;li&gt;A verified token from the first request fails after the second request supersedes it.&lt;/li&gt;
&lt;li&gt;A token with the right hash still fails if the bound session proof is stale.&lt;/li&gt;
&lt;li&gt;The audit trail explains every rejection path.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Manual tests with a disposable inbox can still be handy, especially when QA wants to inspect the full email body. I just keep that outside the trust model. If somebody writes tem email in a test plan, fine, but the backend should never depend on inbox tooling to decide whether an account change is legit.&lt;/p&gt;

&lt;p&gt;This is also where &lt;a href="https://dev.to/silviutech/cypress-email-retries-without-false-passes-2jb8"&gt;email retry boundaries in tests&lt;/a&gt; matter. If the test runner keeps polling forever, you can miss the fact that your API produced two sends or approved the wrong request. Tight retry windows catch more real bugs, even if they feel a little less comfy.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Should the verification link only work in the same browser?
&lt;/h2&gt;

&lt;p&gt;Not always. For many products that would be too strict and kind of annoying. What should stay bound is the proof record, not necessarily the exact browser tab. If you allow cross-device completion, require the callback to reference a recent re-auth artifact that is still valid for that account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do I need this for low-risk products?
&lt;/h2&gt;

&lt;p&gt;If users can change the email that controls password resets, invoices, or admin notices, yes probably. It is a compact control with pretty good leverage, and it ages well as the app grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  What if the user requested the change but lost the session?
&lt;/h2&gt;

&lt;p&gt;Let them restart the flow. That is cleaner than trying to rescue a half-trusted request. Security UX is better when the recovery path is obvious, even when it adds one more click or two.&lt;/p&gt;

&lt;p&gt;Session-bound proof turns email change APIs from "token says yes" into "system can explain why yes". For backend teams, that difference is not flashy, but it saves a lot of messy debugging later and makes the whole flow feel more honest.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>authentication</category>
      <category>node</category>
      <category>restapi</category>
    </item>
    <item>
      <title>PostgreSQL Rules for Burner Email Signups</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Sat, 22 Aug 2026 02:24:10 +0000</pubDate>
      <link>https://dev.to/kevindev27/postgresql-rules-for-burner-email-signups-2871</link>
      <guid>https://dev.to/kevindev27/postgresql-rules-for-burner-email-signups-2871</guid>
      <description>&lt;p&gt;Most teams start with a simple email regex and call the signup flow done. That works until abuse, fake trials, and support noise start stacking up. When a burner email address slips through, the real issue is usually not detection alone. It is that the policy lives in three places at once: app code, a vendor dashboard, and somebody's half-remembered runbook.&lt;/p&gt;

&lt;p&gt;I prefer to make signup email policy a backend artifact first. Put the rules in PostgreSQL, version them, and let the API return a small set of reviewable outcomes. It sounds a bit boring, but boring systems age better. In auth flows, boring is good even when product wants things fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why signup rules drift over time
&lt;/h2&gt;

&lt;p&gt;Disposable-domain checks often begin as a list in source control. A few weeks later, support asks for exceptions. Marketing wants softer handling for trials. Security wants hard blocks for domains used in credential stuffing. Then another service adds its own copy because it needs a quick decision in middleware. Now the same rule is enforced four differnt ways, and nobody fully trusts the result.&lt;/p&gt;

&lt;p&gt;That drift gets worse when the rule engine is hidden behind vague booleans like &lt;code&gt;is_disposable = true&lt;/code&gt;. In practice, teams need more nuance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;block known abuse domains&lt;/li&gt;
&lt;li&gt;allow but flag low-confidence cases&lt;/li&gt;
&lt;li&gt;bypass checks for invited enterprise tenants&lt;/li&gt;
&lt;li&gt;expire temporary overrides automatically&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I have seen incidents where engineers searched notes for odd phrases like &lt;code&gt;tamp mail com&lt;/code&gt; or &lt;code&gt;temp gamil com&lt;/code&gt; because they were trying to reconstruct how a previous exception had been tested. The weird phrasing is not the problem, the missing system record is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model the policy in PostgreSQL first
&lt;/h2&gt;

&lt;p&gt;My default design is a small rule table plus a decision log. The table tells the API what to do. The log tells humans why the API did it. That second part matters more than teams expect.&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;signup_email_policy&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="k"&gt;domain&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;action&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;action&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;'allow'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'flag'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'block'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;reason&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;source&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;confidence&lt;/span&gt; &lt;span class="nb"&gt;smallint&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;confidence&lt;/span&gt; &lt;span class="k"&gt;between&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;starts_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;ends_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;policy_version&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="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="k"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'00000000-0000-0000-0000-000000000000'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;policy_version&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;signup_email_policy_active_idx&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;signup_email_policy&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;starts_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ends_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are three choices here that save pain later.&lt;/p&gt;

&lt;p&gt;First, keep &lt;code&gt;action&lt;/code&gt; explicit. Teams argue less about &lt;code&gt;allow&lt;/code&gt;, &lt;code&gt;flag&lt;/code&gt;, and &lt;code&gt;block&lt;/code&gt; than they do about overloaded booleans. Second, store &lt;code&gt;policy_version&lt;/code&gt; so incident reviews can answer which ruleset made the decision. Third, allow tenant-scoped exceptions without forking the whole policy model.&lt;/p&gt;

&lt;p&gt;For the read path, I usually normalize the domain, query active rules, then choose the highest-confidence match. PostgreSQL is good at this if you keep the query plain:&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;select&lt;/span&gt; &lt;span class="k"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policy_version&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;signup_email_policy&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="k"&gt;domain&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;starts_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;ends_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;or&lt;/span&gt; &lt;span class="n"&gt;ends_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="k"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&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;tenant_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;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt; &lt;span class="n"&gt;nulls&lt;/span&gt; &lt;span class="k"&gt;last&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policy_version&lt;/span&gt; &lt;span class="k"&gt;desc&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That does not need to be clever. It needs to be inspectable at 2 PM and at 2 AM. If your signup service also runs test inbox scenarios, the same discipline used in &lt;a href="https://dev.to/silviutech/how-to-stop-playwright-email-tests-from-flaking-across-parallel-workers-ok8"&gt;stable email test isolation across workers&lt;/a&gt; is useful here too: isolate one run, one decision, one audit trail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the API response boring and consistent
&lt;/h2&gt;

&lt;p&gt;The API should not leak every internal signal to the client. Return a stable contract, and keep the richer reasoning in server logs or an admin view. A shape like this is enough for most Authentication workflows:&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;"decision"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"flag"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"signup_email_review_required"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"policy_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"retryable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&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;What I try to avoid is returning different status codes for every sub-case. If some burner domains get &lt;code&gt;422&lt;/code&gt;, others get &lt;code&gt;409&lt;/code&gt;, and others get &lt;code&gt;202&lt;/code&gt;, client logic becomes messy for no real gain. In many systems, two patterns are enough:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;201&lt;/code&gt; when signup may proceed&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;202&lt;/code&gt; or &lt;code&gt;422&lt;/code&gt; when the request needs review or is blocked, depending on your product contract&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point is consistency. Client teams should not need a secret decoder ring. This part is easy to under-value, but it keeps mobile, web, and partner API behavior from drifting apart again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add reviewable signals instead of hidden magic
&lt;/h2&gt;

&lt;p&gt;A lot of teams bolt on a third-party reputation feed and stop there. That can help, but I would not let an opaque score become the only source of truth. Store the evaluated domain, matched action, policy version, and request metadata in a decision log with retention that matches your privacy policy.&lt;/p&gt;

&lt;p&gt;This is also where privacy review gets easier. If you can show what was stored, why it was stored, and when it expires, review conversations stay shorter and much less fuzzy. The same habit behind &lt;a href="https://dev.to/bitheirstake/privacy-reviews-need-inbox-deletion-proof-547k"&gt;privacy review evidence for inbox deletion&lt;/a&gt; applies to signup policy logs: write down just enough evidence that another engineer can verify the control later.&lt;/p&gt;

&lt;p&gt;My checklist is pretty short, and thats on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;rules live in PostgreSQL, not scattered constants&lt;/li&gt;
&lt;li&gt;policy changes are versioned&lt;/li&gt;
&lt;li&gt;API returns a small and durable decision set&lt;/li&gt;
&lt;li&gt;exceptions expire unless someone renews them&lt;/li&gt;
&lt;li&gt;decision logs are queryable by domain and policy version&lt;/li&gt;
&lt;li&gt;product and support can tell the difference between &lt;code&gt;flag&lt;/code&gt; and &lt;code&gt;block&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you already have rate limiting, device risk, or invite-only onboarding, this model fits beside them without much drama. The burner email address check becomes one signal among several, instead of a giant if-statement that keeps growing sideways.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Should we block every disposable domain?
&lt;/h3&gt;

&lt;p&gt;Usually no. Some products genuinely need strict blocking, but many do better with a &lt;code&gt;flag&lt;/code&gt; path first. It catches risky signups without punishing legitimate evaluation flows too early.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should the domain list live only in Redis?
&lt;/h3&gt;

&lt;p&gt;I would not do that for the source of truth. Cache hot decisions in Redis if needed, sure, but keep PostgreSQL as the auditable record. Otherwise policy debugging gets annoyingly fuzzy.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the most common mistake?
&lt;/h3&gt;

&lt;p&gt;Treating this as only a detection problem. It is also a change-management problem. If you cannot explain which rule fired and when it was added, the system may work, but it wont stay maintainable for long.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>postgres</category>
      <category>authentication</category>
      <category>restapi</category>
    </item>
    <item>
      <title>Node.js Audit Trails for Email Change Flows</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Fri, 21 Aug 2026 05:24:45 +0000</pubDate>
      <link>https://dev.to/kevindev27/nodejs-audit-trails-for-email-change-flows-16al</link>
      <guid>https://dev.to/kevindev27/nodejs-audit-trails-for-email-change-flows-16al</guid>
      <description>&lt;p&gt;Changing a user's email address looks simple until support asks who approved it, which token was active, and whether the old address was warned before the new one went live. In a Node.js backend, that flow gets much easier to maintain when the audit trail is designed first instead of added later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why email change flows become hard to explain
&lt;/h2&gt;

&lt;p&gt;Most teams start with one table update and one verification email. Then edge cases pile up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The user requests a change twice from two devices.&lt;/li&gt;
&lt;li&gt;The old address receives a warning after the new address is already verified.&lt;/li&gt;
&lt;li&gt;A retry worker sends another token with slightly different expiry data.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;At that point the API may still "work", but it becomes hard to explain. For Authentication work, explainability matters a lot. If you cannot reconstruct what happened from database facts, your incident review gets fuzzy real fast.&lt;/p&gt;

&lt;p&gt;I now treat email change as a bounded workflow with explicit state. That sounds heavier than a plain &lt;code&gt;users.email = ?&lt;/code&gt; update, but in practice it removes a ton of support pain and weird rollback logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model the request as a state machine
&lt;/h2&gt;

&lt;p&gt;I like storing email change requests separately from the user row:&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_change_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;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;old_email&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;new_email&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;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;'verified_new'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'confirmed_old'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'applied'&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;'cancelled'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;verify_new_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="n"&gt;confirm_old_token_hash&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;requested_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;applied_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;That table gives the flow a home. More importantly, it stops the &lt;code&gt;users&lt;/code&gt; table from carrying half-finished identity changes.&lt;/p&gt;

&lt;p&gt;My usual rules are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;only one active request per user&lt;/li&gt;
&lt;li&gt;verifying the new address does not immediately swap login identity&lt;/li&gt;
&lt;li&gt;applying the change requires a final state transition you can audit later&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the state machine is explicit, retries stay boring. A second request can reuse the active record or cancel it deliberately. A stale token can fail cleanly. An operator can inspect one row and know what is still pending, which is pretty nice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the audit trail append-only
&lt;/h2&gt;

&lt;p&gt;The request row tracks current truth. The audit log should track how truth changed over time.&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_change_events&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;request_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;email_change_requests&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;event_type&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;actor_user_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ip_address&lt;/span&gt; &lt;span class="n"&gt;inet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_agent&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;metadata&lt;/span&gt; &lt;span class="n"&gt;jsonb&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;'{}'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jsonb&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;Every meaningful step writes an event: request created, new email verified, old email warned, change applied, request expired. I do not mutate old events. I append new ones. That keeps the chain readable, and it avoids the classic "last writer wins" mess where you lose the context that made the current row valid.&lt;/p&gt;

&lt;p&gt;This also pairs well with privacy reviews for staging inboxes. The same discipline that makes production events auditable makes test environments less chaotic too. If your notes mention tamp mail com or temp gamil com during manual QA, the important bit is that the backend timeline still reads clearly.&lt;/p&gt;

&lt;h2&gt;
  
  
  A compact Node.js implementation
&lt;/h2&gt;

&lt;p&gt;The route handler should create intent, not finish identity mutation on the hot path.&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;requestEmailChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&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;nextEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&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;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;trx&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;active&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;findActiveEmailChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;active&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="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="mi"&gt;202&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;active&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;request&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;insertEmailChangeRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&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="na"&gt;oldEmail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;newEmail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nextEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;verifyNewTokenHash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verifyTokenHash&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;context&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="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;insertEmailChangeEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;,&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;request&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;eventType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;requested&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;actorUserId&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;ipAddress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;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="na"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a separate verification handler can move &lt;code&gt;pending -&amp;gt; verified_new&lt;/code&gt;, and another command can move &lt;code&gt;verified_new -&amp;gt; applied&lt;/code&gt; after your policy checks pass. Keeping those transitions small matters more than clever abstractions, honestly.&lt;/p&gt;

&lt;p&gt;One thing I learned the hard way is to avoid rewriting the same token row during retries. It feels efficient, but it makes support logs harder to trust. Append an event, maybe extend expiry under a clear rule, and keep the request identity stable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where disposable inboxes actually help
&lt;/h2&gt;

&lt;p&gt;A disposable email account is useful for testing the contract around the flow, not for replacing the contract. I use it to confirm that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the old email warning is sent once&lt;/li&gt;
&lt;li&gt;the new email verification link maps to the right request&lt;/li&gt;
&lt;li&gt;expired requests stop producing fresh side effects&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For manual checks, the &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;best throwaway email&lt;/a&gt; setup is the one that lets you isolate one request from another without sharing a team inbox. That is useful, but it should remain a small test helper beside your database assertions, not the main source of truth.&lt;/p&gt;

&lt;p&gt;I also like pairing this with &lt;a href="https://dev.to/ryanlee91/ship-react-onboarding-without-email-drift-4acg"&gt;stable onboarding email checks&lt;/a&gt; on the frontend side and &lt;a href="https://dev.to/bitheirstake/privacy-reviews-for-email-testing-in-staging-479o"&gt;privacy reviews for staging inboxes&lt;/a&gt; when teams are deciding what should reach test mailboxes in the first place.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Should the old email always confirm the change?
&lt;/h2&gt;

&lt;p&gt;Not always. For low-risk products, warning-only may be enough. For high-risk accounts, old-email confirmation or a stronger step-up check is often worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where should expiry live?
&lt;/h2&gt;

&lt;p&gt;In the request record, not just inside the token. That makes cleanup jobs and operator queries much more obvious.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is this too much for a small app?
&lt;/h2&gt;

&lt;p&gt;Usually no. The schema is small, the state transitions are readable, and the support story gets way better. Even small apps hit enough concurrency and account-recovery weirdness that a clean audit trail pays back quick.&lt;/p&gt;

</description>
      <category>node</category>
      <category>backend</category>
      <category>authentication</category>
      <category>restapi</category>
    </item>
    <item>
      <title>PostgreSQL Leases for Resend Email APIs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Thu, 20 Aug 2026 17:24:17 +0000</pubDate>
      <link>https://dev.to/kevindev27/postgresql-leases-for-resend-email-apis-82c</link>
      <guid>https://dev.to/kevindev27/postgresql-leases-for-resend-email-apis-82c</guid>
      <description>&lt;p&gt;Resend-email endpoints break when the API, worker, and database disagree about who owns the next send. PostgreSQL leases make that ownership visible and pretty durable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why resend endpoints drift out of sync
&lt;/h2&gt;

&lt;p&gt;The bug usually starts with a reasonable shortcut. A &lt;code&gt;POST /auth/resend-verification&lt;/code&gt; route checks whether the user is pending, enqueues a job, and returns &lt;code&gt;202&lt;/code&gt;. Later, support sees duplicate messages, stale tokens, or a resend that claims success while nothing was actually deliverd.&lt;/p&gt;

&lt;p&gt;What changed? Normally one of these:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The worker timed out after the provider accepted the request.&lt;/li&gt;
&lt;li&gt;The client retried on a slow network.&lt;/li&gt;
&lt;li&gt;Another node handled the same resend before the first write fully settled.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When send ownership lives only in process memory, you cannot answer a basic backend question: "which attempt currently owns delivery?" That is why I now model resend work as a leased database record, not a loose side effect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lease the send in PostgreSQL, not in app memory
&lt;/h2&gt;

&lt;p&gt;A lease is just a short-lived claim on the next delivery attempt. I store resend work in a table where one row represents one email intent. The worker must atomically move the row from &lt;code&gt;pending&lt;/code&gt; to &lt;code&gt;leased&lt;/code&gt; before it talks to the provider.&lt;/p&gt;

&lt;p&gt;That sounds tiny, but it fixes a bunch of messy edge cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;duplicate sends become far less likely&lt;/li&gt;
&lt;li&gt;retries can inspect ownership instead of guessing&lt;/li&gt;
&lt;li&gt;operators can see stuck work with one query&lt;/li&gt;
&lt;li&gt;API responses stop pretending the mail was already sent&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In PostgreSQL, &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; is the part that keeps concurrent workers from grabbing the same pending row. It is not magic, but it is very dependable when the rest of the flow stays simple.&lt;/p&gt;

&lt;h2&gt;
  
  
  A schema that keeps retries boring
&lt;/h2&gt;

&lt;p&gt;I like a table shaped roughly 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_delivery_intents&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="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;'leased'&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;'failed'&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="n"&gt;lease_expires_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;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="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;provider_message_id&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;failure_code&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;sent_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;Then the worker claims work 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;with&lt;/span&gt; &lt;span class="n"&gt;next_job&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_delivery_intents&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;purpose&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'signup_verification'&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;'pending'&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;asc&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;email_delivery_intents&lt;/span&gt; &lt;span class="n"&gt;i&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_expires_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="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;from&lt;/span&gt; &lt;span class="n"&gt;next_job&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;i&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_job&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;i&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;i&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;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key is not the exact SQL. The key is making ownership first-class. If the provider call later fails, I can decide whether to retry the same row, mark it failed, or let the lease expire and recover it. That decision becomes data-driven, which is nice because data ages better than hand-wavy queue logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  REST API behavior that stays honest
&lt;/h2&gt;

&lt;p&gt;For the API, I avoid saying "email sent" unless a send has actually been recorded. Most resend endpoints should return something closer to "accepted, work is now owned by delivery intent X".&lt;/p&gt;

&lt;p&gt;My usual rules are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reuse an existing active intent for a short replay window.&lt;/li&gt;
&lt;li&gt;Create a new pending intent only when the prior one is consumed or failed.&lt;/li&gt;
&lt;li&gt;Never mint a fresh token just because a client got impatent and clicked again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here is a compact Node example:&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;requestResend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;current&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;findActiveIntent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;leased&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;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="na"&gt;intentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;current&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;created&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;createPendingIntent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&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="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="mi"&gt;202&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;intentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;created&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pairs well with an &lt;a href="https://dev.to/kevindev27/idempotent-verification-emails-in-node-apis-4i4c"&gt;idempotent verification email flow&lt;/a&gt;. I also like keeping a small runbook for reclaiming expired leases, the same way &lt;a href="https://dev.to/mrdapperx/cron-writers-need-a-frozen-plan-5154"&gt;frozen plans for automation runs&lt;/a&gt; keep cron systems easier to reason about.&lt;/p&gt;

&lt;p&gt;One subtle benefit: incident review gets much cleaner. You can ask which resend was pending, which worker leased it, and whether the lease expired before the provider callback arrived. Those are useful backend facts, not vibes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where disposable inboxes fit
&lt;/h2&gt;

&lt;p&gt;Disposable inboxes are helpful, but they should verify the contract rather than replace it. When I test resend flows locally, I want proof that one lease produced one effective message, not just proof that some inbox eventually got mail.&lt;/p&gt;

&lt;p&gt;A simple test loop looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;create a fresh user&lt;/li&gt;
&lt;li&gt;call resend twice under the same auth context&lt;/li&gt;
&lt;li&gt;assert only one row is &lt;code&gt;leased&lt;/code&gt; or &lt;code&gt;sent&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;verify one inbox receives one valid link&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For that last step, a throwaway inbox from &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;tempmailso&lt;/a&gt; can be useful during manual checks. I still keep it as a small part of the story, though. If somebody scribbles tamp mail com in a test note, your resend logic should still be deterministic and auditable.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  How long should the lease last?
&lt;/h2&gt;

&lt;p&gt;Long enough for one provider attempt plus a small buffer. In most apps, 60 to 180 seconds is enough. If you make it too long, recovery gets slow. If you make it too short, healthy sends may get reclaimed early, which is not ideal tbh.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should I delete failed intents?
&lt;/h2&gt;

&lt;p&gt;Usually no. Keep them for reconciliation and support analysis, then archive later. Failed rows are often the fastest way to explain weird resend behavior from last night.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is this overkill for a small app?
&lt;/h2&gt;

&lt;p&gt;Not really. The schema is small, the API rules are clear, and the operator experience is much better. Even modest auth systems get weird concurrency at the worst possible moment, so a boring lease model pays for itself pretty quick.&lt;/p&gt;

&lt;p&gt;If your resend endpoint keeps acting random under load, do less in memory and more in PostgreSQL. Once delivery ownership is visible, the whole system gets calmer, and honestly a bit easier to trust.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>postgres</category>
      <category>authentication</category>
      <category>restapi</category>
    </item>
    <item>
      <title>Version Auth Emails With Attempt Receipts</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Thu, 13 Aug 2026 14:24:30 +0000</pubDate>
      <link>https://dev.to/kevindev27/version-auth-emails-with-attempt-receipts-154g</link>
      <guid>https://dev.to/kevindev27/version-auth-emails-with-attempt-receipts-154g</guid>
      <description>&lt;p&gt;In most auth systems, email delivery looks simpler than it really is. A signup or password reset request enters the &lt;code&gt;REST API&lt;/code&gt;, one row changes state, and a worker sends the message later. Then a retry happens, a cooldown rule changes, or the user submits the form twice from two tabs. Suddenly the team is arguing about whether the second email was expected or whether the first receipt is still valid.&lt;/p&gt;

&lt;p&gt;What has worked better for me is treating every auth email decision as an immutable attempt with its own receipt. Instead of one mutable &lt;code&gt;email_status&lt;/code&gt; field on the user record, the backend writes a new attempt row each time policy is evaluated. That row explains why the message was queued, skipped, or superseded. It sounds small, but it makes production debugging much less fuzzy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why auth emails need attempt receipts
&lt;/h2&gt;

&lt;p&gt;One mutable status field collapses too many decisions together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;did the API accept this request?&lt;/li&gt;
&lt;li&gt;did cooldown policy suppress it?&lt;/li&gt;
&lt;li&gt;did a worker already claim the send?&lt;/li&gt;
&lt;li&gt;did a newer verification attempt replace it?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those questions are related, but they are not the same event. When you store them in one field, retries become hard to reason about. That is extra true when disposable inboxes or abuse heuristics are part of the flow. A support ticket might mention a strange address like &lt;code&gt;fake e mail com&lt;/code&gt;, while QA might be checking a temporary disposable mail flow in staging, and the database still only says &lt;code&gt;sent&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I prefer an append-only attempt table because it preserves the sequence. You can see that attempt 17 was skipped for cooldown, attempt 18 was queued after the window expired, and attempt 19 was invalidated because the user changed their address. That history is boring in the best way. It lets the backend answer "what happened?" without guessing from current code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes wrong with one mutable status field
&lt;/h2&gt;

&lt;p&gt;The common shortcut is a user or session row with fields like &lt;code&gt;last_email_sent_at&lt;/code&gt;, &lt;code&gt;verification_status&lt;/code&gt;, and maybe &lt;code&gt;email_job_id&lt;/code&gt;. That works for the first version, but it breaks down when behavior becomes slightly richer.&lt;/p&gt;

&lt;p&gt;Here is the pattern I keep seeing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The first request writes &lt;code&gt;queued&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A second request arrives before the worker finishes.&lt;/li&gt;
&lt;li&gt;The API overwrites the same row with &lt;code&gt;suppressed&lt;/code&gt; or &lt;code&gt;requeued&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Observability now reflects the latest story, not the full story.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;At that point, your team starts reading logs to rebuild state. If logs rotate, or if a worker message shows up late, the explanation gets annoyingly weak. This is also where weird strings like &lt;code&gt;tempail&lt;/code&gt; sneak into test traffic and make the investigation feel more messy than it should be.&lt;/p&gt;

&lt;p&gt;The better boundary is to make each policy evaluation durable and immutable. Then "current state" can be derived from the newest valid receipt instead of overwritten by it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A PostgreSQL model for immutable attempts
&lt;/h2&gt;

&lt;p&gt;For &lt;code&gt;PostgreSQL&lt;/code&gt;, I like an &lt;code&gt;auth_email_attempts&lt;/code&gt; table plus an outbox table keyed by the same receipt id:&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;auth_email_attempts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;receipt_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;flow_type&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;email&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;normalized_email&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;attempt_number&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="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;decision&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;supersedes_receipt_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;policy_snapshot&lt;/span&gt; &lt;span class="n"&gt;jsonb&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="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;auth_email_attempts_active_idx&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;auth_email_attempts&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;flow_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt_number&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important thing is not the exact schema. It is the shape of the evidence:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one immutable receipt per evaluated attempt&lt;/li&gt;
&lt;li&gt;one policy snapshot explaining the decision&lt;/li&gt;
&lt;li&gt;one clear link to any older receipt it replaced&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to know which email is current, query for the newest receipt that was not superseded. If you want to know why a send never happened, inspect the stored decision and snapshot. That beats reconstructing intent from four partially related tables.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing receipts inside the REST API transaction
&lt;/h2&gt;

&lt;p&gt;The receipt should be written in the same transaction that decides whether a send may happen. Do not leave policy evaluation to a later worker if you can avoid it. Otherwise the API and worker can drift, and the final record stops matching what the caller was told.&lt;/p&gt;

&lt;p&gt;A clean flow looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Normalize the submitted email and classify the flow.&lt;/li&gt;
&lt;li&gt;Load the latest active receipt for that user and flow.&lt;/li&gt;
&lt;li&gt;Evaluate cooldown, address policy, and resend rules.&lt;/li&gt;
&lt;li&gt;Insert a new receipt row with the decision snapshot.&lt;/li&gt;
&lt;li&gt;Insert an outbox row only when the decision is &lt;code&gt;queued&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That sequence gives you stable behavior under retries. If the client resubmits, the API can create a new receipt that says &lt;code&gt;suppressed_cooldown&lt;/code&gt; without destroying the earlier &lt;code&gt;queued&lt;/code&gt; receipt. If the worker crashes after claiming the outbox row, you still know which receipt it belonged to. It feels a bit more verbose at first, but it pays for itself pretty fast.&lt;/p&gt;

&lt;p&gt;I also like combining this with &lt;a href="https://dev.to/silviutech/playwright-traces-for-flaky-email-tests-483a"&gt;trace-backed inbox debugging&lt;/a&gt; because the test can assert the receipt id before it waits on the inbox. And if your team is doing &lt;a href="https://dev.to/bitheirstake/privacy-reviews-for-email-testing-in-staging-479o"&gt;privacy reviews for staging email flows&lt;/a&gt;, immutable receipts make retention boundaries easier to discuss, since the schema is already explicit about what evidence you keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  How receipts improve debugging and testing
&lt;/h2&gt;

&lt;p&gt;This design helps in a few very practical ways.&lt;/p&gt;

&lt;p&gt;First, debugging gets narrower. When a user says "I got two verification emails," you can compare two receipts and see whether the backend intentionally issued both or whether one worker retried the same job. That answer is usually visible in one query, which is nice.&lt;/p&gt;

&lt;p&gt;Second, API behavior becomes easier to test. A backend test can assert that a resend creates a new receipt with &lt;code&gt;supersedes_receipt_id&lt;/code&gt; set, instead of just checking whether some timestamp moved. That is a much stronger contract, and honestly more maintainable.&lt;/p&gt;

&lt;p&gt;Third, support and product stop relying on fuzzy timelines. The receipt ids give everyone the same vocabulary. Attempt 42 was queued. Attempt 43 was suppressed. Attempt 44 replaced 42 after the cooldown. Small thing, big clarity.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Do I still need an outbox table?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The receipt explains the decision; the outbox handles delivery work. Keep those concerns separate, even if they share identifiers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is this overkill for a small app?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not really. If your product has signup, reset, or magic-link flows, immutable receipts are one of those small backend habits that age suprisingly well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I keep every receipt forever?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Usually no. Keep them long enough for support, incident review, and trend analysis, then archive or delete based on policy.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>authentication</category>
      <category>postgres</category>
      <category>restapi</category>
    </item>
    <item>
      <title>Store Email Policy Snapshots With Auth Events</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Tue, 11 Aug 2026 05:25:25 +0000</pubDate>
      <link>https://dev.to/kevindev27/store-email-policy-snapshots-with-auth-events-43ba</link>
      <guid>https://dev.to/kevindev27/store-email-policy-snapshots-with-auth-events-43ba</guid>
      <description>&lt;p&gt;In auth systems, email failures are rarely about one missing &lt;code&gt;send()&lt;/code&gt; call. The real problem is that the system forgets &lt;em&gt;why&lt;/em&gt; it decided to send, suppress, or reroute a message at that moment. Weeks later, support sees a complaint, QA has a screenshot, and the backend only has a boolean like &lt;code&gt;email_sent = true&lt;/code&gt;. That field is almost useless.&lt;/p&gt;

&lt;p&gt;What has worked better for me is storing a small policy snapshot beside each auth email event. Not just the recipient address, but the normalized address, the rule version, the reason code, and any delivery guard that shaped the decision. In a &lt;code&gt;REST API&lt;/code&gt; backed by &lt;code&gt;PostgreSQL&lt;/code&gt;, this gives you a clean audit trail without turning the user record into a junk drawer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why send flags stop being useful
&lt;/h2&gt;

&lt;p&gt;A plain send flag tells you that some code path ran. It does not tell you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which normalization rules were active&lt;/li&gt;
&lt;li&gt;whether the address was considered risky or disposable&lt;/li&gt;
&lt;li&gt;whether resend cooldown logic suppressed the message&lt;/li&gt;
&lt;li&gt;whether the provider was skipped because the request already had a valid receipt&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That missing context matters when email policy changes over time. Maybe last month you blocked a domain pattern. Maybe you tightened alias handling. Maybe the signup form started rejecting strings that looked like &lt;code&gt;dummy e mail&lt;/code&gt; in abuse-heavy traffic. If you only keep the current rule set in code, your historical auth events become weirdly hard to explain.&lt;/p&gt;

&lt;p&gt;This is extra important for password reset and verification flows, where a small mismatch can look like an account issue. I like the framing in &lt;a href="https://dev.to/bitheirstake/recovery-emails-need-safer-evidence-19al-temp-slug-4788024?preview=6a002e502e176fcfcc58d78e4fba624d56a894827d2564055f8264b4699ce1f2c25f219dd3e383b370c8a779cde187b1dcfdaea41a812a4afc9a6119"&gt;safer recovery email evidence&lt;/a&gt;: keep enough evidence to debug the decision, but not so much that logs become their own privacy problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Snapshot the policy, not just the address
&lt;/h2&gt;

&lt;p&gt;The snapshot does not need to be huge. I usually persist a compact JSON object with the fields that explain the decision:&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;"normalized_email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sam@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rule_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email-policy-2026-08-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"delivery_decision"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"allowed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason_code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"default_allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cooldown_window_seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"address_traits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"plus_alias"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"consumer_domain"&lt;/span&gt;&lt;span class="p"&gt;]&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 key idea is simple: save the evaluated result, not only the raw input. If you later change canonicalization rules, you can still answer what the system believed at the time. That is often the difference between a 5 minute investigation and a frustrating hour of "it depends what code was deployed then". I have been in that hour a few times, and it is not super fun, honestly.&lt;/p&gt;

&lt;h2&gt;
  
  
  A PostgreSQL shape that stays debuggable
&lt;/h2&gt;

&lt;p&gt;I prefer keeping the snapshot on the event or receipt row rather than on the user 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;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;auth_email_events&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;flow_type&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;correlation_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;raw_email&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;normalized_email&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;policy_snapshot&lt;/span&gt; &lt;span class="n"&gt;jsonb&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;provider_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="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;auth_email_events_lookup_idx&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;auth_email_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;correlation_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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;jsonb&lt;/code&gt; works well here because policy data evolves a bit. You may later add &lt;code&gt;mx_check&lt;/code&gt;, &lt;code&gt;tenant_policy&lt;/code&gt;, or &lt;code&gt;alias_bucket&lt;/code&gt; without forcing a migration every time. I still keep core query fields, like &lt;code&gt;normalized_email&lt;/code&gt; and &lt;code&gt;correlation_id&lt;/code&gt;, as first-class columns so production queries stay fast and boring.&lt;/p&gt;

&lt;p&gt;One practical rule: snapshot only the decision inputs you need for debugging. Do not dump full provider responses or unrelated request payloads into the same blob. That path gets messy, and kinda expensive, pretty fast. I have seen teams mix those concerns together and then regret it later, mostly because the table stops feeling clearly seperated by purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the API should persist at write time
&lt;/h2&gt;

&lt;p&gt;The best moment to write the snapshot is inside the same transaction that creates the outbox or email event. If the worker computes policy later, you invite drift between what the API accepted and what the sender actually used.&lt;/p&gt;

&lt;p&gt;My flow usually looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Normalize and classify the submitted email in the API layer.&lt;/li&gt;
&lt;li&gt;Evaluate resend, risk, and template rules.&lt;/li&gt;
&lt;li&gt;Write the auth event with &lt;code&gt;policy_snapshot&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Insert the outbox row using the same &lt;code&gt;correlation_id&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That sequence makes incident review much cleaner. If support asks why a reset message was skipped for an address that looked like &lt;code&gt;tamp mail com&lt;/code&gt;, the answer comes from the stored event, not from guesswork about current code. If QA is &lt;a href="https://dev.to/sophiax99/how-to-test-oauth-recovery-emails-without-exposing-real-inboxes-hni"&gt;testing OAuth recovery inboxes&lt;/a&gt;, they can line up the observed message with the backend event that created it instead of trusting mailbox timing alone. When a policy change happened last week and the complaint occured today, that extra context helps alot.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this helps testing and support
&lt;/h2&gt;

&lt;p&gt;This pattern helps in three places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Support can inspect one event and see the exact policy decision.&lt;/li&gt;
&lt;li&gt;Engineers can compare old and new rule versions during a rollout.&lt;/li&gt;
&lt;li&gt;Test tooling can assert against backend evidence before waiting on inbox visibility.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point matters more than it seems. A lot of flaky email tests are really traceability problems. The mailbox is only the last hop. When the backend stores the policy snapshot and correlation id early, failures stop feeling random. You can tell whether the app suppressed the send, whether the worker processed it, or whether the inbox observer simply arrived late.&lt;/p&gt;

&lt;p&gt;It also makes search and reporting better. If product wants to know how many addresses were suppressed by a new policy, SQL can answer that directly from the stored snapshot. No log spelunking, no replay job, less pain.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Do I need a full policy engine for this?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. Even a small ruleset benefits from snapshots if it influences auth email behavior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I store the raw email too?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Usually yes, but keep access scoped. The normalized form explains behavior; the raw form helps diagnose user input edge cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Won't JSONB become a dumping ground?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It can, if you let it. Keep the snapshot small and decision-focused. Treat it like a receipt, not a warehouse.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>authentication</category>
      <category>postgres</category>
      <category>restapi</category>
    </item>
    <item>
      <title>Idempotent Signup Emails in REST APIs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Tue, 11 Aug 2026 02:24:16 +0000</pubDate>
      <link>https://dev.to/kevindev27/idempotent-signup-emails-in-rest-apis-54e4</link>
      <guid>https://dev.to/kevindev27/idempotent-signup-emails-in-rest-apis-54e4</guid>
      <description>&lt;p&gt;Signup email delivery looks simple until retries start stacking up. A client times out, the user taps submit again, and now the same account may get two confirmation emails with different lifetimes or different audit trails. In a REST API, that is not just annoying. It makes Authentication behavior harder to explain, harder to test, and harder to support once prod traffic gets noisy.&lt;/p&gt;

&lt;p&gt;Teams searching for get temporary email flows or tp mail so are often trying to isolate inboxes for testing, but the deeper backend issue is idempotency. You want one logical signup attempt to produce one durable email intent, even if the transport layer or the worker retried more than once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why signup emails become duplicated
&lt;/h2&gt;

&lt;p&gt;Most duplicate signup emails are caused by two things happening at the same time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the API does not persist a stable request identity before enqueueing work&lt;/li&gt;
&lt;li&gt;the email worker cannot tell whether it is handling a fresh event or a replay&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That gap is usualy hidden when the happy path is fast. It appears later when mobile networks retry, reverse proxies replay on 502s, or a queue consumer restarts mid-send. If the only trace you keep is "email sent," your enviroment gives you no clean way to explain which request created which message.&lt;/p&gt;

&lt;p&gt;The same thinking behind &lt;a href="https://dev.to/kevindev27/testing-password-reset-emails-in-postgresql-backed-rest-apis-fb8"&gt;reset email isolation patterns&lt;/a&gt; applies here. An email event should be tied to durable state first, delivery second.&lt;/p&gt;

&lt;h2&gt;
  
  
  What state a REST API should persist
&lt;/h2&gt;

&lt;p&gt;For signup confirmation, I like storing one row per logical email intent rather than one row per worker attempt. That row can be small:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;signup_request_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;user_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;purpose&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;idempotency_key&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;token_hash&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;status&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;created_at&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;last_sent_at&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important part is not the exact column list. It is that your service can answer a few boring but critical questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did this client already ask for a signup confirmation?&lt;/li&gt;
&lt;li&gt;Is the current token still the valid one?&lt;/li&gt;
&lt;li&gt;Was the email already queued or sent?&lt;/li&gt;
&lt;li&gt;If a worker retries, should it send again or mark the attempt as duplicate?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When support teams chase weird aliases from staging, they also run into garbage inputs like tepm mail com. I do not use those strings as identifiers, but I do keep the raw submitted email in a scrubbed audit record so operaters can see what the client actually sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small idempotent email design
&lt;/h2&gt;

&lt;p&gt;The easiest design to maintain is usually:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Accept an idempotency key from the client, or derive one from a bounded request window.&lt;/li&gt;
&lt;li&gt;Insert the signup intent in a transaction.&lt;/li&gt;
&lt;li&gt;Enqueue an outbox job that references the inserted row.&lt;/li&gt;
&lt;li&gt;Let the worker send only when the row is still in a sendable state.&lt;/li&gt;
&lt;li&gt;Mark delivery timestamps on success without changing the logical intent identity.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In Node.js, the handler can stay pretty small:&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="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;intent&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;signupEmailIntent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId_idempotencyKey&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="na"&gt;update&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
    &lt;span class="na"&gt;create&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="na"&gt;purpose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;signup_confirm&lt;/span&gt;&lt;span class="dl"&gt;"&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;tokenHash&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&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;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;outbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;signup-email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;aggregateId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;intent&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That pattern isnt fancy, but it does something valuable: retries stop creating new logical work. The worker may still retry delivery, yet the API state stays stable. This also makes &lt;a href="https://dev.to/ryanlee91/react-signup-flows-need-email-states-5c45"&gt;signup email state handling&lt;/a&gt; easier for frontend teams because the backend can expose a clearer status model.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I test retries without guessing
&lt;/h2&gt;

&lt;p&gt;My preferred test is not "did one email arrive?" It is "did the system preserve one logical signup intent while surviving repeated calls?" The flow is pretty simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a fresh user and isolated inbox alias.&lt;/li&gt;
&lt;li&gt;Call the signup-confirm endpoint twice with the same idempotency key.&lt;/li&gt;
&lt;li&gt;Assert that the database still has one logical signup intent row.&lt;/li&gt;
&lt;li&gt;Assert that only one current token is valid.&lt;/li&gt;
&lt;li&gt;Inspect the inbox and prove the observed message maps to that row.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If I need inbox verification, I keep the query narrow by alias and time window. That avoids false positives from older mailboxes and makes failures much less random. The test should also verify that a second worker retry does not produce a second valid token. Delivery may retry; meaning should not.&lt;/p&gt;

&lt;p&gt;One subtle bug shows up when the API writes the outbox event before the intent row is committed. Under load, a worker can race ahead, fetch partial state, and send an email that no longer matches the final token. Those bugs are rare, but they are exactly the kind that become expensive once alerts start waking people up at 3 AM.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist before you ship
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One signup attempt maps to one durable email intent.&lt;/li&gt;
&lt;li&gt;The REST API can answer whether a request was new or repeated.&lt;/li&gt;
&lt;li&gt;Worker retries are recorded as attempts, not new business events.&lt;/li&gt;
&lt;li&gt;The valid confirmation token is easy to identify in storage.&lt;/li&gt;
&lt;li&gt;Inbox tests use isolated aliases and tight windows.&lt;/li&gt;
&lt;li&gt;Audit fields are readable enough that an on-call engineer can diagnose the issue fast, even if the first report is a bit messy or worded akwardly.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Should I block every repeated signup request?
&lt;/h3&gt;

&lt;p&gt;Not necessarily. Repeated requests are normal. What you want is stable semantics, not fragile rejection logic. Return a safe response and keep the business event deduplicated.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need an outbox if my mail provider already retries?
&lt;/h3&gt;

&lt;p&gt;Yes, if you care about explaining backend behavior. Provider retries help delivery. The outbox helps your service keep a consistent story about what should have happened.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is one inbox message always enough evidence?
&lt;/h3&gt;

&lt;p&gt;No. A readable inbox is useful, but the stronger proof comes from joining message evidence back to stored API state. Without that, duplicate sends and stale tokens are harder to seperate.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>authentication</category>
      <category>node</category>
    </item>
    <item>
      <title>Auth Logs Need Inbox Correlation IDs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Sat, 08 Aug 2026 23:24:26 +0000</pubDate>
      <link>https://dev.to/kevindev27/auth-logs-need-inbox-correlation-ids-ah</link>
      <guid>https://dev.to/kevindev27/auth-logs-need-inbox-correlation-ids-ah</guid>
      <description>&lt;p&gt;Verification email bugs are annoying because the failure usually lands between systems. The &lt;code&gt;REST API&lt;/code&gt; says the request passed, the worker says it sent something, and QA says the inbox never showed the message. If those three views do not share one identifier, your incident review turns into log archaeology pretty fast.&lt;/p&gt;

&lt;p&gt;What has worked better for me is adding a correlation id that travels from the auth request, into PostgreSQL, through the worker, and finally into any inbox assertion data. It is a small backend habit, but it makes Authentication issues much easier to explain and fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why auth systems lose the email trail
&lt;/h2&gt;

&lt;p&gt;Many auth services already store useful bits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;user id&lt;/li&gt;
&lt;li&gt;email template&lt;/li&gt;
&lt;li&gt;provider response id&lt;/li&gt;
&lt;li&gt;retry count&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That still is not enough when support asks a simple question: "Did this exact signup request produce the message that QA checked?"&lt;/p&gt;

&lt;p&gt;The gap happens because delivery metadata and inbox metadata are often recorded in different places with different keys. A worker might log &lt;code&gt;job_id&lt;/code&gt;, while the test harness stores only the recipient address. If the address was reused, or a resend happened, your evidence gets fuzzy. I have seen this go wrong even in otherwise clean systems, and the debugging cost was higher than the schema fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The correlation id shape I keep in PostgreSQL
&lt;/h2&gt;

&lt;p&gt;I like giving every email-producing auth action one &lt;code&gt;correlation_id&lt;/code&gt; at request time. That id gets written to the outbox row and also to a receipt 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;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;auth_email_receipts&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;correlation_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;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;email_job_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;email_jobs&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="k"&gt;template&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;delivery_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="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;provider_message_id&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;inbox_observed_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;inbox_observation_source&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="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is not fancy SQL. It is that &lt;code&gt;correlation_id&lt;/code&gt; survives every handoff. PostgreSQL is a good fit here because inserts are cheap, ordering is obvious, and querying one incident path stays boring. Boring is good, honestly.&lt;/p&gt;

&lt;p&gt;I also keep &lt;code&gt;inbox_observation_source&lt;/code&gt; optional. Sometimes the inbox check comes from Playwright, sometimes from a manual run, and sometimes not at all. Your source of truth should still be the backend evidence trail, not whether an inbox tool happened to respond that minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the API and worker pass the same evidence forward
&lt;/h2&gt;

&lt;p&gt;My preferred flow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The signup or password-reset endpoint creates &lt;code&gt;correlation_id&lt;/code&gt; before the transaction commits.&lt;/li&gt;
&lt;li&gt;The API writes the outbox row with that id.&lt;/li&gt;
&lt;li&gt;The worker copies the same id into the delivery receipt when it claims and sends the job.&lt;/li&gt;
&lt;li&gt;Test or QA tooling records inbox observations against the same id.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That means one query can answer the whole story:&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;select&lt;/span&gt; &lt;span class="n"&gt;correlation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delivery_status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;provider_message_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inbox_observed_at&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;auth_email_receipts&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;correlation_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;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;asc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When teams add &lt;a href="https://dev.to/ryanlee91/abortable-email-polling-in-react-jgp"&gt;abortable inbox polling&lt;/a&gt; on the client or in QA tooling, this shared id keeps polling results attached to the right auth event instead of just "whatever arrived for this mailbox". That matters more than people expect.&lt;/p&gt;

&lt;p&gt;If I need an external mailbox for a staging check, I keep it narrow and contextual. A service like &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;email temporary free&lt;/a&gt; can help validate that the user-visible message arrived, but I still treat it as supporting evidence. The receipt and outbox data should explain the backend path first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where inbox tools fit without owning the truth
&lt;/h2&gt;

&lt;p&gt;Inbox tools are useful, but I try not to let them define correctness by themselves. Mailboxes are noisier than your database. Polling delays happen. Test addresses get reused. Someone pastes the wrong temp org mail into a manual checklist and now the investigation starts from a bad premise.&lt;/p&gt;

&lt;p&gt;That is why I separate two questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did the auth backend create and send the intended message?&lt;/li&gt;
&lt;li&gt;Did an inbox observer later confirm the message was visible?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are related, but not identical. The first one should be answerable entirely from your application records. The second one adds user-facing confidence. For teams working on privacy-sensitive flows, &lt;a href="https://dev.to/bitheirstake/privacy-notes-for-invite-email-debugging-4cm5"&gt;invite email privacy checks&lt;/a&gt; are a good reminder that inbox validation should stay scoped and deliberate, not sprayed across logs.&lt;/p&gt;

&lt;p&gt;One more small thing: make the correlation id visible in operator tooling. If an on-call engineer can paste one id into a dashboard and see the request, outbox row, worker attempt, and inbox observation together, the mean time to understand drops a lot. That sounds obvious, but many systems almost do this and then miss by one join key. I did that once, and it was a bit embarrasing.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Should the user id be enough?
&lt;/h3&gt;

&lt;p&gt;Usually no. A single user can trigger several auth emails in a short window, so you need event-level identity, not just actor-level identity.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a new table for this?
&lt;/h3&gt;

&lt;p&gt;Not always, but a dedicated receipt table keeps the timeline cleaner than packing every state change onto the user record.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if the provider already gives me a message id?
&lt;/h3&gt;

&lt;p&gt;Keep it, but do not depend on it as your primary join key. You need an id created inside your system before the provider call happens.&lt;/p&gt;

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