Autentikasi, otorisasi, validasi input, rate limiting, CORS, manajemen secret, dan OWASP API Top 10. Apa yang saya periksa sebelum setiap deployment produksi.
Saya pernah mengirimkan API yang terbuka lebar. Bukan dengan sengaja, bukan karena malas — saya hanya tidak tahu apa yang tidak saya ketahui. Sebuah endpoint yang mengembalikan setiap field dalam objek user, termasuk password yang sudah di-hash. Sebuah rate limiter yang hanya memeriksa alamat IP, yang berarti siapa pun di balik proxy bisa membombardir API tersebut. Sebuah implementasi JWT di mana saya lupa memverifikasi klaim iss, sehingga token dari layanan yang sama sekali berbeda bisa berfungsi dengan baik.
Setiap kesalahan itu berhasil sampai ke produksi. Setiap kesalahan itu tertangkap — beberapa oleh saya, beberapa oleh pengguna, satu oleh peneliti keamanan yang cukup baik hati untuk mengirim email kepada saya daripada mempostingnya di Twitter.
Tulisan ini adalah checklist yang saya buat dari kesalahan-kesalahan itu. Saya menjalankannya sebelum setiap deployment produksi. Bukan karena saya paranoid, tetapi karena saya sudah belajar bahwa bug keamanan adalah yang paling menyakitkan. Tombol yang rusak membuat pengguna kesal. Alur autentikasi yang rusak membocorkan data mereka.
Dua kata ini sering digunakan secara bergantian dalam rapat, dalam dokumentasi, bahkan dalam komentar kode. Keduanya bukan hal yang sama.
Autentikasi menjawab: "Siapa Anda?" Ini adalah langkah login. Username dan password, alur OAuth, magic link — apa pun yang membuktikan identitas Anda.
Otorisasi menjawab: "Apa yang boleh Anda lakukan?" Ini adalah langkah perizinan. Bisakah pengguna ini menghapus resource ini? Bisakah mereka mengakses endpoint admin ini? Bisakah mereka membaca data pengguna lain?
Bug keamanan paling umum yang saya temui di API produksi bukanlah alur login yang rusak. Melainkan pemeriksaan otorisasi yang hilang. Pengguna sudah terautentikasi — mereka memiliki token yang valid — tetapi API tidak pernah memeriksa apakah mereka diizinkan untuk melakukan aksi yang diminta.
JWT ada di mana-mana. Mereka juga disalahpahami di mana-mana. JWT memiliki tiga bagian, dipisahkan oleh titik:
header.payload.signature
Header menunjukkan algoritma mana yang digunakan. Payload berisi klaim (user ID, role, kedaluwarsa). Signature membuktikan tidak ada yang merusak dua bagian pertama.
Berikut verifikasi JWT yang benar di Node.js:
import jwt from "jsonwebtoken";
import { timingSafeEqual } from "crypto";
interface TokenPayload {
sub: string;
role: "user" | "admin";
iss: string;
aud: string;
exp: number;
iat: number;
jti: string;
}
function verifyToken(token: string): TokenPayload {
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!, {
algorithms: ["HS256"], // Jangan pernah izinkan "none"
issuer: "api.yourapp.com",
audience: "yourapp.com",
clockTolerance: 30, // 30 detik toleransi untuk perbedaan jam
}) as TokenPayload;
return payload;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new ApiError(401, "Token expired");
}
if (error instanceof jwt.JsonWebTokenError) {
throw new ApiError(401, "Invalid token");
}
throw new ApiError(401, "Authentication failed");
}
}Beberapa hal yang perlu diperhatikan:
algorithms: ["HS256"] — Ini sangat penting. Jika Anda tidak menentukan algoritma, penyerang bisa mengirim token dengan "alg": "none" di header dan melewati verifikasi sepenuhnya. Ini adalah serangan alg: none, dan telah mempengaruhi sistem produksi nyata.
issuer dan audience — Tanpa ini, token yang dibuat untuk Layanan A bisa digunakan di Layanan B. Jika Anda menjalankan beberapa layanan yang berbagi secret yang sama (yang seharusnya tidak Anda lakukan, tapi orang melakukannya), inilah bagaimana penyalahgunaan token lintas-layanan terjadi.
Penanganan error yang spesifik — Jangan mengembalikan "invalid token" untuk setiap kegagalan. Membedakan antara token kedaluwarsa dan token tidak valid membantu klien mengetahui apakah harus me-refresh atau re-autentikasi.
Access token harus berumur pendek — 15 menit adalah standar. Tetapi Anda tidak ingin pengguna memasukkan ulang password mereka setiap 15 menit. Di situlah refresh token berperan.
Pola yang benar-benar berfungsi di produksi:
import { randomBytes } from "crypto";
import { redis } from "./redis";
interface RefreshTokenData {
userId: string;
family: string; // Keluarga token untuk deteksi rotasi
createdAt: number;
}
async function rotateRefreshToken(
oldRefreshToken: string
): Promise<{ accessToken: string; refreshToken: string }> {
const tokenData = await redis.get(`refresh:${oldRefreshToken}`);
if (!tokenData) {
// Token tidak ditemukan — entah sudah kedaluwarsa atau sudah digunakan.
// Jika sudah digunakan, ini adalah potensi serangan replay.
// Batalkan seluruh keluarga token.
const parsed = decodeRefreshToken(oldRefreshToken);
if (parsed?.family) {
await invalidateTokenFamily(parsed.family);
}
throw new ApiError(401, "Invalid refresh token");
}
const data: RefreshTokenData = JSON.parse(tokenData);
// Hapus token lama segera — sekali pakai saja
await redis.del(`refresh:${oldRefreshToken}`);
// Buat token baru
const newRefreshToken = randomBytes(64).toString("hex");
const newAccessToken = generateAccessToken(data.userId);
// Simpan refresh token baru dengan keluarga yang sama
await redis.setex(
`refresh:${newRefreshToken}`,
60 * 60 * 24 * 30, // 30 hari
JSON.stringify({
userId: data.userId,
family: data.family,
createdAt: Date.now(),
})
);
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
async function invalidateTokenFamily(family: string): Promise<void> {
// Pindai semua token dalam keluarga ini dan hapus.
// Ini adalah opsi nuklir — jika seseorang me-replay refresh token,
// kita matikan setiap token dalam keluarga, memaksa re-autentikasi.
const keys = await redis.keys(`refresh:*`);
for (const key of keys) {
const data = await redis.get(key);
if (data) {
const parsed = JSON.parse(data) as RefreshTokenData;
if (parsed.family === family) {
await redis.del(key);
}
}
}
}Konsep keluarga token inilah yang membuatnya aman. Setiap refresh token termasuk dalam sebuah keluarga (dibuat saat login). Ketika Anda merotasi, token baru mewarisi keluarga tersebut. Jika penyerang me-replay refresh token lama, Anda mendeteksi penggunaan ulang dan mematikan seluruh keluarga. Pengguna yang sah akan ter-logout, tetapi penyerang tidak bisa masuk.
Debat ini sudah berlangsung bertahun-tahun, dan jawabannya sudah jelas: httpOnly cookie untuk refresh token, selalu.
localStorage bisa diakses oleh JavaScript apa pun yang berjalan di halaman Anda. Jika Anda memiliki satu kerentanan XSS — dan pada skala besar, suatu saat Anda akan mengalaminya — penyerang bisa membaca token dan mengeksfiltrasinya. Selesai.
httpOnly cookie tidak bisa diakses oleh JavaScript. Titik. Kerentanan XSS masih bisa membuat request atas nama pengguna (karena cookie dikirim secara otomatis), tetapi penyerang tidak bisa mencuri token itu sendiri. Itu perbedaan yang bermakna.
// Mengatur cookie refresh token yang aman
function setRefreshTokenCookie(res: Response, token: string): void {
res.cookie("refresh_token", token, {
httpOnly: true, // Tidak bisa diakses via JavaScript
secure: true, // Hanya HTTPS
sameSite: "strict", // Tidak ada request lintas-situs
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 hari
path: "/api/auth", // Hanya dikirim ke endpoint autentikasi
});
}path: "/api/auth" adalah detail yang kebanyakan orang lewatkan. Secara default, cookie dikirim ke setiap endpoint di domain Anda. Refresh token Anda tidak perlu dikirim ke /api/users atau /api/products. Batasi path-nya, kurangi permukaan serangan.
Untuk access token, saya menyimpannya di memori (variabel JavaScript). Bukan localStorage, bukan sessionStorage, bukan cookie. Di memori. Mereka berumur pendek (15 menit), dan ketika halaman di-refresh, klien secara diam-diam mengakses endpoint refresh untuk mendapatkan yang baru. Ya, ini berarti request tambahan saat halaman dimuat. Itu sepadan.
Klien bukan teman Anda. Klien adalah orang asing yang masuk ke rumah Anda dan berkata "Saya diizinkan berada di sini." Anda tetap memeriksa identitasnya.
Setiap data yang datang dari luar server Anda — body request, parameter query, parameter URL, header — adalah input yang tidak dipercaya. Tidak masalah bahwa form React Anda memiliki validasi. Seseorang akan melewatinya dengan curl.
Zod adalah hal terbaik yang terjadi pada validasi input Node.js. Ia memberikan Anda validasi runtime dengan tipe TypeScript secara gratis:
import { z } from "zod";
const CreateUserSchema = z.object({
email: z
.string()
.email("Format email tidak valid")
.max(254, "Email terlalu panjang")
.transform((e) => e.toLowerCase().trim()),
password: z
.string()
.min(12, "Password harus minimal 12 karakter")
.max(128, "Password terlalu panjang") // Mencegah DoS bcrypt
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
"Password harus mengandung huruf besar, huruf kecil, dan angka"
),
name: z
.string()
.min(1, "Nama wajib diisi")
.max(100, "Nama terlalu panjang")
.regex(/^[\p{L}\p{M}\s'-]+$/u, "Nama mengandung karakter yang tidak valid"),
role: z.enum(["user", "editor"]).default("user"),
// Catatan: "admin" sengaja tidak menjadi opsi di sini.
// Penetapan role admin melalui endpoint terpisah yang memiliki privilege.
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
// Penggunaan di handler Express
app.post("/api/users", async (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: "Validasi gagal",
details: result.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
})),
});
}
// result.data sudah memiliki tipe lengkap sebagai CreateUserInput
const user = await createUser(result.data);
return res.status(201).json({ id: user.id, email: user.email });
});Beberapa detail yang relevan dengan keamanan:
max(128) pada password — bcrypt memiliki batas input 72 byte, dan beberapa implementasi langsung memotongnya secara diam-diam. Tetapi yang lebih penting, jika Anda mengizinkan password 10MB, bcrypt akan menghabiskan waktu yang signifikan untuk meng-hash-nya. Itu adalah vektor DoS.max(254) pada email — RFC 5321 membatasi alamat email hingga 254 karakter. Apa pun yang lebih panjang bukan email yang valid."role": "admin" dan berharap yang terbaik."Gunakan saja ORM" tidak melindungi Anda jika Anda menulis query mentah untuk performa. Dan semua orang akhirnya menulis query mentah untuk performa.
// RENTAN — penggabungan string
const query = `SELECT * FROM users WHERE email = '${email}'`;
// AMAN — query terparameterisasi
const query = `SELECT * FROM users WHERE email = $1`;
const result = await pool.query(query, [email]);Dengan Prisma, Anda sebagian besar aman — tetapi $queryRaw masih bisa menggigit:
// RENTAN — template literal dalam $queryRaw
const users = await prisma.$queryRaw`
SELECT * FROM users WHERE name LIKE '%${searchTerm}%'
`;
// AMAN — menggunakan Prisma.sql untuk parameterisasi
import { Prisma } from "@prisma/client";
const users = await prisma.$queryRaw(
Prisma.sql`SELECT * FROM users WHERE name LIKE ${`%${searchTerm}%`}`
);MongoDB tidak menggunakan SQL, tetapi tidak kebal terhadap injection. Jika Anda melewatkan input pengguna yang belum disanitasi sebagai objek query, masalah terjadi:
// RENTAN — jika req.body.username adalah { "$gt": "" }
// ini mengembalikan pengguna pertama dalam koleksi
const user = await db.collection("users").findOne({
username: req.body.username,
});
// AMAN — konversi secara eksplisit ke string
const user = await db.collection("users").findOne({
username: String(req.body.username),
});
// LEBIH BAIK — validasi dengan Zod terlebih dahulu
const LoginSchema = z.object({
username: z.string().min(1).max(50),
password: z.string().min(1).max(128),
});Perbaikannya sederhana: validasi tipe input sebelum mencapai driver basis data Anda. Jika username seharusnya berupa string, pastikan itu adalah string.
Jika API Anda menyajikan file atau membaca dari path yang menyertakan input pengguna, path traversal akan merusak minggu Anda:
import path from "path";
import { access, constants } from "fs/promises";
const ALLOWED_DIR = "/app/uploads";
async function resolveUserFilePath(userInput: string): Promise<string> {
// Normalisasi dan resolve ke path absolut
const resolved = path.resolve(ALLOWED_DIR, userInput);
// Kritis: verifikasi path yang di-resolve masih dalam direktori yang diizinkan
if (!resolved.startsWith(ALLOWED_DIR + path.sep)) {
throw new ApiError(403, "Akses ditolak");
}
// Verifikasi file benar-benar ada
await access(resolved, constants.R_OK);
return resolved;
}
// Tanpa pemeriksaan ini:
// GET /api/files?name=../../../etc/passwd
// di-resolve ke /etc/passwdPola path.resolve + startsWith adalah pendekatan yang benar. Jangan coba menghapus ../ secara manual — ada terlalu banyak trik encoding (..%2F, ..%252F, ....//) yang akan melewati regex Anda.
Tanpa rate limiting, API Anda adalah prasmanan sepuasnya untuk bot. Serangan brute force, credential stuffing, kehabisan resource — rate limiting adalah pertahanan pertama terhadap semuanya.
Token bucket: Anda memiliki ember yang menampung N token. Setiap request memerlukan satu token. Token terisi ulang pada kecepatan tetap. Jika ember kosong, request ditolak. Ini memungkinkan lonjakan — jika ember penuh, Anda bisa membuat N request secara instan.
Sliding window: Hitung request dalam jendela waktu yang bergerak. Lebih dapat diprediksi, lebih sulit untuk ditembus secara beruntun.
Saya menggunakan sliding window untuk sebagian besar hal karena perilakunya lebih mudah dipahami dan dijelaskan kepada tim:
import { Redis } from "ioredis";
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number;
}
async function slidingWindowRateLimit(
redis: Redis,
key: string,
limit: number,
windowMs: number
): Promise<RateLimitResult> {
const now = Date.now();
const windowStart = now - windowMs;
const multi = redis.multi();
// Hapus entri di luar jendela waktu
multi.zremrangebyscore(key, 0, windowStart);
// Hitung entri dalam jendela waktu
multi.zcard(key);
// Tambahkan request saat ini (kita akan menghapusnya jika melebihi batas)
multi.zadd(key, now.toString(), `${now}:${Math.random()}`);
// Atur kedaluwarsa pada key
multi.pexpire(key, windowMs);
const results = await multi.exec();
if (!results) {
throw new Error("Transaksi Redis gagal");
}
const count = results[1][1] as number;
if (count >= limit) {
// Melebihi batas — hapus entri yang baru kita tambahkan
await redis.zremrangebyscore(key, now, now);
return {
allowed: false,
remaining: 0,
resetAt: windowStart + windowMs,
};
}
return {
allowed: true,
remaining: limit - count - 1,
resetAt: now + windowMs,
};
}Satu rate limit global tidak cukup. Endpoint yang berbeda memiliki profil risiko yang berbeda:
interface RateLimitConfig {
window: number;
max: number;
}
const RATE_LIMITS: Record<string, RateLimitConfig> = {
// Endpoint autentikasi — batas ketat, target brute force
"POST:/api/auth/login": { window: 15 * 60 * 1000, max: 5 },
"POST:/api/auth/register": { window: 60 * 60 * 1000, max: 3 },
"POST:/api/auth/reset-password": { window: 60 * 60 * 1000, max: 3 },
// Pembacaan data — lebih longgar
"GET:/api/users": { window: 60 * 1000, max: 100 },
"GET:/api/products": { window: 60 * 1000, max: 200 },
// Penulisan data — moderat
"POST:/api/posts": { window: 60 * 1000, max: 10 },
"PUT:/api/posts": { window: 60 * 1000, max: 30 },
// Fallback global
"*": { window: 60 * 1000, max: 60 },
};
function getRateLimitKey(req: Request, config: RateLimitConfig): string {
const identifier = req.user?.id ?? getClientIp(req);
const endpoint = `${req.method}:${req.path}`;
return `ratelimit:${identifier}:${endpoint}`;
}Perhatikan: pengguna yang terautentikasi dibatasi berdasarkan user ID, bukan IP. Ini penting karena banyak pengguna yang sah berbagi IP (jaringan perusahaan, VPN, operator seluler). Jika Anda hanya membatasi berdasarkan IP, Anda akan memblokir seluruh kantor.
Selalu beritahu klien apa yang terjadi:
function setRateLimitHeaders(
res: Response,
result: RateLimitResult,
limit: number
): void {
res.set({
"X-RateLimit-Limit": limit.toString(),
"X-RateLimit-Remaining": result.remaining.toString(),
"X-RateLimit-Reset": Math.ceil(result.resetAt / 1000).toString(),
"Retry-After": result.allowed
? undefined
: Math.ceil((result.resetAt - Date.now()) / 1000).toString(),
});
if (!result.allowed) {
res.status(429).json({
error: "Terlalu banyak request",
retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
});
}
}CORS mungkin adalah mekanisme keamanan yang paling disalahpahami dalam pengembangan web. Setengah dari jawaban Stack Overflow tentang CORS adalah "cukup atur Access-Control-Allow-Origin: * dan itu berfungsi." Secara teknis itu benar. Itu juga cara Anda membuka API Anda untuk setiap situs jahat di internet.
CORS adalah mekanisme browser. Ia memberitahu browser apakah JavaScript dari Origin A diizinkan untuk membaca respons dari Origin B. Itu saja.
Apa yang CORS tidak lakukan:
Apa yang CORS lakukan:
// BERBAHAYA — mengizinkan situs web mana pun untuk membaca respons API Anda
app.use(cors({ origin: "*" }));
// JUGA BERBAHAYA — ini adalah pendekatan "dinamis" umum yang sebenarnya hanya * dengan langkah tambahan
app.use(
cors({
origin: (origin, callback) => {
callback(null, true); // Mengizinkan semuanya
},
})
);Masalah dengan * adalah ia membuat respons API Anda bisa dibaca oleh JavaScript mana pun di halaman mana pun. Jika API Anda mengembalikan data pengguna dan pengguna terautentikasi via cookie, situs web mana pun yang dikunjungi pengguna bisa membaca data tersebut.
Lebih buruk lagi: Access-Control-Allow-Origin: * tidak bisa dikombinasikan dengan credentials: true. Jadi jika Anda membutuhkan cookie (untuk autentikasi), Anda benar-benar tidak bisa menggunakan wildcard. Tetapi saya pernah melihat orang mencoba mengakalinya dengan memantulkan header Origin kembali — yang setara dengan * dengan credentials, yang terburuk dari keduanya.
import cors from "cors";
const ALLOWED_ORIGINS = new Set([
"https://yourapp.com",
"https://www.yourapp.com",
"https://admin.yourapp.com",
]);
if (process.env.NODE_ENV === "development") {
ALLOWED_ORIGINS.add("http://localhost:3000");
ALLOWED_ORIGINS.add("http://localhost:5173");
}
app.use(
cors({
origin: (origin, callback) => {
// Izinkan request tanpa origin (aplikasi mobile, curl, server-ke-server)
if (!origin) {
return callback(null, true);
}
if (ALLOWED_ORIGINS.has(origin)) {
return callback(null, origin);
}
callback(new Error(`Origin ${origin} tidak diizinkan oleh CORS`));
},
credentials: true, // Izinkan cookie
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
allowedHeaders: ["Content-Type", "Authorization"],
exposedHeaders: ["X-RateLimit-Limit", "X-RateLimit-Remaining"],
maxAge: 86400, // Cache preflight selama 24 jam
})
);Keputusan-keputusan kunci:
yourapp.com bisa cocok dengan evilyourapp.com jika regex Anda tidak di-anchor dengan benar.credentials: true karena kita menggunakan httpOnly cookie untuk refresh token.maxAge: 86400 — Request preflight (OPTIONS) menambah latensi. Memberitahu browser untuk meng-cache hasil CORS selama 24 jam mengurangi round trip yang tidak perlu.exposedHeaders — Secara default, browser hanya mengekspos segelintir header respons "sederhana" ke JavaScript. Jika Anda ingin klien membaca header rate limit Anda, Anda harus secara eksplisit mengeksposnya.Ketika sebuah request bukan "sederhana" (menggunakan header non-standar, metode non-standar, atau tipe konten non-standar), browser mengirim request OPTIONS terlebih dahulu untuk meminta izin. Ini adalah preflight.
Jika konfigurasi CORS Anda tidak menangani OPTIONS, request preflight akan gagal, dan request yang sebenarnya tidak akan pernah dikirim. Kebanyakan library CORS menangani ini secara otomatis, tetapi jika Anda menggunakan framework yang tidak, Anda perlu menanganinya:
// Penanganan preflight manual (kebanyakan framework melakukan ini untuk Anda)
app.options("*", (req, res) => {
res.set({
"Access-Control-Allow-Origin": getAllowedOrigin(req.headers.origin),
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
});
res.status(204).end();
});Header keamanan adalah peningkatan keamanan termurah yang bisa Anda lakukan. Mereka adalah header respons yang memberitahu browser untuk mengaktifkan fitur keamanan. Sebagian besar dari mereka hanya satu baris konfigurasi, dan mereka melindungi terhadap seluruh kelas serangan.
import helmet from "helmet";
// Satu baris. Ini adalah kemenangan keamanan tercepat di aplikasi Express mana pun.
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // Diperlukan untuk banyak solusi CSS-in-JS
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.yourapp.com"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
upgradeInsecureRequests: [],
},
},
hsts: {
maxAge: 31536000, // 1 tahun
includeSubDomains: true,
preload: true,
},
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
})
);Apa yang dilakukan setiap header:
Content-Security-Policy (CSP) — Header keamanan paling kuat. Ia memberitahu browser sumber mana yang diizinkan untuk script, style, gambar, font, dll. Jika penyerang menyisipkan tag <script> yang memuat dari evil.com, CSP memblokirnya. Ini adalah pertahanan tunggal paling efektif terhadap XSS.
Strict-Transport-Security (HSTS) — Memberitahu browser untuk selalu menggunakan HTTPS, bahkan jika pengguna mengetik http://. Direktif preload memungkinkan Anda mengirimkan domain Anda ke daftar HSTS bawaan browser, sehingga bahkan request pertama dipaksa menggunakan HTTPS.
X-Frame-Options — Mencegah situs Anda disematkan dalam iframe. Ini menghentikan serangan clickjacking di mana penyerang melapisi halaman Anda dengan elemen yang tidak terlihat. Helmet mengatur ini ke SAMEORIGIN secara default. Pengganti modernnya adalah frame-ancestors di CSP.
X-Content-Type-Options: nosniff — Mencegah browser menebak (sniffing) tipe MIME dari sebuah respons. Tanpa ini, jika Anda menyajikan file dengan Content-Type yang salah, browser mungkin mengeksekusinya sebagai JavaScript.
Referrer-Policy — Mengontrol berapa banyak informasi URL yang dikirim dalam header Referer. strict-origin-when-cross-origin mengirim URL lengkap untuk request same-origin tetapi hanya origin untuk request cross-origin. Ini mencegah bocornya parameter URL sensitif ke pihak ketiga.
Setelah deployment, periksa skor Anda di securityheaders.com. Targetkan rating A+. Hanya butuh sekitar lima menit konfigurasi untuk mencapainya.
Anda juga bisa memverifikasi header secara programatis:
import { describe, it, expect } from "vitest";
describe("Header keamanan", () => {
it("harus menyertakan semua header keamanan yang diperlukan", async () => {
const response = await fetch("https://api.yourapp.com/health");
expect(response.headers.get("strict-transport-security")).toBeTruthy();
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
expect(response.headers.get("x-frame-options")).toBe("SAMEORIGIN");
expect(response.headers.get("content-security-policy")).toBeTruthy();
expect(response.headers.get("referrer-policy")).toBeTruthy();
expect(response.headers.get("x-powered-by")).toBeNull(); // Helmet menghapus ini
});
});Pemeriksaan x-powered-by itu halus tapi penting. Express menetapkan X-Powered-By: Express secara default, memberitahu penyerang framework apa yang Anda gunakan. Helmet menghapusnya.
Yang satu ini seharusnya sudah jelas, tetapi saya masih melihatnya di pull request: API key, password basis data, dan secret JWT yang di-hardcode dalam file sumber. Atau di-commit dalam file .env yang tidak ada di .gitignore. Begitu masuk ke riwayat git, selamanya ada di sana, bahkan jika Anda menghapus file di commit berikutnya.
Jangan pernah commit secret ke git. Tidak di kode, tidak di .env, tidak di file config, tidak di file Docker Compose, tidak di komentar "hanya untuk testing".
Gunakan .env.example sebagai template. Ia mendokumentasikan variabel lingkungan apa yang diperlukan, tanpa mengandung nilai sebenarnya:
# .env.example — commit ini
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
JWT_SECRET=your-secret-here
REDIS_URL=redis://localhost:6379
SMTP_API_KEY=your-smtp-key
# .env — JANGAN PERNAH commit ini
# Tercantum di .gitignoreimport { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32, "JWT secret harus minimal 32 karakter"),
REDIS_URL: z.string().url(),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
PORT: z.coerce.number().default(3000),
CORS_ORIGINS: z.string().transform((s) => s.split(",")),
});
export type Env = z.infer<typeof envSchema>;
function validateEnv(): Env {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error("Variabel lingkungan tidak valid:");
console.error(result.error.format());
process.exit(1); // Jangan mulai dengan konfigurasi yang buruk
}
return result.data;
}
export const env = validateEnv();Untuk sistem produksi, gunakan secret manager yang tepat:
Polanya sama terlepas dari mana yang Anda gunakan: aplikasi mengambil secret saat startup dari secret manager, bukan dari variabel lingkungan.
interface SigningKey {
id: string;
secret: string;
createdAt: Date;
active: boolean; // Hanya kunci aktif yang menandatangani token baru
}
async function verifyWithRotation(token: string): Promise<TokenPayload> {
const keys = await getSigningKeys(); // Mengembalikan semua kunci yang valid
for (const key of keys) {
try {
return jwt.verify(token, key.secret, {
algorithms: ["HS256"],
}) as TokenPayload;
} catch {
continue; // Coba kunci berikutnya
}
}
throw new ApiError(401, "Token tidak valid");
}
function signToken(payload: Omit<TokenPayload, "iat" | "exp">): string {
const activeKey = getActiveSigningKey();
return jwt.sign(payload, activeKey.secret, {
algorithm: "HS256",
expiresIn: "15m",
keyid: activeKey.id, // Sertakan ID kunci di header
});
}OWASP API Security Top 10 adalah daftar standar industri untuk kerentanan API. Diperbarui secara berkala, dan setiap item dalam daftar adalah sesuatu yang pernah saya lihat di codebase nyata. Mari kita bahas satu per satu.
Kerentanan API paling umum. Pengguna sudah terautentikasi, tetapi API tidak memeriksa apakah mereka memiliki akses ke objek spesifik yang mereka minta.
// RENTAN — setiap pengguna terautentikasi bisa mengakses data pengguna mana pun
app.get("/api/users/:id", authenticate, async (req, res) => {
const user = await db.users.findById(req.params.id);
return res.json(user);
});
// DIPERBAIKI — verifikasi pengguna mengakses datanya sendiri (atau adalah admin)
app.get("/api/users/:id", authenticate, async (req, res) => {
if (req.user.id !== req.params.id && req.user.role !== "admin") {
return res.status(403).json({ error: "Akses ditolak" });
}
const user = await db.users.findById(req.params.id);
return res.json(user);
});Versi yang rentan ada di mana-mana. Ia melewati setiap pemeriksaan autentikasi — pengguna memiliki token yang valid — tetapi tidak memverifikasi apakah mereka berwenang untuk mengakses resource spesifik ini. Ubah ID di URL, dan Anda mendapatkan data orang lain.
Mekanisme login yang lemah, MFA yang hilang, token yang tidak pernah kedaluwarsa, password disimpan dalam plaintext. Ini mencakup lapisan autentikasi itu sendiri.
Perbaikannya adalah semua yang kita bahas di bagian autentikasi: persyaratan password yang kuat, bcrypt dengan rounds yang cukup, access token berumur pendek, rotasi refresh token, penguncian akun setelah percobaan gagal.
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 menit
async function handleLogin(email: string, password: string): Promise<AuthResult> {
const lockoutKey = `lockout:${email}`;
const attempts = await redis.get(lockoutKey);
if (attempts && parseInt(attempts) >= MAX_LOGIN_ATTEMPTS) {
const ttl = await redis.pttl(lockoutKey);
throw new ApiError(
429,
`Akun terkunci. Coba lagi dalam ${Math.ceil(ttl / 60000)} menit.`
);
}
const user = await db.users.findByEmail(email);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
// Tambah percobaan gagal
await redis.multi()
.incr(lockoutKey)
.pexpire(lockoutKey, LOCKOUT_DURATION)
.exec();
// Pesan error sama untuk kedua kasus — jangan ungkapkan apakah email ada
throw new ApiError(401, "Email atau password tidak valid");
}
// Reset percobaan gagal pada login berhasil
await redis.del(lockoutKey);
return generateTokens(user);
}Komentar tentang "pesan error sama" itu penting. Jika API Anda mengembalikan "pengguna tidak ditemukan" untuk email yang tidak valid dan "password salah" untuk email valid dengan password yang salah, Anda memberitahu penyerang email mana yang ada di sistem Anda.
Mengembalikan lebih banyak data dari yang diperlukan, atau mengizinkan pengguna memodifikasi properti yang seharusnya tidak mereka modifikasi.
// RENTAN — mengembalikan seluruh objek pengguna, termasuk field internal
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
const user = await db.users.findById(req.params.id);
return res.json(user);
// Respons menyertakan: passwordHash, internalNotes, billingId, ...
});
// DIPERBAIKI — allowlist eksplisit untuk field yang dikembalikan
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
const user = await db.users.findById(req.params.id);
return res.json({
id: user.id,
name: user.name,
email: user.email,
avatar: user.avatar,
createdAt: user.createdAt,
});
});Jangan pernah mengembalikan seluruh objek basis data. Selalu pilih field yang ingin Anda ekspos. Ini berlaku untuk write juga — jangan spread seluruh body request ke query update Anda:
// RENTAN — mass assignment
app.put("/api/users/:id", authenticate, async (req, res) => {
await db.users.update(req.params.id, req.body);
// Penyerang mengirim: { "role": "admin", "verified": true }
});
// DIPERBAIKI — pilih field yang diizinkan
const UpdateUserSchema = z.object({
name: z.string().min(1).max(100).optional(),
avatar: z.string().url().optional(),
});
app.put("/api/users/:id", authenticate, async (req, res) => {
const data = UpdateUserSchema.parse(req.body);
await db.users.update(req.params.id, data);
});API Anda adalah resource. CPU, memori, bandwidth, koneksi basis data — semuanya terbatas. Tanpa batasan, satu klien bisa menghabiskan semuanya.
Ini melampaui rate limiting. Termasuk:
// Batasi ukuran body request
app.use(express.json({ limit: "1mb" }));
// Batasi kompleksitas query
const MAX_PAGE_SIZE = 100;
const DEFAULT_PAGE_SIZE = 20;
const PaginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce
.number()
.int()
.positive()
.max(MAX_PAGE_SIZE)
.default(DEFAULT_PAGE_SIZE),
});
// Batasi ukuran upload file
const upload = multer({
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
files: 1,
},
fileFilter: (req, file, cb) => {
const allowed = ["image/jpeg", "image/png", "image/webp"];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error("Tipe file tidak valid"));
}
},
});
// Timeout untuk request yang berjalan lama
app.use((req, res, next) => {
res.setTimeout(30000, () => {
res.status(408).json({ error: "Request timeout" });
});
next();
});Berbeda dari BOLA. Ini tentang mengakses fungsi (endpoint) yang seharusnya tidak Anda akses, bukan objek. Contoh klasik: pengguna biasa menemukan endpoint admin.
// Middleware yang memeriksa akses berbasis role
function requireRole(...allowedRoles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: "Tidak terautentikasi" });
}
if (!allowedRoles.includes(req.user.role)) {
// Catat percobaan — ini mungkin serangan
logger.warn("Percobaan akses tidak sah", {
userId: req.user.id,
role: req.user.role,
requiredRoles: allowedRoles,
endpoint: `${req.method} ${req.path}`,
ip: req.ip,
});
return res.status(403).json({ error: "Izin tidak mencukupi" });
}
next();
};
}
// Terapkan ke route
app.delete("/api/users/:id", authenticate, requireRole("admin"), deleteUser);
app.get("/api/admin/stats", authenticate, requireRole("admin"), getStats);
app.post("/api/posts", authenticate, requireRole("admin", "editor"), createPost);Jangan mengandalkan penyembunyian endpoint. "Keamanan melalui ketidakjelasan" bukan keamanan. Bahkan jika URL panel admin tidak ditautkan di mana pun, seseorang akan menemukan /api/admin/users dengan fuzzing.
Penyalahgunaan otomatis terhadap fungsionalitas bisnis yang sah. Misalnya: bot membeli item stok terbatas, pembuatan akun otomatis untuk spam, scraping harga produk.
Mitigasinya bersifat konteks-spesifik: CAPTCHA, device fingerprinting, analisis perilaku, autentikasi step-up untuk operasi sensitif. Tidak ada satu cuplikan kode yang cocok untuk semua.
Jika API Anda mengambil URL yang diberikan pengguna (webhook, URL foto profil, preview tautan), penyerang bisa membuat server Anda me-request resource internal:
import { URL } from "url";
import dns from "dns/promises";
import { isPrivateIP } from "./network-utils";
async function safeFetch(userProvidedUrl: string): Promise<Response> {
let parsed: URL;
try {
parsed = new URL(userProvidedUrl);
} catch {
throw new ApiError(400, "URL tidak valid");
}
// Hanya izinkan HTTP(S)
if (!["http:", "https:"].includes(parsed.protocol)) {
throw new ApiError(400, "Hanya URL HTTP(S) yang diizinkan");
}
// Resolve hostname dan periksa apakah itu IP privat
const addresses = await dns.resolve4(parsed.hostname);
for (const addr of addresses) {
if (isPrivateIP(addr)) {
throw new ApiError(400, "Alamat internal tidak diizinkan");
}
}
// Sekarang fetch dengan timeout dan batas ukuran
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(userProvidedUrl, {
signal: controller.signal,
redirect: "error", // Jangan ikuti redirect (bisa redirect ke IP internal)
});
return response;
} finally {
clearTimeout(timeout);
}
}Detail kunci: resolve DNS terlebih dahulu dan periksa IP sebelum membuat request. Blokir redirect — penyerang bisa meng-host URL yang redirect ke http://169.254.169.254/ (endpoint metadata AWS) untuk melewati pemeriksaan level URL Anda.
Credential default yang tidak diubah, metode HTTP yang tidak perlu diaktifkan, pesan error yang verbose di produksi, directory listing diaktifkan, CORS salah konfigurasi. Ini adalah kategori "Anda lupa mengunci pintu".
// Jangan bocorkan stack trace di produksi
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
logger.error("Error tidak tertangani", {
error: err.message,
stack: err.stack,
path: req.path,
method: req.method,
});
if (process.env.NODE_ENV === "production") {
// Pesan error generik — jangan ungkapkan internal
res.status(500).json({
error: "Internal server error",
requestId: req.id, // Sertakan request ID untuk debugging
});
} else {
// Di development, tampilkan error lengkap
res.status(500).json({
error: err.message,
stack: err.stack,
});
}
});
// Nonaktifkan metode HTTP yang tidak perlu
app.use((req, res, next) => {
const allowed = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
if (!allowed.includes(req.method)) {
return res.status(405).json({ error: "Metode tidak diizinkan" });
}
next();
});Anda men-deploy v2 API tetapi lupa menonaktifkan v1. Atau ada endpoint /debug/ yang berguna selama pengembangan dan masih berjalan di produksi. Atau server staging yang bisa diakses publik dengan data produksi.
Ini bukan perbaikan kode — ini disiplin operasi. Kelola daftar semua endpoint API, semua versi yang di-deploy, dan semua environment. Gunakan pemindaian otomatis untuk menemukan layanan yang terekspos. Matikan yang tidak Anda butuhkan.
API Anda mengkonsumsi API pihak ketiga. Apakah Anda memvalidasi responnya? Apa yang terjadi jika payload webhook dari Stripe sebenarnya dari penyerang?
import crypto from "crypto";
// Verifikasi signature webhook Stripe
function verifyStripeWebhook(
payload: string,
signature: string,
secret: string
): boolean {
const timestamp = signature.split(",").find((s) => s.startsWith("t="))?.slice(2);
const expectedSig = signature.split(",").find((s) => s.startsWith("v1="))?.slice(3);
if (!timestamp || !expectedSig) return false;
// Tolak timestamp lama (cegah serangan replay)
const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
if (age > 300) return false; // Toleransi 5 menit
const signedPayload = `${timestamp}.${payload}`;
const computedSig = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(computedSig),
Buffer.from(expectedSig)
);
}Selalu verifikasi signature pada webhook. Selalu validasi struktur respons API pihak ketiga. Selalu tetapkan timeout pada request keluar. Jangan pernah percaya data hanya karena datang dari "mitra terpercaya."
Ketika sesuatu berjalan salah — dan pasti akan — audit log adalah cara Anda mencari tahu apa yang terjadi. Tetapi logging adalah pedang bermata dua. Log terlalu sedikit dan Anda buta. Log terlalu banyak dan Anda menciptakan kewajiban privasi.
interface AuditLogEntry {
timestamp: string;
action: string; // "user.login", "post.delete", "admin.role_change"
actor: {
id: string;
ip: string;
userAgent: string;
};
target: {
type: string; // "user", "post", "setting"
id: string;
};
result: "success" | "failure";
metadata: Record<string, unknown>; // Konteks tambahan
requestId: string; // Untuk korelasi dengan log aplikasi
}
async function auditLog(entry: AuditLogEntry): Promise<void> {
// Tulis ke data store terpisah yang hanya-tambah
// Ini SEHARUSNYA BUKAN basis data yang sama yang digunakan aplikasi Anda
await auditDb.collection("audit_logs").insertOne({
...entry,
timestamp: new Date().toISOString(),
});
// Untuk aksi kritis, tulis juga ke log eksternal yang immutable
if (isCriticalAction(entry.action)) {
await externalLogger.send(entry);
}
}Catat event-event ini:
Jangan pernah mencatat:
sk_live_...abc)function sanitizeForLogging(data: Record<string, unknown>): Record<string, unknown> {
const sensitiveKeys = new Set([
"password",
"passwordHash",
"token",
"secret",
"apiKey",
"creditCard",
"ssn",
"authorization",
]);
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
if (sensitiveKeys.has(key.toLowerCase())) {
sanitized[key] = "[DIHAPUS]";
} else if (typeof value === "object" && value !== null) {
sanitized[key] = sanitizeForLogging(value as Record<string, unknown>);
} else {
sanitized[key] = value;
}
}
return sanitized;
}Jika penyerang mendapatkan akses ke sistem Anda, salah satu hal pertama yang akan mereka lakukan adalah memodifikasi log untuk menutupi jejak mereka. Logging anti-rusak membuat ini terdeteksi:
import crypto from "crypto";
let previousHash = "GENESIS"; // Hash awal dalam rantai
function createTamperEvidentEntry(entry: AuditLogEntry): AuditLogEntry & { hash: string } {
const content = JSON.stringify(entry) + previousHash;
const hash = crypto.createHash("sha256").update(content).digest("hex");
previousHash = hash;
return { ...entry, hash };
}
// Untuk memverifikasi integritas rantai:
function verifyLogChain(entries: Array<AuditLogEntry & { hash: string }>): boolean {
let expectedPreviousHash = "GENESIS";
for (const entry of entries) {
const { hash, ...rest } = entry;
const content = JSON.stringify(rest) + expectedPreviousHash;
const computedHash = crypto.createHash("sha256").update(content).digest("hex");
if (computedHash !== hash) {
return false; // Rantai rusak — log telah dirusak
}
expectedPreviousHash = hash;
}
return true;
}Ini adalah konsep yang sama dengan blockchain — hash setiap entri log bergantung pada entri sebelumnya. Jika seseorang memodifikasi atau menghapus entri, rantai terputus.
Kode Anda mungkin aman. Tetapi bagaimana dengan 847 paket npm di node_modules Anda? Masalah rantai pasokan itu nyata, dan semakin buruk dari tahun ke tahun.
# Jalankan ini di CI, gagalkan build pada kerentanan high/critical
npm audit --audit-level=high
# Perbaiki yang bisa diperbaiki secara otomatis
npm audit fix
# Lihat apa yang sebenarnya Anda tarik
npm ls --allTetapi npm audit memiliki keterbatasan. Ia hanya memeriksa basis data advisory npm, dan peringkat keparahannya tidak selalu akurat. Tambahkan tool tambahan:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
reviewers:
- "your-team"
labels:
- "dependencies"
# Kelompokkan update minor dan patch untuk mengurangi noise PR
groups:
production-dependencies:
patterns:
- "*"
update-types:
- "minor"
- "patch"Selalu commit package-lock.json Anda (atau pnpm-lock.yaml, atau yarn.lock). Lockfile menyematkan versi pasti dari setiap dependensi, termasuk yang transitif. Tanpanya, npm install mungkin menarik versi yang berbeda dari yang Anda uji — dan versi berbeda itu mungkin sudah dikompromikan.
# Di CI, gunakan ci daripada install — ia menghormati lockfile secara ketat
npm cinpm ci gagal jika lockfile tidak cocok dengan package.json, alih-alih memperbarui secara diam-diam. Ini menangkap kasus di mana seseorang memodifikasi package.json tetapi lupa memperbarui lockfile.
Sebelum menambahkan dependensi, tanyakan:
is-odd bergantung pada is-number yang bergantung pada kind-of. Itu tiga paket untuk melakukan sesuatu yang satu baris kode bisa lakukan.// Anda tidak butuh paket untuk ini:
const isEven = (n: number): boolean => n % 2 === 0;
// Atau ini:
const leftPad = (str: string, len: number, char = " "): string =>
str.padStart(len, char);
// Atau ini:
const isNil = (value: unknown): value is null | undefined =>
value === null || value === undefined;Ini adalah checklist sebenarnya yang saya gunakan sebelum setiap deployment produksi. Ini tidak lengkap — keamanan tidak pernah "selesai" — tetapi ia menangkap kesalahan yang paling penting.
| # | Pemeriksaan | Kriteria Lulus | Prioritas |
|---|---|---|---|
| 1 | Autentikasi | JWT diverifikasi dengan algoritma, issuer, dan audience eksplisit. Tidak ada alg: none. | Kritis |
| 2 | Kedaluwarsa token | Access token kedaluwarsa dalam 15 menit atau kurang. Refresh token dirotasi saat digunakan. | Kritis |
| 3 | Penyimpanan token | Refresh token di httpOnly secure cookie. Tidak ada token di localStorage. | Kritis |
| 4 | Otorisasi di setiap endpoint | Setiap endpoint akses data memeriksa izin level objek. BOLA diuji. | Kritis |
| 5 | Validasi input | Semua input pengguna divalidasi dengan Zod atau setara. Tidak ada req.body mentah dalam query. | Kritis |
| 6 | SQL/NoSQL injection | Semua query basis data menggunakan query terparameterisasi atau metode ORM. Tidak ada penggabungan string. | Kritis |
| 7 | Rate limiting | Endpoint autentikasi: 5/15menit. API umum: 60/menit. Header rate limit dikembalikan. | Tinggi |
| 8 | CORS | Allowlist origin eksplisit. Tidak ada wildcard dengan credentials. Preflight di-cache. | Tinggi |
| 9 | Header keamanan | CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy semuanya ada. | Tinggi |
| 10 | Penanganan error | Error produksi mengembalikan pesan generik. Tidak ada stack trace, tidak ada error SQL yang terekspos. | Tinggi |
| 11 | Secret | Tidak ada secret di kode atau riwayat git. .env di .gitignore. Divalidasi saat startup. | Kritis |
| 12 | Dependensi | npm audit bersih (tidak ada high/critical). Lockfile di-commit. npm ci di CI. | Tinggi |
| 13 | Hanya HTTPS | HSTS diaktifkan dengan preload. HTTP redirect ke HTTPS. Flag secure cookie disetel. | Kritis |
| 14 | Logging | Event autentikasi, akses ditolak, dan mutasi data dicatat. Tidak ada PII di log. | Sedang |
| 15 | Batas ukuran request | Body parser dibatasi (default 1MB). Upload file dibatasi. Paginasi query ditegakkan. | Sedang |
| 16 | Perlindungan SSRF | URL yang diberikan pengguna divalidasi. IP privat diblokir. Redirect dinonaktifkan atau divalidasi. | Sedang |
| 17 | Penguncian akun | Percobaan login gagal memicu penguncian setelah 5 kali. Penguncian dicatat. | Tinggi |
| 18 | Verifikasi webhook | Semua webhook masuk diverifikasi dengan signature. Perlindungan replay via timestamp. | Tinggi |
| 19 | Endpoint admin | Kontrol akses berbasis role pada semua route admin. Percobaan dicatat. | Kritis |
| 20 | Mass assignment | Endpoint update menggunakan schema Zod dengan field yang di-allowlist. Tidak ada spread body mentah. | Tinggi |
Saya menyimpan ini sebagai template issue GitHub. Sebelum menandai rilis, seseorang di tim harus memeriksa setiap baris dan menandatangani. Tidak glamor, tetapi berhasil.
Keamanan bukan fitur yang Anda tambahkan di akhir. Bukan sprint yang Anda lakukan setahun sekali. Ini adalah cara berpikir tentang setiap baris kode yang Anda tulis.
Ketika Anda menulis endpoint, pikirkan: "Bagaimana jika seseorang mengirim data yang tidak saya harapkan?" Ketika Anda menambahkan parameter, pikirkan: "Bagaimana jika seseorang mengubah ini ke ID orang lain?" Ketika Anda menambahkan dependensi, pikirkan: "Apa yang terjadi jika paket ini dikompromikan Selasa depan?"
Anda tidak akan menangkap semuanya. Tidak ada yang bisa. Tetapi menjalankan checklist ini — secara metodis, sebelum setiap deployment — menangkap hal-hal yang paling penting. Kemenangan mudah. Lubang yang jelas. Kesalahan yang mengubah hari buruk menjadi kebocoran data.
Bangun kebiasaannya. Jalankan checklist-nya. Kirim dengan percaya diri.