tl;dr:不想為了 email 登入綁一個每月固定費用的寄信服務,可以用 AWS SES + Lambda + DynamoDB + API Gateway 自己做一套 Magic Link(無密碼)登入,寄信成本是線性的 $0.10 / 1,000 封、沒有月費門檻。這篇從資安概念講到完整架構,重點放在一個很多人會忽略的雷:DynamoDB 的 TTL 不能拿來當 token 的過期判斷。
toui.io 是我維護的短網址服務(名字是台語「佗位(tó-uī)」,意思是「哪裡」),它在上線前要做使用者登入,除了 Google 登入外,我還選了無密碼的 Magic Link 登入方式:使用者輸入 email、收一封含一次性連結的信、點了就登入,不用記密碼。
第一版我用現成的寄信服務串起來,跑得很好,而且免費使用也很香。但坐下來把預估上線後一年的寄信量算一算,發現費用曲線不太對——這類服務多半是「跨過免費額度就跳到每月 $20 起跳的方案」,雖然說多也不算太多,但只是為了登入就要再額外花費,每次有人登入都要心痛一下,這樣好像不太對。算完帳後決定:寄信這段自己用 AWS 做,成本從「月費」變回「用多少算多少」。整個搬遷花了一個下午,這篇就把這套架構完整寫出來。
先說清楚:這不是在diss任何寄信服務。如果你想要儘快上線、專注在服務核心,那些服務是好的選擇。只是當你開始預估「正式環境的真實量」時,算法就不同。
很多人對「沒有密碼反而比較安全」覺得反直覺,所以先把觀念講清楚。後面那些實作為什麼要這樣設計,原因其實都在這。
Magic Link 把「密碼」整個拿掉,好處是:
關鍵觀念:Magic Link 把「那條連結」變成了憑證本身。 所以那個 token 必須:
一個進階的雷:有些企業的郵件安全閘道(防釣魚、防惡意連結那種)會自動預抓信裡的連結。如果你的驗證連結是「一點就消耗」,掃描器可能在使用者真的點之前就把一次性 token 點掉了。要做到很穩,可以在最後加一個「需要使用者實際按一下」的確認頁,而不是 GET 連結直接完成登入。
簡單說 Magic Link 不是「比較弱的登入」,而是把「密碼 + 忘記密碼」這條本來就存在的後門,整併成一條你掌控得住、也查得到紀錄的流程——前提是 token 的亂數、一次性、時效都做對。
把寄信服務的方案門檻和 SES 的線性計費擺在一起看(數字以官方定價頁為準,以下為撰寫時的概況):
典型寄信服務:免費額度(約每月 3,000 封 / 每日 100 封)→ 跨過就跳到每月 $20 起的付費方案,再往上是 $35、$90⋯⋯一路階梯。重點是月費門檻:你用 10,001 封或 49,999 封,付的是同一筆方案費。
AWS SES:前 12 個月每月前 3,000 封 $0,之後 $0.10 / 1,000 封,線性、沒有月費門檻、沒有每月寄信上限。
抓幾個量級對比(左邊 SES、右邊是寄信服務的方案費):
| 每月寄信量 | SES | 寄信服務方案 | 倍數 |
|---|---|---|---|
| 10,000 | $1.00 | $20 | 約 20× |
| 100,000 | $10.00 | $35 | 約 3.5× |
| 500,000 | $50.00 | $350 | 約 7× |
低量端那個 20× 不是因為 SES 多神,而是因為月費方案有 $20 的地板——一跨過免費額度就得付,跟你實際只用了一萬封還是四萬封無關。SES 跨過免費額度後是線性的。
還有一個容易忽略的點:每日寄信上限。短網址服務常有病毒式尖峰,一篇被分享的貼文可能一小時內灌進 200 個註冊。免費額度的「每日 100 封」會在這裡卡死;SES 沒有每月上限,每秒寄信速率會隨你的寄信信譽自動往上。
整套 Magic Link 服務做成一個獨立的 AWS stack,跟主站(我的主站跑在 Cloudflare Worker 上)分開部署:

