<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Building toui.io]]></title><description><![CDATA[Building toui.io]]></description><link>https://thebrecht.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/69f77c7d76c1469ba4ccc219/064b7d8c-d1db-4db1-9d70-fb33b8a9c905.png</url><title>Building toui.io</title><link>https://thebrecht.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 09 Sep 2026 00:46:24 GMT</lastBuildDate><atom:link href="https://thebrecht.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Five days before launch I tore out Resend and rebuilt magic-link auth on AWS SES + Lambda]]></title><description><![CDATA[I run toui.io, a URL shortener I shipped to the public on April 7, 2026. Eleven days before launch I had passwordless email login working on Resend. Five days before launch I tore it out and rebuilt t]]></description><link>https://thebrecht.hashnode.dev/five-days-before-launch-i-tore-out-resend-and-rebuilt-magic-link-auth-on-aws-ses-lambda</link><guid isPermaLink="true">https://thebrecht.hashnode.dev/five-days-before-launch-i-tore-out-resend-and-rebuilt-magic-link-auth-on-aws-ses-lambda</guid><category><![CDATA[AWS]]></category><category><![CDATA[resend]]></category><category><![CDATA[SES]]></category><category><![CDATA[lambda]]></category><category><![CDATA[serverless]]></category><category><![CDATA[DynamoDB]]></category><category><![CDATA[email]]></category><dc:creator><![CDATA[Brecht Huang]]></dc:creator><pubDate>Mon, 01 Jun 2026 03:51:17 GMT</pubDate><content:encoded><![CDATA[<img src="https://cdn.hashnode.com/uploads/covers/69f77c7d76c1469ba4ccc219/49bebbe1-19b4-4bc7-8487-465a848a0446.png" alt="" style="display:block;margin:0 auto" />

