Không có rate limit, form quên mật khẩu thành máy bắn spam miễn phí
Form quên mật khẩu, đăng nhập, gửi OTP không giới hạn số lần gọi thì ai cũng dùng được để spam người khác và đốt tiền email, SMS của bạn. Cách đặt rate limit theo IP, theo tài khoản, trả 429 kèm Retry-After, có code Next.js và Express.
Xem nhanh trong 1 phút
Mỗi lần ai đó bấm “Quên mật khẩu”, app của bạn gửi đi một email. Nếu bước đó là SMS thì mỗi tin tốn vài trăm đồng. Bây giờ thử hình dung một script gọi nút đó 50.000 lần trong một đêm, mỗi lần một email nạn nhân khác nhau, hoặc cùng một số điện thoại của người bạn ghét. App không chặn gì cả, vì lúc nhờ AI viết form, không ai nhắc tới chuyện giới hạn.
Sáng ra bạn thấy tài khoản Resend hay SendGrid bị khóa vì gửi spam, tên miền vào danh sách đen, hoặc hóa đơn SMS dài bất thường. OWASP đưa đúng kịch bản này vào danh sách lỗi API phổ biến: hacker gọi API khởi tạo đặt lại mật khẩu hàng chục nghìn lần, mỗi lần nhà cung cấp SMS tính 0,05 đô, và công ty mất vài nghìn đô trong vài phút.
Rate limit là gì, đặt ở đâu
Rate limit là giới hạn số lần một “ai đó” được gọi một chức năng trong một khoảng thời gian. Quá giới hạn thì server từ chối, trả mã 429 Too Many Requests.
Chữ “ai đó” là phần cần nghĩ. Có ba cách đếm, và form nhạy cảm thường cần hai cách cùng lúc:
Theo IP: chặn một máy gọi dồn dập. Dễ làm nhưng hacker có thể đổi IP liên tục, còn người dùng sau cùng một mạng công ty thì bị chặn chung.
Theo tài khoản hoặc email đích: chặn việc spam một người. OWASP khuyên khóa đăng nhập theo tài khoản chứ không theo IP, chính vì hacker đổi IP được.
Theo API key: cho API bạn cấp key cho khách hoặc cho app khác gọi.
Những chỗ app vibe code hay quên giới hạn nhất, xếp theo độ đau:
Chức năng
Vì sao cần chặn
Gợi ý giới hạn
Quên mật khẩu, gửi lại email xác nhận, magic link
Spam hộp thư người khác, đốt quota email
1 lần mỗi 60 giây cho mỗi email, vài lần mỗi giờ cho mỗi IP
Gửi OTP qua SMS hoặc Zalo
Tốn tiền thật cho mỗi tin
Như trên, cộng trần số tin mỗi ngày cho cả app
Đăng nhập, nhập mã OTP
Dò mật khẩu, dò mã 6 số
Khóa tạm theo tài khoản sau vài lần sai
Đăng ký
Tạo hàng loạt tài khoản rác
Theo IP
API gọi AI, xuất file, tìm kiếm
Tốn CPU, tốn tiền model
Theo người dùng và theo key
Webhook nhận từ ngoài
Bị gọi dồn làm sập worker
Theo nguồn, kèm kiểm chữ ký
Các con số trong bảng là điểm bắt đầu, không phải chuẩn. Supabase Auth đặt mặc định 60 giây giữa hai lần gửi email đặt lại mật khẩu cho một người, và 30 request mỗi 5 phút cho nhóm đăng ký, đăng nhập. Bạn có thể lấy đó làm mốc.
Trả 429 cho đúng
Khi chặn, đừng trả 200 kèm câu “thử lại sau”, cũng đừng trả 500. Trả 429 để trình duyệt, app di động và các thư viện gọi API hiểu chuyện gì đang xảy ra. Thêm header Retry-After cho biết phải chờ bao nhiêu giây. Theo RFC 6585, header này không bắt buộc, nhưng có nó thì client tử tế sẽ tự lùi lại thay vì gọi tiếp.
HTTP/1.1 429 Too Many RequestsRetry-After: 60Content-Type: application/json{"error": "Bạn gửi yêu cầu quá nhanh, thử lại sau 60 giây."}
Với form quên mật khẩu còn một điều nữa: câu trả lời khi bị chặn và khi gửi thành công nên giống nhau về nội dung lẫn thời gian phản hồi. Nếu không, hacker nhìn vào là biết email nào có tài khoản.
Next.js với Upstash Redis
App chạy trên Vercel hay serverless thì không đếm trong bộ nhớ được, vì mỗi request có thể rơi vào một instance khác. Cần một chỗ đếm chung, Redis là lựa chọn quen thuộc. Thư viện @upstash/ratelimit làm sẵn phần đếm:
// app/api/forgot-password/route.tsimport { Ratelimit } from "@upstash/ratelimit";import { Redis } from "@upstash/redis";const redis = Redis.fromEnv();const perEmail = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(1, "60 s"), prefix: "rl:forgot:email" });const perIp = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(5, "1 h"), prefix: "rl:forgot:ip" });export async function POST(req: Request) { const { email } = await req.json(); const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; const [a, b] = await Promise.all([ perEmail.limit(String(email).toLowerCase()), perIp.limit(ip), ]); if (!a.success || !b.success) { const reset = Math.max(a.reset, b.reset); const retry = Math.ceil((reset - Date.now()) / 1000); return Response.json( { error: "Bạn gửi yêu cầu quá nhanh, thử lại sau." }, { status: 429, headers: { "Retry-After": String(retry) } } ); } await sendResetEmailIfUserExists(email); // không báo email có tồn tại hay không return Response.json({ message: "Nếu email có tài khoản, bạn sẽ nhận được link trong vài phút." });}
Đếm theo email đã chuẩn hóa chữ thường, nếu không A@gmail.com và a@gmail.com thành hai người. Header x-forwarded-for trên Vercel do nền tảng đặt nên tin được phần tử đầu. Nếu tự chạy sau Nginx hay Cloudflare, hãy kiểm lại proxy của bạn ghi IP thật vào header nào, vì client tự gửi header này lên được.
Express với express-rate-limit
import express from "express";import { rateLimit } from "express-rate-limit";const app = express();app.set("trust proxy", 1); // chỉ khi app nằm sau đúng một reverse proxyconst authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 10, standardHeaders: "draft-8", legacyHeaders: false,});const forgotLimiter = rateLimit({ windowMs: 60 * 1000, limit: 1, keyGenerator: (req) => String(req.body?.email ?? "").toLowerCase(),});app.post("/login", authLimiter, loginHandler);app.post("/forgot-password", express.json(), authLimiter, forgotLimiter, forgotHandler);
Hai chỗ hay sai. Một là trust proxy: đặt sai thì mọi request mang IP của proxy, cả app chung một bộ đếm và người dùng thật bị chặn hàng loạt; đặt true bừa thì hacker giả IP bằng header. Hai là bộ đếm mặc định nằm trong RAM: chạy hai instance trở lên, hoặc khởi động lại server, là giới hạn mất tác dụng. Lúc đó dùng store Redis.
Supabase, Firebase và dịch vụ có sẵn
Nếu dùng Supabase Auth hay Firebase Auth cho đăng nhập, phần auth đã có giới hạn riêng. Vào Supabase, mục Authentication > Rate Limits để xem và chỉnh. Lưu ý email mặc định của Supabase chỉ cho 2 email mỗi giờ, nên nhiều người nối SMTP riêng để tăng lên, rồi quên đặt lại giới hạn hợp lý.
Giới hạn của dịch vụ auth chỉ bảo vệ các endpoint của nó. Edge Function bạn tự viết để gửi OTP qua Twilio, form liên hệ gửi mail qua Resend, API gọi OpenAI thì vẫn trống, và đó thường là chỗ tốn tiền nhất.
Thêm một lớp ngoài cùng: đặt trần chi tiêu ngay trên trang của nhà cung cấp SMS, email, AI. Rate limit trong code có lỗi thì trần này vẫn chặn được hóa đơn.
Kiểm tra app của bạn trong vài phút
Mở danh sách route, đánh dấu những route gửi email, SMS, gọi AI, hoặc nhận mật khẩu và mã OTP.
Với mỗi route đó, gọi liên tục 30 lần và xem có lần nào bị chặn không:
for i in $(seq 1 30); do curl -s -o /dev/null -w "%{http_code} " -X POST https://app-cua-ban.com/api/forgot-password \ -H 'Content-Type: application/json' -d '{"email":"email-cua-ban@example.com"}'done; echo
Thấy ba mươi số 200 là chưa có giới hạn. Nhớ dùng email của chính bạn, và chỉ thử trên app của mình.
Nếu đã thấy 429, kiểm tra có header Retry-After không: thêm -i vào lệnh curl để xem header.
Thử đổi chữ hoa chữ thường trong email, thêm header X-Forwarded-For: 1.2.3.4. Vẫn qua được thì bộ đếm đang tin nhầm thứ client gửi lên.
Mở trang quản trị của nhà cung cấp email, SMS, AI, xem đã đặt trần chi tiêu hoặc cảnh báo chưa.
Muốn nhờ AI sửa, đưa nó bảng ở trên và yêu cầu cụ thể: “thêm rate limit theo email và theo IP cho route này, trả 429 kèm Retry-After, dùng Redis”. Câu “thêm bảo mật cho form” thường chỉ ra một captcha.
App của bạn đang có route nào gửi SMS hay email mà chưa giới hạn số lần không? Vào cộng đồng Vibe Code & Bảo Mật kể cho mọi người cách bạn đặt giới hạn nhé.
rate limitđăng nhậpAPI/anh/web/rate-limit-quen-mat-khau-480.webprate limit · đăng nhập · API
Khong co rate limit, form quen mat khau thanh may ban spam mien phi Form quen mat khau, dang nhap, gui OTP khong gioi han so lan goi thi ai cung dung duoc de spam nguoi khac va dot tien email, SMS cua ban. Cach dat rate limit theo IP, theo tai khoan, tra 429 kem Retry-After, co code Next.js va Express. rate limit dang nhap API Moi lan ai do bam "Quen mat khau", app cua ban gui di mot email. Neu buoc do la SMS thi moi tin ton vai tram dong. Bay gio thu hinh dung mot script goi nut do 50.000 lan trong mot dem, moi lan mot email nan nhan khac nhau, hoac cung mot so dien thoai cua nguoi ban ghet. App khong chan gi ca, vi luc nho AI viet form, khong ai nhac toi chuyen gioi han.
Sang ra ban thay tai khoan Resend hay SendGrid bi khoa vi gui spam, ten mien vao danh sach den, hoac hoa don SMS dai bat thuong. OWASP dua dung kich ban nay vao danh sach loi API pho bien: hacker goi API khoi tao dat lai mat khau hang chuc nghin lan, moi lan nha cung cap SMS tinh 0,05 do, va cong ty mat vai nghin do trong vai phut.