兩朵雲之間唯一的橋,是那條帶 HMAC 簽章的轉址:AWS 這邊負責 token 生命週期,主站那邊負責 session。兩邊的程式碼都不需要持有對方雲的憑證。
AWS SAM 把整包打包成一份 CloudFormation stack,sam deploy 一次原子部署。
DynamoDB 存 token。單一表,partition key 是 token(一個 UUID),billing 用 PAY_PER_REQUEST——在登入流量這種量級,帳單幾乎是零。每筆設一個 TTL 屬性 = now + 900(15 分鐘)。但這個 TTL 不是拿來判斷過期的(下一段細講)。
Lambda(Node.js 20.x、128MB、10s timeout)跑三條路由:
POST /send — 驗證 email、把 token 寫進 DynamoDB、呼叫 ses:SendEmail
GET /verify — 從 DynamoDB 讀 token、刪除、帶 HMAC 簽章轉址回主站POST /send-email — 給非登入流程用的通用寄信(歡迎信、帳單、公告),由主站經 HMAC 呼叫API Gateway(HTTP API v2) 放在 Lambda 前面。自訂網域、ACM 憑證、CORS 限定你的主站來源。節流設每路由 10 req/s burst、5 req/s sustained——順手擋掉對 /send 的濫用。
SES 負責真正寄信。驗證網域、自訂 MAIL FROM、DKIM CNAME + SPF + DMARC 在切換時一次設好(送達率全靠這幾項)。
Secrets Manager 存 HMAC_SECRET 等敏感值。Lambda 在冷啟動時讀一次、快取在 module-level 變數裡,熱呼叫就不用再付一次 API 呼叫(這點我第一版做錯了,見最後)。
這是整篇最值得注意的一點。
DynamoDB 的 TTL 功能是盡力而為的背景清理,不是即時刪除。AWS 官方文件白紙黑字寫:過期項目會在「過期時間後的幾天內」被刪掉——實務上,一筆過了 TTL 的 row 可能還會在表裡留到約 48 小時。
這代表什麼?如果你的 /verify 是「GetItem 讀得到就放行」、然後信任 DynamoDB 已經幫你把過期的掃掉了,那你等於把一條號稱 15 分鐘的 Magic Link,實際壽命拉長到最多約 48 小時。一條本該失效的登入連結,在這段時間裡還能用。
正確做法是:過期判斷放在 verify 當下做,TTL 只負責之後的清理。 兩者分開。
最乾淨的寫法,是把「新鮮度檢查」直接塞進那個原子的 DeleteItem 裡,用 ConditionExpression——這樣一條過期的 token 不可能同時通過驗證又被消耗掉:
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)) } },
}));
如果條件不成立,這個 delete 會丟出 ConditionalCheckFailedException——接住它、轉址到失敗頁,結束。TTL 還是會在背景做它的事,那筆 row 最終會消失;但「15 分鐘 Magic Link」這個安全性質,是由 verify handler 保證的,不是由清理機制保證的。
如果你不想用條件式寫入,至少也要在 GetItem 之後做一次明確檢查:
const ttl = Number(result.Item.ttl?.N || '0');
if (ttl < Math.floor(Date.now() / 1000)) {
return failureRedirect();
}
這個「TTL 管整潔、條件式寫入管真正的過期」的拆分,是我之後處理任何短命 token 表都會預設採用的模式。
SES 沙箱(sandbox):SES 帳號一開始在沙箱模式,只能寄給已驗證的地址,而且配額很低(每 24 小時 200 封、每秒 1 封)。要正式寄給任何人,得開支援單申請出沙箱,AWS 大約 24 小時內審。建議:建好帳號當天就申請,不要等到要上線那天才弄。
寄信信譽 / 暖機:拿到正式權限後,起始配額「依使用情境而定」(官方用語,實務上是一個會隨信譽成長的低起點)。對上線初期的量通常夠用;但如果你第一天就要寄給好幾千人,要提前規劃專用 IP 和暖機排程。
我會重做的一點:第一版我把 Secrets Manager 寫成「每次呼叫都去讀一次」,後來才發現有夠囉嗦——修法很簡單:冷啟動讀一次、快取在 module-level 變數,熱呼叫就跳過。AWS 自己的 Lambda + Secrets Manager 文件就是這個模式,從第一個 commit 就照做,不要像我到第三次部署才修。
如果你在做側專案、還不知道自己的規模,用現成寄信服務沒有錯,但只要你的預估裡有一個會跨過「每月一萬封」的數字,就值得早點把這套自己搭起來——核心其實不複雜,難的是 TTL 那種「看起來會動、但安全性質是錯的」細節。