DEV Community

thebrecht
thebrecht

Posted on

How I migrated magic-link login from Resend to AWS SES + Lambda five days before launch

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 the same flow on AWS — Lambda + DynamoDB + SES + API Gateway, packaged as a SAM stack.

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.

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.

The math that pushed me off Resend

When I shipped the Resend version on March 27 the price was $0/month — toui.io was a side project sending zero emails. Then I sat down and projected the year-one cost curve.

Resend's pricing, at the time of writing (May 2026):

Tier Monthly cost Email cap
Free $0 3,000/month (100/day)
Pro $20 50,000/month
Pro $35 100,000/month
Scale $90–$1,150 100,000–2,500,000/month

AWS SES pricing, also today:

Volume Monthly cost
First 3,000/month (first 12 months) $0
Anything else $0.10 per 1,000 emails

That's not a small gap. Some scenarios I sketched, with Resend on the right and SES on the left (Resend Scale tier breakpoints taken from the same pricing page):

Monthly volume SES Resend Multiple
10,000 $1.00 $20 (Pro 50k) 20×
100,000 $10.00 $35 (Pro 100k) 3.5×
500,000 $50.00 $350 (Scale)
1,000,000 $100.00 $650 (Scale) 6.5×
2,500,000 $250.00 $1,150 (Scale top) 4.6×

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.

For toui.io's 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.

And there's a second thing: Resend Free's 100/day daily cap. 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.

The math said move now. Five days before launch is the worst possible time to do anything risky, but it's the best possible time to do something whose risk is "rebuild a working flow on a different vendor, in one well-isolated stack."

What I built

The whole magic-link service became a separate AWS stack, deployable independently of the Cloudflare Worker that hosts the rest of toui.io:

Frontend (toui.io)         API Gateway (auth.toui.io)        CF Worker (toui.io)
   │                            │                                │
   ├── 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&sig=HMAC ──►│
   │                            │                                ├─ Verify HMAC
   │                            │                                ├─ D1: create session
   │                            │                                ├─ Set-Cookie
   │                            │                                ├─ 302 → /admin
Enter fullscreen mode Exit fullscreen mode

The pieces, top to bottom:

AWS SAM packages the whole thing as one CloudFormation stack. sam deploy ships everything atomically.

DynamoDB holds tokens. Single table, partition key token (a UUID), with a TTL attribute set to now + 900s (15 minutes). Billing mode PAY_PER_REQUEST — at auth-flow volumes the bill is effectively zero.

One thing worth flagging clearly, because it's easy to miss: DynamoDB's TTL feature is opportunistic cleanup, not a security boundary. 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 GetItem 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.

The right shape is to enforce expiry at verify time and let TTL handle the eventual cleanup separately. Either an explicit check after GetItem:

const ttl = Number(result.Item.ttl?.N || '0');
if (ttl < Math.floor(Date.now() / 1000)) {
  return failureRedirect();
}
Enter fullscreen mode Exit fullscreen mode

…or, even cleaner, push the freshness check into the atomic DeleteItem with a ConditionExpression, so a stale token can never both pass verification and get consumed:

await dynamo.send(new DeleteItemCommand({
  TableName: process.env.TOKENS_TABLE!,
  Key: { token: { S: token } },
  ConditionExpression: '#ttl > :now',
  ExpressionAttributeNames: { '#ttl': 'ttl' },
  ExpressionAttributeValues: { ':now': { N: String(Math.floor(Date.now() / 1000)) } },
}));
Enter fullscreen mode Exit fullscreen mode

If the condition fails, the delete throws ConditionalCheckFailedException — catch it, redirect to the failure URL, done. TTL still does its background job; the row will eventually disappear. But the security of "15-minute magic link" is enforced by the verify handler, not by the cleanup mechanism.

This split — TTL for tidy housekeeping, conditional-write for real expiry — is the pattern I'd default to for any short-lived token table.

Lambda (Node.js 20.x, 128MB, 10s timeout) runs three routes:

  • POST /send — validate email, write token to DynamoDB, call ses:SendEmail
  • GET /verify — read token from DynamoDB, delete it, redirect to the Cloudflare Worker with an HMAC-signed payload
  • POST /send-email — generic email send for non-auth flows (welcome, billing, announcements); called by the CF Worker over HMAC

API Gateway HTTP API (v2) fronts the Lambda. Custom domain auth.toui.io, ACM cert, CORS limited to https://toui.io. Throttling: 10 req/s burst, 5 req/s sustained per route.

SES sends the actual email. Verified domain toui.io, custom MAIL FROM mail.toui.io, DKIM CNAMEs + SPF + DMARC all set during the cutover.

Secrets Manager holds HMAC_SECRET and FROM_EMAIL. Lambda reads on cold start and caches in a module-level variable, so warm invocations don't pay the API call.

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.

The cutover itself

Counter to how this story sounds, the migration wasn't dramatic. I had:

  • The design spec written that morning, after the math became clear
  • An existing Resend implementation to mirror for behavior (rate-limit, email body, error messages)
  • AWS SES already verified for toui.io (I'd done that the week before, "just in case")

By the afternoon the SAM stack was deployed, the Cloudflare Worker had been updated to call auth.toui.io 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.

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.

Surprises and the one thing I'd redo

SES out-of-sandbox: 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 ap-northeast-1. If you're doing this fresh, file the ticket the day you create the account, not the day you need to ship.

Email warm-up: A new SES account starts in sandbox mode, capped at 200 emails per 24 hours and 1 email per second. 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 toui.io's 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.

The one thing I'd redo: 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 Lambda + Secrets Manager docs cover the pattern; follow it from the first commit, not the third.

Would I do it again?

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.

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.

The AWS magic-link service is still the auth backbone of toui.io two months in. The SAM template, Lambda handlers, and email templates live in the aws/magic-link/ directory of the toui.io codebase. If anyone wants a sanitized stand-alone reference repo of just the SAM stack + Lambda code + DynamoDB schema, ping me and I'll cut one — it's small enough to be useful as a starting point for any side project that needs passwordless email auth.

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 — hello@toui.io.


toui.io is a free URL shortener with permanent links, a free public REST API, OG/QR customization, and Telegram + LINE bots. The name is Taiwanese: to-ui — "where to?"

Top comments (0)