API Security: Rate Limiting, Auth, dan Kesalahan yang Sering Lolos Code Review
Reviewer cuma nulis "LGTM" karena test hijau dan tidak ada error TypeScript. Enam bulan kemudian ada di headline: 40 juta token API bocor karena endpoint /api/export tidak cek quota dan tidak ada rate limit. OWASP API Security Top 10 (2023) ada untuk itu — API punya attack surface sendiri yang tidak masuk di web UI biasa. Artikel ini tidak akan daftar definisi. Kita langsung bedah kode yang salah di Express dan Next.js, cara saya nge-exploitnya di lab, dan gimana nge-fix dengan alasan yang jelas. Fokus tiga yang paling sering lolos review: rate limiting, authentication/authorization, dan kesalahan validasi yang kelihatan sepele tapi fatal.
Rate Limiting: middleware yang cuma jalan di satu route
Masalah paling umum: rate limit dipasang di /api/login tapi lupa di /api/reset-password, /api/verify-otp, dan semua endpoint publik lain. Attacker pindah ke endpoint yang tidak dilindungi.
// Express — SALAH: limit cuma di satu route
const rateLimit = require("express-rate-limit");
const loginLimit = rateLimit({ windowMs: 15 * 60 * 1000, max: 10 });
app.post("/login", loginLimit, loginHandler); // aman
app.post("/reset-password", resetHandler); // ← tidak ada limit
app.post("/verify-otp", otpHandler); // ← tidak ada limit
Exploit: attacker panggil /reset-password 1000x dengan email korban → mailbomb, atau brute OTP 6 digit (hanya 1 juta kombinasi, bisa habis dalam menit kalau tidak dilimit per IP + per email).
Fix: pasang limit global sebagai middleware application-level, lalu per-route lebih ketat untuk auth:
// global
app.use(rateLimit({ windowMs: 60_000, max: 120 })); // 120 req/menit per IP
// stricter untuk auth
const authLimit = rateLimit({ windowMs: 15 * 60 * 1000, max: 5, keyGenerator: (req) => req.ip + req.body?.email });
app.post("/reset-password", authLimit, resetHandler);
Alasan: limit per-IP saja tidak cukup kalau attacker pakai botnet, tapi limit per-IP + per-identifier (email/user) menaikkan biaya serangan. Jangan simpan counter di memory proses kalau kamu punya banyak instance — pakai Redis (rate-limit-redis) supaya counter terbagi. Di Next.js, pakai @upstash/ratelimit + Redis atau middleware export const config = { matcher: '/api/:path*' } untuk global, lalu logic per-route di handler.
Rate Limiting: trust ke X-Forwarded-For tanpa validasi
Kode di atas pakai req.ip — di Express behind proxy, req.ip bisa salah kalau app.set('trust proxy') tidak diset, atau sebaliknya attacker bisa spoof X-Forwarded-For.
// SALAH: keyGenerator pakai header yang bisa di-spoof
keyGenerator: (req) => req.headers["x-forwarded-for"] || req.ip
Exploit: attacker kirim X-Forwarded-For: 1.2.3.4 berganti-ganti tiap request → tiap request hitung sebagai IP beda, limit per-IP useless.
Fix: set app.set('trust proxy', 1) (atau angka hop yang benar), lalu req.ip sudah berisi IP asli dari proxy terpercaya. Jangan pernah pakai X-Forwarded-For mentah sebagai identifier kecuali kamu yang kontrol proxy dan yakin chain-nya bersih. Di Caddy/Nginx, pastikan header di-rewrite, bukan di-append sembarangan.
Authentication: token di query string
Masih banyak API bikin ?token=abc karena "gampang di-test". Token di URL masuk ke log proxy, browser history, dan referrer header.
// Next.js — SALAH
export async function GET(req: Request) {
const token = new URL(req.url).searchParams.get("token"); // bocor ke log
const user = await verifyToken(token);
}
Exploit: admin buka https://app/api/export?token=... di browser, token masuk ke history perusahaan dan ke log akses Caddy (/var/log/access.log). Siapa pun dengan read log dapat token.
Fix: token di Authorization: Bearer header. Di Next.js, baca dari req.headers.get('authorization'). Untuk WebSocket, pakai cookie Sec-WebSocket-Protocol atau query sekali pakai (bukan token jangka panjang). Alasan: URL bukan tempat rahasia; header tidak tercatat di akses log standar.
Authentication: JWT tanpa expiry atau tanpa blacklist
JWT tanpa exp = credential abadi. Atau ada exp tapi logout tidak invalidate (sudah dibahas di OWASP A07). Variant lain: secret signing lemah.
// SALAH: secret dari env tapi default fallback lemah
const secret = process.env.JWT_SECRET || "devsecret";
Exploit: kalau env lupa diset di prod, attacker forge token dengan devsecret → admin penuh.
Fix: jangan ada fallback. Proses crash kalau JWT_SECRET kosong. Pakai secret 32+ byte acak (openssl rand -hex 32). Untuk invalidate, pakai jti + deny list Redis (lihat OWASP A07). Alasan: fallback secret adalah "works on my machine" yang jadi breach di prod.
Authorization: object-level access control (BOLA)
Ini yang paling sering lolos review. Endpoint GET /api/orders/:id cek login, tapi lupa cek apakah order itu milik user.
// Next.js — SALAH (BOLA: Broken Object Level Authorization)
export async function GET(req: Request, { params }: { params: { id: string } }) {
const session = await getServerSession();
if (!session) return Response.json({ error: "Unauthorized" }, { status: 401 });
const order = await prisma.order.findUnique({ where: { id: params.id } });
return Response.json(order); // ← order milik user lain pun kebawa
}
Exploit: user A ganti :id dari ord_111 ke ord_112 (punya user B) → baca order orang lain. Kalau ID sequential (1,2,3), attacker enumerate seluruh database.
Fix: filter by owner di query:
const order = await prisma.order.findFirst({
where: { id: params.id, userId: session.user.id }, // ← owner check
});
if (!order) return Response.json({ error: "Not found" }, { status: 404 });
Alasan: 404 bukan 403 untuk resource bukan milik supaya attacker tidak tahu ID valid milik orang lain. Ini OWASP API1:2023 — paling sering di API karena reviewer fokus ke "sudah login" bukan "punya hak ke object ini".
Authorization: function-level (BFLA) di admin
Endpoint /api/admin/delete-user boleh diakses user biasa kalau middleware cuma cek isLoggedIn.
// Express — SALAH
app.use("/admin", authMiddleware); // cuma cek login
app.delete("/admin/users/:id", deleteUser); // tidak cek role ADMIN
Exploit: user biasa POST /admin/users/5 → hapus user lain.
Fix: role check terpusat (sudah dibahas di OWASP A01). Gunakan decorator atau helper requireRole('ADMIN') di tiap route sensitif, jangan mengandalkan "frontend tidak nampilin tombol".
Input validation: Parse bukan sekadar type-check
Banyak yang validasi dengan typeof body.amount === 'number' lalu langsung pakai. Tapi amount bisa NaN, -999, atau 1e309.
// SALAH: cek tipe doang
const amount = req.body.amount;
if (typeof amount !== "number") return error();
await charge(amount); // amount = -500 → refund ke attacker
Exploit: {"amount": -500} → sistem "charge" negatif = transfer ke attacker. Atau amount: 1e30 → integer overflow di kolom DB.
Fix: validasi range dan tanda dengan schema (Zod/Yup):
const Schema = z.object({ amount: z.number().positive().finite().max(1_000_000) });
const { amount } = Schema.parse(req.body); // throw kalau invalid
Alasan: type-check tidak menangkap semantik. Uang negatif, nol, atau di luar batas adalah logic bug yang jadi financial exploit.
Mass assignment: body mentah ke update
Endpoint PATCH /api/profile yang langsung spread req.body ke Prisma update.
// SALAH: mass assignment
await prisma.user.update({ where: { id }, data: req.body }); // user kirim {role:"ADMIN"}
Exploit: user biasa PATCH { "role": "ADMIN", "isVerified": true } → promote diri jadi admin.
Fix: whitelist field yang boleh diubah:
await prisma.user.update({
where: { id },
data: { name: body.name, bio: body.bio }, // hanya field aman
});
Atau pakai Zod .pick() dari schema user. Alasan: jangan pernah percaya shape body client; selalu explicit field yang diizinkan tulis.
Error handling: stack trace ke client
API yang lempar error mentah ke response bocor struktur internal.
// SALAH
catch (e) { return Response.json({ error: e.stack }, { status: 500 }); }
Exploit: attacker trigger 500, baca stack trace dapat path /var/www/app/src/db/, versi modul, nama fungsi internal → recon cepat.
Fix: log error di server (console.error ke journald), kirim ke client hanya {"error":"Internal server error"} tanpa detail. Di Express, central error handler app.use((err, req, res, next) = ...). Alasan: detail error adalah peta untuk attacker.
CORS: wildcard dengan credentials
// SALAH
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Credentials", "true"); // kontradiktif + bahaya
Exploit: browser tidak izinkan * + credentials, tapi kalau kamu reflect origin (Allow-Origin: evil.com) + credentials, evil.com bisa panggil API dengan cookie user.
Fix: allowlist origin eksplisit, jangan reflect sembarangan, dan jangan pakai * dengan credentials. Di Next.js, set di next.config headers() atau Caddy. Alasan: CORS salah konfigurasi = CSRF + data leak lintas origin.
Kesalahan yang lolos code review: summary
Ini yang saya temui paling sering lewat PR:
- Rate limit cuma di login, lupa di reset/otp/verify.
req.ipdi-spoof lewat X-Forwarded-For.- Token di query string.
- JWT secret ada fallback lemah.
- BOLA: cek login tapi tidak cek owner object.
- BFLA: cek login tapi tidak cek role di route admin.
- Validasi cuma
typeof, tidak cek range/tanda. - Mass assignment: body mentah ke update.
- Stack trace ke client.
- CORS
*+ credentials.
Semua di atas lolos karena "tidak error saat test" dan "frontend tidak bisa akses". Frontend bukan security boundary — API harus berdiri sendiri aman.
Takeaways
- Rate limit global + per-identifier; simpan counter di Redis kalau multi-instance.
req.iponly valid kalautrust proxybenar; jangan percaya X-Forwarded-For mentah.- Token di Authorization header, bukan URL.
- JWT butuh exp + secret kuat tanpa fallback + deny list saat logout.
- BOLA: selalu filter by owner di query, return 404 bukan 403.
- BFLA: role check terpusat di tiap route sensitif.
- Validasi pakai schema (Zod) dengan range + tanda, bukan typeof doang.
- Mass assignment: whitelist field write.
- Error: log di server, kirim pesan netral ke client.
- CORS: allowlist origin, jangan
*+ credentials.