## Rate limit la gi, dat o dau
Rate limit la gioi han so lan mot "ai do" duoc goi mot chuc nang trong mot khoang thoi gian. Qua gioi han thi server tu choi, tra ma `429 Too Many Requests`.
Chu "ai do" la phan can nghi. Co ba cach dem, va form nhay cam thuong can hai cach cung luc:
- **Theo IP**: chan mot may goi don dap. De lam nhung hacker co the doi IP lien tuc, con nguoi dung sau cung mot mang cong ty thi bi chan chung.
- **Theo tai khoan hoac email dich**: chan viec spam mot nguoi. OWASP khuyen khoa dang nhap theo tai khoan chu khong theo IP, chinh vi hacker doi IP duoc.
- **Theo API key**: cho API ban cap key cho khach hoac cho app khac goi.
Nhung cho app vibe code hay quen gioi han nhat, xep theo do dau:
| Chuc nang | Vi sao can chan | Goi y gioi han |
|---|---|---|
| Quen mat khau, gui lai email xac nhan, magic link | Spam hop thu nguoi khac, dot quota email | 1 lan moi 60 giay cho moi email, vai lan moi gio cho moi IP |
| Gui OTP qua SMS hoac Zalo | Ton tien that cho moi tin | Nhu tren, cong tran so tin moi ngay cho ca app |
| Dang nhap, nhap ma OTP | Do mat khau, do ma 6 so | Khoa tam theo tai khoan sau vai lan sai |
| Dang ky | Tao hang loat tai khoan rac | Theo IP |
| API goi AI, xuat file, tim kiem | Ton CPU, ton tien model | Theo nguoi dung va theo key |
| Webhook nhan tu ngoai | Bi goi don lam sap worker | Theo nguon, kem kiem chu ky |
Cac con so trong bang la diem bat dau, khong phai chuan. Supabase Auth dat mac dinh 60 giay giua hai lan gui email dat lai mat khau cho mot nguoi, va 30 request moi 5 phut cho nhom dang ky, dang nhap. Ban co the lay do lam moc.
## Tra 429 cho dung
Khi chan, dung tra `200` kem cau "thu lai sau", cung dung tra `500`. Tra `429` de trinh duyet, app di dong va cac thu vien goi API hieu chuyen gi dang xay ra. Them header `Retry-After` cho biet phai cho bao nhieu giay. Theo RFC 6585, header nay khong bat buoc, nhung co no thi client tu te se tu lui lai thay vi goi tiep.
```http
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json
{"error": "Ban gui yeu cau qua nhanh, thu lai sau 60 giay."}
```
Voi form quen mat khau con mot dieu nua: cau tra loi khi bi chan va khi gui thanh cong nen giong nhau ve noi dung lan thoi gian phan hoi. Neu khong, hacker nhin vao la biet email nao co tai khoan.
## Next.js voi Upstash Redis
App chay tren Vercel hay serverless thi khong dem trong bo nho duoc, vi moi request co the roi vao mot instance khac. Can mot cho dem chung, Redis la lua chon quen thuoc. Thu vien `@upstash/ratelimit` lam san phan dem:
```ts
// app/api/forgot-password/route.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const redis = Redis.fromEnv();
const perEmail = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(1, "60 s"), prefix: "rl:forgot:email" });
const perIp = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(5, "1 h"), prefix: "rl:forgot:ip" });
export async function POST(req: Request) {
const { email } = await req.json();
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const [a, b] = await Promise.all([
perEmail.limit(String(email).toLowerCase()),
perIp.limit(ip),
]);
if (!a.success || !b.success) {
const reset = Math.max(a.reset, b.reset);
const retry = Math.ceil((reset - Date.now()) / 1000);
return Response.json(
{ error: "Ban gui yeu cau qua nhanh, thu lai sau." },
{ status: 429, headers: { "Retry-After": String(retry) } }
);
}
await sendResetEmailIfUserExists(email); // khong bao email co ton tai hay khong
return Response.json({ message: "Neu email co tai khoan, ban se nhan duoc link trong vai phut." });
}
```
Dem theo email da chuan hoa chu thuong, neu khong `A@gmail.com` va `a@gmail.com` thanh hai nguoi. Header `x-forwarded-for` tren Vercel do nen tang dat nen tin duoc phan tu dau. Neu tu chay sau Nginx hay Cloudflare, hay kiem lai proxy cua ban ghi IP that vao header nao, vi client tu gui header nay len duoc.
## Express voi express-rate-limit
```js
import express from "express";
import { rateLimit } from "express-rate-limit";
const app = express();
app.set("trust proxy", 1); // chi khi app nam sau dung mot reverse proxy
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10,
standardHeaders: "draft-8",
legacyHeaders: false,
});
const forgotLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 1,
keyGenerator: (req) => String(req.body?.email ?? "").toLowerCase(),
});
app.post("/login", authLimiter, loginHandler);
app.post("/forgot-password", express.json(), authLimiter, forgotLimiter, forgotHandler);
```
Hai cho hay sai. Mot la `trust proxy`: dat sai thi moi request mang IP cua proxy, ca app chung mot bo dem va nguoi dung that bi chan hang loat; dat `true` bua thi hacker gia IP bang header. Hai la bo dem mac dinh nam trong RAM: chay hai instance tro len, hoac khoi dong lai server, la gioi han mat tac dung. Luc do dung `store` Redis.
## Supabase, Firebase va dich vu co san
Neu dung Supabase Auth hay Firebase Auth cho dang nhap, phan auth da co gioi han rieng. Vao Supabase, muc **Authentication > Rate Limits** de xem va chinh. Luu y email mac dinh cua Supabase chi cho 2 email moi gio, nen nhieu nguoi noi SMTP rieng de tang len, roi quen dat lai gioi han hop ly.
Gioi han cua dich vu auth chi bao ve cac endpoint cua no. Edge Function ban tu viet de gui OTP qua Twilio, form lien he gui mail qua Resend, API goi OpenAI thi van trong, va do thuong la cho ton tien nhat.
Them mot lop ngoai cung: dat tran chi tieu ngay tren trang cua nha cung cap SMS, email, AI. Rate limit trong code co loi thi tran nay van chan duoc hoa don.
## Kiem tra app cua ban trong vai phut
1. Mo danh sach route, danh dau nhung route gui email, SMS, goi AI, hoac nhan mat khau va ma OTP.
2. Voi moi route do, goi lien tuc 30 lan va xem co lan nao bi chan khong:
```bash
for i in $(seq 1 30); do
curl -s -o /dev/null -w "%{http_code} " -X POST https://app-cua-ban.com/api/forgot-password \
-H 'Content-Type: application/json' -d '{"email":"email-cua-ban@example.com"}'
done; echo
```
Thay ba muoi so `200` la chua co gioi han. Nho dung email cua chinh ban, va chi thu tren app cua minh.
3. Neu da thay `429`, kiem tra co header `Retry-After` khong: them `-i` vao lenh `curl` de xem header.
4. Thu doi chu hoa chu thuong trong email, them header `X-Forwarded-For: 1.2.3.4`. Van qua duoc thi bo dem dang tin nham thu client gui len.
5. Mo trang quan tri cua nha cung cap email, SMS, AI, xem da dat tran chi tieu hoac canh bao chua.
Muon nho AI sua, dua no bang o tren va yeu cau cu the: "them rate limit theo email va theo IP cho route nay, tra 429 kem Retry-After, dung Redis". Cau "them bao mat cho form" thuong chi ra mot captcha.
---
App cua ban dang co route nao gui SMS hay email ma chua gioi han so lan khong? Vao [cong dong Vibe Code & Bao Mat](https://www.facebook.com/groups/1867387324580254) ke cho moi nguoi cach ban dat gioi han nhe.
## Nguon
- [API4:2023 Unrestricted Resource Consumption](https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/), OWASP API Security Top 10
- [Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html) va [Forgot Password Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html), OWASP
- [RFC 6585, muc 4: 429 Too Many Requests](https://datatracker.ietf.org/doc/html/rfc6585#section-4), IETF
- [Rate limits](https://supabase.com/docs/guides/auth/rate-limits), Supabase Docs
- [Getting started with @upstash/ratelimit](https://upstash.com/docs/redis/sdks/ratelimit-ts/gettingstarted), Upstash
- [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit), GitHub