<p>I run <a href="https://toui.io">toui.io</a>, a URL shortener I shipped to the public on <strong>April 7, 2026</strong>. Eleven days before launch I had passwordless email login working on <a href="https://resend.com/">Resend</a>. Five days before launch I tore it out and rebuilt the same flow on AWS — Lambda + DynamoDB + SES + API Gateway, packaged as a SAM stack.</p>
<p>The whole migration took one afternoon. I want to write about why I did it, what the stack looks like, and the one thing I'd do differently.</p>
<p>This isn't a Resend hit piece. Resend is excellent for product engineers who want clean DX, a great API, and to never think about deliverability. If you're shipping a side project, use it. The math just changes when you start projecting actual production volume.</p>
<h2>The math that pushed me off Resend</h2>
<p>When I shipped the Resend version on March 27 the price was $0/month — the project was sending zero emails. Then I sat down and projected the year-one cost curve.</p>
<p><a href="https://resend.com/pricing">Resend's pricing</a>, at the time of writing (May 2026):</p>
<table>
<thead>
<tr>
<th>Tier</th>
<th>Monthly cost</th>
<th>Email cap</th>
</tr>
</thead>
<tbody><tr>
<td>Free</td>
<td>$0</td>
<td>3,000/month (100/day)</td>
</tr>
<tr>
<td>Pro</td>
<td>$20</td>
<td>50,000/month</td>
</tr>
<tr>
<td>Pro</td>
<td>$35</td>
<td>100,000/month</td>
</tr>
<tr>
<td>Scale</td>
<td>\(90–\)1,150</td>
<td>100,000–2,500,000/month</td>
</tr>
</tbody></table>
<p><a href="https://aws.amazon.com/ses/pricing/">AWS SES pricing</a>, also today:</p>
<table>
<thead>
<tr>
<th>Volume</th>
<th>Monthly cost</th>
</tr>
</thead>
<tbody><tr>
<td>First 3,000/month (first 12 months)</td>
<td>$0</td>
</tr>
<tr>
<td>Anything else</td>
<td>$0.10 per 1,000 emails</td>
</tr>
</tbody></table>
<p>That's not a small gap. Some scenarios I sketched, with SES on the left and Resend on the right (Resend Scale tier breakpoints taken from the same pricing page):</p>
<table>
<thead>
<tr>
<th>Monthly volume</th>
<th>SES</th>
<th>Resend</th>
<th>Multiple</th>
</tr>
</thead>
<tbody><tr>
<td>10,000</td>
<td>$1.00</td>
<td>$20 (Pro 50k)</td>
<td><strong>20×</strong></td>
</tr>
<tr>
<td>100,000</td>
<td>$10.00</td>
<td>$35 (Pro 100k)</td>
<td><strong>3.5×</strong></td>
</tr>
<tr>
<td>500,000</td>
<td>$50.00</td>
<td>$350 (Scale)</td>
<td><strong>7×</strong></td>
</tr>
<tr>
<td>1,000,000</td>
<td>$100.00</td>
<td>$650 (Scale)</td>
<td><strong>6.5×</strong></td>
</tr>
<tr>
<td>2,500,000</td>
<td>$250.00</td>
<td>$1,150 (Scale top)</td>
<td><strong>4.6×</strong></td>
</tr>
</tbody></table>
<p>The 20× number at the low end isn't because SES is magic. It's because Resend Pro has a \(20 floor — you pay the Pro fee the moment you cross 3,000 emails or 100/day, regardless of whether your actual usage is 10,001 or 49,999. SES's \)0.10/1,000 is linear past the free tier.</p>
<p>For my profile — magic-link login + occasional product emails (welcome, billing receipts, announcements) — I was projecting somewhere between 10k and 100k emails per month in year one. That's solidly inside the "3.5–20×" cost-gap band.</p>
<p>And there's a second thing: <strong>Resend Free's 100/day daily cap</strong>. URL shorteners get viral spikes. A single shared post can dump 200 signups in an hour. Resend Free shuts that down at 100; Resend Pro fixes it but you're back on the $20+ tier. SES has no monthly cap and a per-second sending rate that scales up automatically as you build reputation.</p>
<p>The math said move now. Five days before launch is the worst possible time to do anything risky, but it's the <strong>best</strong> possible time to do something whose risk is "rebuild a working flow on a different vendor, in one well-isolated stack."</p>
<h2>What I built</h2>
<p>The whole magic-link service became a separate AWS stack, deployable independently of the Cloudflare Worker that hosts the rest of the app:</p>
<pre><code class="language-plaintext">Frontend                   API Gateway (auth.&lt;domain&gt;)       CF Worker
   │                            │                                │
   ├── POST /send {email} ─────►│                                │
   │                            ├─ DynamoDB: store token (TTL)   │
   │                            ├─ SES: send email with link     │
   ◄── 200 OK ──────────────────┤                                │
   │                            │                                │
   │  (user clicks email link)  │                                │
   │                            │◄── GET /verify?token=xxx       │
   │                            ├─ DynamoDB: validate + delete   │
   │                            ├─ 302 → /auth/magic-callback    │
   │                            │    ?payload=BASE64&amp;sig=HMAC ──►│
   │                            │                                ├─ Verify HMAC
   │                            │                                ├─ create session
   │                            │                                ├─ Set-Cookie
   │                            │                                ├─ 302 → /admin
</code></pre>
<p>The pieces, top to bottom:</p>
<p><a href="https://aws.amazon.com/serverless/sam/"><strong>AWS SAM</strong></a> packages the whole thing as one CloudFormation stack. <code>sam deploy</code> ships everything atomically.</p>
<p><strong>DynamoDB</strong> holds tokens. Single table, partition key <code>token</code> (a UUID), with a TTL attribute set to <code>now + 900s</code> (15 minutes). Billing mode <code>PAY_PER_REQUEST</code> — at auth-flow volumes the bill is effectively zero.</p>
<p>One thing worth flagging clearly, because it's easy to miss: <strong>DynamoDB's</strong> <a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/howitworks-ttl.html"><strong>TTL feature</strong></a> <strong>is opportunistic cleanup, not a security boundary.</strong> AWS's own docs say expired items are deleted "within a few days of their expiration time" — in practice that means the row can linger for up to ~48 hours past the TTL value. So if you let <code>GetItem</code> succeed and trust DynamoDB to have already swept the row away, you're effectively extending the token's lifetime from 15 minutes to up to ~48 hours.</p>
<p>The right shape is to enforce expiry <strong>at verify time</strong> and let TTL handle the eventual cleanup separately. Either an explicit check after <code>GetItem</code>:</p>
<pre><code class="language-ts">const ttl = Number(result.Item.ttl?.N || '0');
if (ttl &lt; Math.floor(Date.now() / 1000)) {
  return failureRedirect();
}
</code></pre>
<p>…or, even cleaner, push the freshness check into the atomic <code>DeleteItem</code> with a <code>ConditionExpression</code>, so a stale token can never both pass verification and get consumed:</p>
<pre><code class="language-ts">await dynamo.send(new DeleteItemCommand({
  TableName: process.env.TOKENS_TABLE!,
  Key: { token: { S: token } },
  ConditionExpression: '#ttl &gt; :now',
  ExpressionAttributeNames: { '#ttl': 'ttl' },
  ExpressionAttributeValues: { ':now': { N: String(Math.floor(Date.now() / 1000)) } },
}));
</code></pre>
<p>If the condition fails, the delete throws <code>ConditionalCheckFailedException</code> — catch it, redirect to the failure URL, done. TTL still does its background job; the row will eventually disappear. But the <em>security</em> of "15-minute magic link" is enforced by the verify handler, not by the cleanup mechanism.</p>
<p>This split — TTL for tidy housekeeping, conditional-write for real expiry — is the pattern I'd default to for any short-lived token table.</p>
<p><strong>Lambda</strong> (Node.js 20.x, 128MB, 10s timeout) runs three routes:</p>
<ul>
<li><p><code>POST /send</code> — validate email, write token to DynamoDB, call <code>ses:SendEmail</code></p>
</li>
<li><p><code>GET /verify</code> — read token from DynamoDB, delete it, redirect to the Cloudflare Worker with an HMAC-signed payload</p>
</li>
<li><p><code>POST /send-email</code> — generic email send for non-auth flows (welcome, billing, announcements); called by the CF Worker over HMAC</p>
</li>
</ul>
<p><strong>API Gateway HTTP API (v2)</strong> fronts the Lambda. Custom domain, ACM cert, CORS limited to the app origin. Throttling: 10 req/s burst, 5 req/s sustained per route.</p>
<p><strong>SES</strong> sends the actual email. Verified domain, custom MAIL FROM subdomain, DKIM CNAMEs + SPF + DMARC all set during the cutover.</p>
<p><strong>Secrets Manager</strong> holds <code>HMAC_SECRET</code> and <code>FROM_EMAIL</code>. Lambda reads on cold start and caches in a module-level variable, so warm invocations don't pay the API call.</p>
<p>The CF Worker side stays simple — it never touches DynamoDB. The only bridge between the AWS side and the Cloudflare side is the HMAC-signed redirect. AWS owns the token lifecycle; Cloudflare owns sessions. Neither code path needs cross-cloud credentials.</p>
<h2>The cutover itself</h2>
<p>Counter to how this story sounds, the migration wasn't dramatic. I had:</p>
<ul>
<li><p>The design spec written that morning, after the math became clear</p>
</li>
<li><p>An existing Resend implementation to mirror for behavior (rate-limit, email body, error messages)</p>
</li>
<li><p>AWS SES already verified for the domain (I'd done that the week before, "just in case")</p>
</li>
</ul>
<p>By the afternoon the SAM stack was deployed, the Cloudflare Worker had been updated to call the new <code>auth.</code> subdomain instead of the old in-Worker Resend endpoint, and I'd manually sent test magic links from a few different email clients to make sure nothing landed in spam.</p>
<p>Total wall-clock time: about 6 hours. About 90 minutes of that was DNS propagation for the custom domain CNAME — the only step I couldn't speed up. The rest was code that I'd already mentally compiled by the time I started typing.</p>
<h2>Surprises and the one thing I'd redo</h2>
<p><strong>SES out-of-sandbox</strong>: SES accounts start in sandbox mode (can only send to verified addresses). Production needs you to request out-of-sandbox via a support ticket, and AWS reviews it in ~24h. I'd done this previously for an unrelated project on the same account, so I was already approved in <code>ap-northeast-1</code>. If you're doing this fresh, file the ticket the day you create the account, not the day you need to ship.</p>
<p><strong>Email warm-up</strong>: A new SES account starts in sandbox mode, capped at <a href="https://docs.aws.amazon.com/ses/latest/dg/quotas.html">200 emails per 24 hours and 1 email per second</a>. Once you request and receive production access, your starting quota "varies based on your specific use case" (AWS docs language — in practice, a low starting limit that scales with reputation). For pre-launch and early-launch traffic this was fine. If you're launching to thousands of users on day one, plan ahead with a dedicated IP and a warm-up schedule.</p>
<p><strong>The one thing I'd redo</strong>: I shipped the Lambda's secrets reader as a per-invocation lookup before realizing how chatty that was. The fix is trivial — read on cold start, cache in a module-level variable so warm invocations skip the call — but the first deploy ate a few cents of Secrets Manager API calls before I noticed. AWS's own <a href="https://docs.aws.amazon.com/secretsmanager/latest/userguide/retrieving-secrets_lambda.html">Lambda + Secrets Manager docs</a> cover the pattern; follow it from the first commit, not the third.</p>
<h2>Would I do it again?</h2>
<p>Yes — but earlier. Sitting on Resend's free tier for the months before launch cost me nothing in money but cost me a sunk-cost-aware migration on the worst possible week. If I had projected my year-one volume the day I picked Resend, I'd have started on SES.</p>
<p>If you're prototyping and don't know your scale, Resend is still the right answer; the developer experience is genuinely better and the API is well-shaped. If you have a number — even a rough one — that crosses 10k emails/month in your projection, do the math early.</p>
<p>The AWS magic-link service is still the auth backbone of the project two months in — SAM template, Lambda handlers, and email templates all living in one <code>aws/magic-link/</code> directory. If anyone wants a sanitized stand-alone reference repo of just the SAM stack + Lambda code + DynamoDB schema, <strong>drop a comment</strong> and I'll cut one — it's small enough to be a useful starting point for any side project that needs passwordless email auth.</p>
<p>If you've done the same migration in the opposite direction, or stuck with Resend at scale, I'd genuinely like to hear about it — <strong>leave a comment below.</strong></p>
]]></content:encoded></item></channel></rss>