API Security Best Practices: वो Checklist जो मैं हर Project पर चलाता हूं
Authentication, authorization, input validation, rate limiting, CORS, secrets management, और OWASP API Top 10। हर production deployment से पहले मैं क्या check करता हूं।
मैंने ऐसे APIs ship किए हैं जो पूरी तरह खुले थे। दुर्भावना से नहीं, आलस से नहीं — मुझे बस वो नहीं पता था जो मुझे नहीं पता था। एक endpoint जो user object में हर field return करता था, hashed passwords सहित। एक rate limiter जो सिर्फ IP addresses check करता था, जिसका मतलब proxy के पीछे कोई भी API को hammer कर सकता था। एक JWT implementation जहां मैं iss claim verify करना भूल गया, तो एक बिल्कुल अलग service के tokens भी ठीक काम कर जाते थे।
इनमें से हर गलती production में पहुंची। हर एक पकड़ी गई — कुछ मेरे द्वारा, कुछ users द्वारा, एक security researcher द्वारा जो इतने दयालु थे कि Twitter पर post करने की बजाय मुझे email किया।
यह post वो checklist है जो मैंने उन गलतियों से बनाई। मैं हर production deployment से पहले इसे चलाता हूं। इसलिए नहीं कि मैं paranoid हूं, बल्कि इसलिए कि मैंने सीखा है कि security bugs सबसे ज़्यादा नुकसान करते हैं। एक टूटा button users को irritate करता है। एक टूटा auth flow उनका data leak करता है।
Authentication vs Authorization#
ये दो शब्द meetings में, docs में, code comments में भी interchangeably use होते हैं। ये एक ही चीज़ नहीं हैं।
Authentication जवाब देता है: "तुम कौन हो?" यह login step है। Username और password, OAuth flow, magic link — जो कुछ भी आपकी identity prove करे।
Authorization जवाब देता है: "तुम्हें क्या करने की permission है?" यह permission step है। क्या यह user इस resource को delete कर सकता है? क्या वे इस admin endpoint को access कर सकते हैं? क्या वे दूसरे user का data पढ़ सकते हैं?
Production APIs में मैंने सबसे common security bug देखा है वो broken login flow नहीं है। वो एक missing authorization check है। User authenticated है — उनके पास valid token है — लेकिन API कभी check नहीं करता कि वे जो action request कर रहे हैं उसकी permission उन्हें है या नहीं।
JWT: Anatomy और वो गलतियां जो Matter करती हैं#
JWTs हर जगह हैं। वे हर जगह गलत भी समझे जाते हैं। एक JWT के तीन parts होते हैं, dots से अलग:
header.payload.signature
Header बताता है कौन सा algorithm use हुआ। Payload में claims होते हैं (user ID, roles, expiration)। Signature prove करता है कि किसी ने पहले दो parts के साथ कोई छेड़छाड़ नहीं की।
यहां Node.js में proper JWT verification है:
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"], // कभी "none" allow न करें
issuer: "api.yourapp.com",
audience: "yourapp.com",
clockTolerance: 30, // clock skew के लिए 30 seconds leeway
}) 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");
}
}कुछ चीज़ें ध्यान दें:
-
algorithms: ["HS256"]— यह critical है। अगर आप algorithm specify नहीं करते, तो attacker header में"alg": "none"वाला token भेज सकता है और verification पूरी तरह skip कर सकता है। यहalg: noneattack है, और इसने real production systems को affect किया है। -
issuerऔरaudience— इनके बिना, Service A के लिए बना token Service B पर काम कर जाता है। अगर आप multiple services चलाते हैं जो same secret share करती हैं (जो आपको नहीं करना चाहिए, लेकिन लोग करते हैं), तो cross-service token abuse ऐसे ही होता है। -
Specific error handling — हर failure के लिए
"invalid token"return न करें। Expired और invalid में distinguish करने से client को पता चलता है कि refresh करना है या re-authenticate।
Refresh Token Rotation#
Access tokens short-lived होने चाहिए — 15 minutes standard है। लेकिन आप नहीं चाहते कि users हर 15 minutes में अपना password दोबारा enter करें। यहीं refresh tokens काम आते हैं।
वो pattern जो actually production में काम करता है:
import { randomBytes } from "crypto";
import { redis } from "./redis";
interface RefreshTokenData {
userId: string;
family: string; // Rotation detection के लिए Token family
createdAt: number;
}
async function rotateRefreshToken(
oldRefreshToken: string
): Promise<{ accessToken: string; refreshToken: string }> {
const tokenData = await redis.get(`refresh:${oldRefreshToken}`);
if (!tokenData) {
// Token नहीं मिला — या तो expire हो गया या पहले ही use हो चुका।
// अगर पहले ही use हो चुका, तो यह potential replay attack है।
// पूरी token family invalidate करो।
const parsed = decodeRefreshToken(oldRefreshToken);
if (parsed?.family) {
await invalidateTokenFamily(parsed.family);
}
throw new ApiError(401, "Invalid refresh token");
}
const data: RefreshTokenData = JSON.parse(tokenData);
// पुराना token तुरंत delete करो — single use only
await redis.del(`refresh:${oldRefreshToken}`);
// नए tokens generate करो
const newRefreshToken = randomBytes(64).toString("hex");
const newAccessToken = generateAccessToken(data.userId);
// नया refresh token same family के साथ store करो
await redis.setex(
`refresh:${newRefreshToken}`,
60 * 60 * 24 * 30, // 30 days
JSON.stringify({
userId: data.userId,
family: data.family,
createdAt: Date.now(),
})
);
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
async function invalidateTokenFamily(family: string): Promise<void> {
// इस family के सभी tokens scan करो और delete करो।
// यह nuclear option है — अगर कोई refresh token replay करता है,
// तो हम family का हर token kill कर देते हैं, re-authentication force करते हैं।
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);
}
}
}
}Token family concept ही इसे secure बनाता है। हर refresh token एक family से belong करता है (login पर create होती है)। जब आप rotate करते हैं, तो नया token family inherit करता है। अगर attacker पुराना refresh token replay करता है, तो आप reuse detect करते हैं और पूरी family kill कर देते हैं। Legitimate user logout हो जाता है, लेकिन attacker अंदर नहीं आ पाता।
Token Storage: httpOnly Cookie vs localStorage बहस#
यह बहस सालों से चल रही है, और जवाब clear है: refresh tokens के लिए हमेशा httpOnly cookies।
localStorage आपके page पर चल रहे किसी भी JavaScript को accessible है। अगर आपके पास एक भी XSS vulnerability है — और scale पर, आखिरकार होगी — attacker token पढ़ सकता है और exfiltrate कर सकता है। Game over।
httpOnly cookies JavaScript को accessible नहीं हैं। बिल्कुल नहीं। एक XSS vulnerability अभी भी user की behalf पर requests कर सकती है (क्योंकि cookies automatically भेजी जाती हैं), लेकिन attacker token चुरा नहीं सकता। यह एक meaningful difference है।
// एक secure refresh token cookie set करना
function setRefreshTokenCookie(res: Response, token: string): void {
res.cookie("refresh_token", token, {
httpOnly: true, // JavaScript से accessible नहीं
secure: true, // सिर्फ HTTPS
sameSite: "strict", // कोई cross-site requests नहीं
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
path: "/api/auth", // सिर्फ auth endpoints को भेजा जाए
});
}path: "/api/auth" वो detail है जो ज़्यादातर लोग miss कर देते हैं। Default में, cookies आपके domain के हर endpoint को भेजी जाती हैं। आपके refresh token को /api/users या /api/products पर जाने की ज़रूरत नहीं। Path restrict करो, attack surface कम करो।
Access tokens के लिए, मैं उन्हें memory में रखता हूं (एक JavaScript variable)। localStorage नहीं, sessionStorage नहीं, cookie नहीं। Memory में। वे short-lived हैं (15 minutes), और जब page refresh होता है, client silently refresh endpoint hit करता है नया token लेने के लिए। हां, इसका मतलब page load पर एक extra request। यह worth it है।
Input Validation: कभी Client पर Trust न करें#
Client आपका दोस्त नहीं है। Client एक अजनबी है जो आपके घर में आया और बोला "मुझे यहां होने की permission है।" आप फिर भी उनकी ID check करते हैं।
आपके server के बाहर से आने वाला हर data — request body, query parameters, URL params, headers — untrusted input है। इससे कोई फर्क नहीं पड़ता कि आपके React form में validation है। कोई न कोई curl से इसे bypass कर देगा।
Zod for Type-Safe Validation#
Zod Node.js input validation के लिए सबसे अच्छी चीज़ है। यह आपको runtime validation के साथ TypeScript types free में देता है:
import { z } from "zod";
const CreateUserSchema = z.object({
email: z
.string()
.email("Invalid email format")
.max(254, "Email too long")
.transform((e) => e.toLowerCase().trim()),
password: z
.string()
.min(12, "Password must be at least 12 characters")
.max(128, "Password too long") // bcrypt DoS रोकें
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
"Password must contain uppercase, lowercase, and a number"
),
name: z
.string()
.min(1, "Name is required")
.max(100, "Name too long")
.regex(/^[\p{L}\p{M}\s'-]+$/u, "Name contains invalid characters"),
role: z.enum(["user", "editor"]).default("user"),
// Note: "admin" intentionally यहां option नहीं है।
// Admin role assignment एक separate, privileged endpoint से होता है।
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
// Express handler में usage
app.post("/api/users", async (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: "Validation failed",
details: result.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
})),
});
}
// result.data पूरी तरह typed है CreateUserInput के रूप में
const user = await createUser(result.data);
return res.status(201).json({ id: user.id, email: user.email });
});कुछ security-relevant details:
- Password पर
max(128)— bcrypt की 72-byte input limit है, और कुछ implementations silently truncate करते हैं। लेकिन ज़्यादा important, अगर आप 10MB password allow करते हैं, bcrypt इसे hash करने में काफी time लगाएगा। यह एक DoS vector है। - Email पर
max(254)— RFC 5321 email addresses को 254 characters तक limit करता है। इससे लंबा valid email नहीं है। - Role के लिए Enum, admin के बिना — Mass assignment सबसे पुरानी API vulnerabilities में से एक है। अगर आप request body से role validate किए बिना accept करते हैं, कोई
"role": "admin"भेजेगा और hope करेगा।
SQL Injection Solved नहीं है#
"बस ORM use करो" आपकी protection नहीं करता अगर आप performance के लिए raw queries लिखते हैं। और हर कोई performance के लिए आखिरकार raw queries लिखता है।
// VULNERABLE — string concatenation
const query = `SELECT * FROM users WHERE email = '${email}'`;
// SAFE — parameterized query
const query = `SELECT * FROM users WHERE email = $1`;
const result = await pool.query(query, [email]);Prisma के साथ, आप mostly safe हैं — लेकिन $queryRaw अभी भी काट सकता है:
// VULNERABLE — $queryRaw में template literal
const users = await prisma.$queryRaw`
SELECT * FROM users WHERE name LIKE '%${searchTerm}%'
`;
// SAFE — parameterization के लिए Prisma.sql use करना
import { Prisma } from "@prisma/client";
const users = await prisma.$queryRaw(
Prisma.sql`SELECT * FROM users WHERE name LIKE ${`%${searchTerm}%`}`
);NoSQL Injection#
MongoDB SQL use नहीं करता, लेकिन injection से immune नहीं है। अगर आप unsanitized user input query object के रूप में pass करते हैं, तो चीज़ें गलत हो जाती हैं:
// VULNERABLE — अगर req.body.username है { "$gt": "" }
// तो यह collection का पहला user return करता है
const user = await db.collection("users").findOne({
username: req.body.username,
});
// SAFE — explicitly string में coerce करें
const user = await db.collection("users").findOne({
username: String(req.body.username),
});
// BETTER — पहले Zod से validate करें
const LoginSchema = z.object({
username: z.string().min(1).max(50),
password: z.string().min(1).max(128),
});Fix simple है: database driver तक पहुंचने से पहले input types validate करें। अगर username string होना चाहिए, assert करें कि वो string है।
Path Traversal#
अगर आपकी API files serve करती है या ऐसे path से पढ़ती है जिसमें user input शामिल है, path traversal आपका हफ्ता बर्बाद कर देगा:
import path from "path";
import { access, constants } from "fs/promises";
const ALLOWED_DIR = "/app/uploads";
async function resolveUserFilePath(userInput: string): Promise<string> {
// Absolute path में normalize और resolve करें
const resolved = path.resolve(ALLOWED_DIR, userInput);
// Critical: verify करें resolved path अभी भी allowed directory में है
if (!resolved.startsWith(ALLOWED_DIR + path.sep)) {
throw new ApiError(403, "Access denied");
}
// Verify करें file actually exist करती है
await access(resolved, constants.R_OK);
return resolved;
}
// इस check के बिना:
// GET /api/files?name=../../../etc/passwd
// resolve होता है /etc/passwd मेंpath.resolve + startsWith pattern सही approach है। Manually ../ strip करने की कोशिश न करें — बहुत सारे encoding tricks (..%2F, ..%252F, ....//) हैं जो आपके regex को bypass कर देंगे।
Rate Limiting#
Rate limiting के बिना, आपकी API bots के लिए all-you-can-eat buffet है। Brute force attacks, credential stuffing, resource exhaustion — rate limiting इन सबके खिलाफ पहला defense है।
Token Bucket vs Sliding Window#
Token bucket: आपके पास एक bucket है जिसमें N tokens हैं। हर request की cost एक token है। Tokens एक fixed rate पर refill होते हैं। अगर bucket खाली है, request reject हो जाती है। यह bursts allow करता है — अगर bucket भरा है, तो आप instantly N requests कर सकते हैं।
Sliding window: एक moving time window में requests count करें। ज़्यादा predictable, burst करना मुश्किल।
मैं ज़्यादातर चीज़ों के लिए sliding window use करता हूं क्योंकि behavior reason करना और team को explain करना आसान है:
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();
// Window के बाहर की entries remove करें
multi.zremrangebyscore(key, 0, windowStart);
// Window में entries count करें
multi.zcard(key);
// Current request add करें (over limit होने पर remove करेंगे)
multi.zadd(key, now.toString(), `${now}:${Math.random()}`);
// Key पर expiry set करें
multi.pexpire(key, windowMs);
const results = await multi.exec();
if (!results) {
throw new Error("Redis transaction failed");
}
const count = results[1][1] as number;
if (count >= limit) {
// Over limit — अभी जो entry add की वो remove करें
await redis.zremrangebyscore(key, now, now);
return {
allowed: false,
remaining: 0,
resetAt: windowStart + windowMs,
};
}
return {
allowed: true,
remaining: limit - count - 1,
resetAt: now + windowMs,
};
}Layered Rate Limits#
एक global rate limit काफी नहीं है। अलग-अलग endpoints के अलग-अलग risk profiles हैं:
interface RateLimitConfig {
window: number;
max: number;
}
const RATE_LIMITS: Record<string, RateLimitConfig> = {
// Auth endpoints — tight limits, brute force target
"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 },
// Data reads — ज़्यादा generous
"GET:/api/users": { window: 60 * 1000, max: 100 },
"GET:/api/products": { window: 60 * 1000, max: 200 },
// Data writes — moderate
"POST:/api/posts": { window: 60 * 1000, max: 10 },
"PUT:/api/posts": { window: 60 * 1000, max: 30 },
// Global fallback
"*": { 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}`;
}ध्यान दें: authenticated users user ID से rate-limited होते हैं, IP से नहीं। यह important है क्योंकि बहुत से legitimate users IPs share करते हैं (corporate networks, VPNs, mobile carriers)। अगर आप सिर्फ IP से limit करते हैं, पूरे offices block हो जाएंगे।
Rate Limit Headers#
Client को हमेशा बताएं कि क्या हो रहा है:
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: "Too many requests",
retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
});
}
}CORS Configuration#
CORS शायद web development में सबसे गलत समझा जाने वाला security mechanism है। Stack Overflow के आधे जवाब CORS के बारे में हैं "बस Access-Control-Allow-Origin: * set करो और काम करेगा।" यह technically सही है। यह ऐसे भी है कि आप अपनी API internet की हर malicious site के लिए खोल देते हैं।
CORS Actually क्या करता है (और क्या नहीं)#
CORS एक browser mechanism है। यह browser को बताता है कि Origin A का JavaScript Origin B का response पढ़ सकता है या नहीं। बस इतना ही।
CORS क्या नहीं करता:
- यह आपकी API को curl, Postman, या server-to-server requests से protect नहीं करता
- यह requests authenticate नहीं करता
- यह कुछ encrypt नहीं करता
- यह अकेले CSRF नहीं रोकता (हालांकि दूसरे mechanisms के साथ help करता है)
CORS क्या करता है:
- malicious-website.com को your-api.com पर fetch requests बनाने और user के browser में response पढ़ने से रोकता है
- Attacker के JavaScript को victim के authenticated session से data exfiltrate करने से रोकता है
Wildcard Trap#
// DANGEROUS — किसी भी website को आपके API responses पढ़ने देता है
app.use(cors({ origin: "*" }));
// ALSO DANGEROUS — यह common "dynamic" approach बस extra steps वाला * है
app.use(
cors({
origin: (origin, callback) => {
callback(null, true); // सब कुछ allow करता है
},
})
);* की समस्या यह है कि यह आपके API responses को किसी भी page पर किसी भी JavaScript को readable बना देता है। अगर आपकी API user data return करती है और user cookies से authenticated है, user जो भी website visit करे वो data पढ़ सकती है।
और भी बुरा: Access-Control-Allow-Origin: * credentials: true के साथ combine नहीं हो सकता। तो अगर आपको cookies चाहिए (auth के लिए), आप literally wildcard use नहीं कर सकते। लेकिन मैंने लोगों को Origin header reflect करके workaround करते देखा है — जो credentials के साथ * के बराबर है, दोनों दुनिया का सबसे बुरा।
सही Configuration#
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) => {
// बिना origin वाली requests allow करें (mobile apps, curl, server-to-server)
if (!origin) {
return callback(null, true);
}
if (ALLOWED_ORIGINS.has(origin)) {
return callback(null, origin);
}
callback(new Error(`Origin ${origin} not allowed by CORS`));
},
credentials: true, // Cookies allow करें
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
allowedHeaders: ["Content-Type", "Authorization"],
exposedHeaders: ["X-RateLimit-Limit", "X-RateLimit-Remaining"],
maxAge: 86400, // 24 hours के लिए preflight cache करें
})
);Key decisions:
- Explicit origin set, regex नहीं। Regexes tricky हैं —
yourapp.comevilyourapp.comको भी match कर सकता है अगर regex properly anchored नहीं है। credentials: trueक्योंकि हम refresh tokens के लिए httpOnly cookies use करते हैं।maxAge: 86400— Preflight requests (OPTIONS) latency add करते हैं। Browser को 24 hours के लिए CORS result cache करने बोलना unnecessary round trips कम करता है।exposedHeaders— Default में, browser JavaScript को सिर्फ मुट्ठी भर "simple" response headers expose करता है। अगर आप चाहते हैं client आपके rate limit headers पढ़े, explicitly expose करना होगा।
Preflight Requests#
जब request "simple" नहीं होती (non-standard header, non-standard method, या non-standard content type use करती है), browser पहले permission मांगने के लिए OPTIONS request भेजता है। यह preflight है।
अगर आपकी CORS configuration OPTIONS handle नहीं करती, preflight requests fail होंगी, और actual request कभी नहीं भेजी जाएगी। ज़्यादातर CORS libraries यह automatically handle करती हैं, लेकिन अगर आप ऐसा framework use करते हैं जो नहीं करता, आपको handle करना होगा:
// Manual preflight handling (ज़्यादातर frameworks यह आपके लिए करते हैं)
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();
});Security Headers#
Security headers सबसे सस्ता security improvement हैं जो आप कर सकते हैं। ये response headers हैं जो browser को security features enable करने बोलते हैं। इनमें से ज़्यादातर single line configuration हैं, और ये attacks की पूरी classes से protect करते हैं।
Headers जो Matter करते हैं#
import helmet from "helmet";
// एक line। किसी भी Express app में सबसे तेज़ security win।
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // बहुत से CSS-in-JS solutions के लिए ज़रूरी
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.yourapp.com"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
upgradeInsecureRequests: [],
},
},
hsts: {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true,
},
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
})
);हर header क्या करता है:
Content-Security-Policy (CSP) — सबसे powerful security header। यह browser को बताता है scripts, styles, images, fonts आदि के लिए कौन से sources allowed हैं। अगर attacker <script> tag inject करता है जो evil.com से load होता है, CSP block कर देता है। XSS के खिलाफ single most effective defense।
Strict-Transport-Security (HSTS) — Browser को बताता है हमेशा HTTPS use करें, चाहे user http:// type करे। preload directive आपको domain browser की built-in HSTS list में submit करने देता है, ताकि पहली request भी HTTPS पर force हो।
X-Frame-Options — आपकी site को iframe में embed होने से रोकता है। यह clickjacking attacks रोकता है जहां attacker invisible elements से आपके page को overlay करता है। Helmet इसे default में SAMEORIGIN set करता है। Modern replacement CSP में frame-ancestors है।
X-Content-Type-Options: nosniff — Browser को response का MIME type guess (sniff) करने से रोकता है। इसके बिना, अगर आप गलत Content-Type वाली file serve करते हैं, browser उसे JavaScript की तरह execute कर सकता है।
Referrer-Policy — Control करता है Referer header में कितनी URL information भेजी जाए। strict-origin-when-cross-origin same-origin requests के लिए full URL भेजता है लेकिन cross-origin के लिए सिर्फ origin। Sensitive URL parameters third parties को leak होने से रोकता है।
अपने Headers Test करें#
Deploy करने के बाद, securityheaders.com पर score check करें। A+ rating का लक्ष्य रखें। वहां पहुंचने में करीब पांच मिनट configuration लगती है।
Headers programmatically भी verify कर सकते हैं:
import { describe, it, expect } from "vitest";
describe("Security headers", () => {
it("should include all required security headers", 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 इसे remove करता है
});
});x-powered-by check subtle लेकिन important है। Express default में X-Powered-By: Express set करता है, attackers को बताता है exactly कौन सा framework use हो रहा है। Helmet इसे remove करता है।
Secrets Management#
यह obvious होना चाहिए, लेकिन मैं अभी भी pull requests में देखता हूं: API keys, database passwords, और JWT secrets source files में hardcoded। या .env files commit किए जो .gitignore में नहीं थीं। एक बार git history में आया तो हमेशा वहां है, चाहे अगली commit में file delete कर दें।
नियम#
-
कभी secrets git में commit न करें। Code में नहीं,
.envमें नहीं, config files में नहीं, Docker Compose files में नहीं, "just for testing" comments में नहीं। -
.env.exampletemplate के रूप में use करें। यह document करता है कौन से environment variables ज़रूरी हैं, बिना actual values:
# .env.example — इसे commit करें
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 — इसे कभी commit न करें
# .gitignore में listed- Startup पर environment variables validate करें। Database URL वाले endpoint पर request आने तक wait न करें। Fail fast:
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32, "JWT secret must be at least 32 characters"),
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("Invalid environment variables:");
console.error(result.error.format());
process.exit(1); // खराब config से start न करें
}
return result.data;
}
export const env = validateEnv();- Production में secret manager use करें। Environment variables simple setups के लिए काम करती हैं, लेकिन limitations हैं: process listings में visible, memory में persist, error logs से leak हो सकती हैं।
Production systems के लिए, proper secret manager use करें:
- AWS Secrets Manager या SSM Parameter Store
- HashiCorp Vault
- Google Secret Manager
- Azure Key Vault
- Doppler (अगर सब clouds पर काम करे)
Pattern वही है चाहे कोई भी use करें: application startup पर secret manager से secrets fetch करती है, environment variables से नहीं।
- Secrets regularly rotate करें। अगर दो साल से same JWT secret use कर रहे हैं, rotate करने का time है। Key rotation implement करें: एक साथ multiple valid signing keys support करें, नए tokens नई key से sign करें, पुरानी और नई दोनों से verify करें, सभी existing tokens expire होने के बाद पुरानी key retire करें।
interface SigningKey {
id: string;
secret: string;
createdAt: Date;
active: boolean; // सिर्फ active key नए tokens sign करती है
}
async function verifyWithRotation(token: string): Promise<TokenPayload> {
const keys = await getSigningKeys(); // सभी valid keys return करता है
for (const key of keys) {
try {
return jwt.verify(token, key.secret, {
algorithms: ["HS256"],
}) as TokenPayload;
} catch {
continue; // अगली key try करें
}
}
throw new ApiError(401, "Invalid token");
}
function signToken(payload: Omit<TokenPayload, "iat" | "exp">): string {
const activeKey = getActiveSigningKey();
return jwt.sign(payload, activeKey.secret, {
algorithm: "HS256",
expiresIn: "15m",
keyid: activeKey.id, // Header में key ID include करें
});
}OWASP API Security Top 10#
OWASP API Security Top 10 API vulnerabilities की industry standard list है। यह periodically update होती है, और list पर हर item ऐसा है जो मैंने real codebases में देखा है। चलिए हर एक देखते हैं।
API1: Broken Object Level Authorization (BOLA)#
सबसे common API vulnerability। User authenticated है, लेकिन API check नहीं करता कि जो specific object वे request कर रहे हैं उसका access है या नहीं।
// VULNERABLE — कोई भी authenticated user किसी का भी data access कर सकता है
app.get("/api/users/:id", authenticate, async (req, res) => {
const user = await db.users.findById(req.params.id);
return res.json(user);
});
// FIXED — verify करें user अपना ही data access कर रहा (या 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: "Access denied" });
}
const user = await db.users.findById(req.params.id);
return res.json(user);
});Vulnerable version हर जगह है। यह हर auth check pass करता है — user के पास valid token है — लेकिन verify नहीं करता कि वे इस specific resource के authorized हैं। URL में ID बदलो, किसी और का data मिल जाता है।
API2: Broken Authentication#
कमज़ोर login mechanisms, missing MFA, tokens जो कभी expire नहीं होते, plaintext में stored passwords। यह authentication layer को cover करता है।
Fix वो सब है जो हमने authentication section में discuss किया: strong password requirements, sufficient rounds के साथ bcrypt, short-lived access tokens, refresh token rotation, failed attempts के बाद account lockout।
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
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,
`Account locked. Try again in ${Math.ceil(ttl / 60000)} minutes.`
);
}
const user = await db.users.findByEmail(email);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
// Failed attempts increment करें
await redis.multi()
.incr(lockoutKey)
.pexpire(lockoutKey, LOCKOUT_DURATION)
.exec();
// दोनों cases में same error — reveal न करें email exist करता है
throw new ApiError(401, "Invalid email or password");
}
// Successful login पर failed attempts reset करें
await redis.del(lockoutKey);
return generateTokens(user);
}"Same error message" comment important है। अगर API invalid emails के लिए "user not found" और valid emails with wrong passwords के लिए "wrong password" return करती है, आप attacker को बता रहे हैं कौन से emails system में exist करते हैं।
API3: Broken Object Property Level Authorization#
ज़रूरत से ज़्यादा data return करना, या users को ऐसी properties modify करने देना जो नहीं करनी चाहिए।
// VULNERABLE — पूरा user object return, internal fields सहित
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
const user = await db.users.findById(req.params.id);
return res.json(user);
// Response में: passwordHash, internalNotes, billingId, ...
});
// FIXED — returned fields की explicit allowlist
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,
});
});कभी पूरे database objects return न करें। हमेशा expose करने वाले fields pick करें। Writes पर भी apply होता है — पूरी request body update query में spread न करें:
// VULNERABLE — mass assignment
app.put("/api/users/:id", authenticate, async (req, res) => {
await db.users.update(req.params.id, req.body);
// Attacker भेजता है: { "role": "admin", "verified": true }
});
// FIXED — allowed fields pick करें
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);
});API4: Unrestricted Resource Consumption#
आपकी API एक resource है। CPU, memory, bandwidth, database connections — सब finite हैं। Limits के बिना, single client सब exhaust कर सकता है।
यह rate limiting से आगे जाता है। इसमें शामिल है:
// Request body size limit
app.use(express.json({ limit: "1mb" }));
// Query complexity limit
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),
});
// File upload size limit
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("Invalid file type"));
}
},
});
// Long-running requests timeout
app.use((req, res, next) => {
res.setTimeout(30000, () => {
res.status(408).json({ error: "Request timeout" });
});
next();
});API5: Broken Function Level Authorization#
BOLA से अलग। यह उन functions (endpoints) को access करने के बारे में है जिनका access नहीं होना चाहिए, objects नहीं। Classic example: regular user admin endpoints discover कर ले।
// Role-based access check middleware
function requireRole(...allowedRoles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: "Not authenticated" });
}
if (!allowedRoles.includes(req.user.role)) {
// Attempt log करें — attack हो सकता है
logger.warn("Unauthorized access attempt", {
userId: req.user.id,
role: req.user.role,
requiredRoles: allowedRoles,
endpoint: `${req.method} ${req.path}`,
ip: req.ip,
});
return res.status(403).json({ error: "Insufficient permissions" });
}
next();
};
}
// Routes पर apply
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);Endpoints छुपाने पर rely न करें। "Security through obscurity" security नहीं है। चाहे admin panel URL कहीं linked न हो, कोई fuzzing से /api/admin/users ढूंढ लेगा।
API6: Unrestricted Access to Sensitive Business Flows#
Legitimate business functionality का automated abuse। सोचें: bots limited-stock items खरीद रहे हैं, spam के लिए automated account creation, product prices scraping।
Mitigations context-specific हैं: CAPTCHAs, device fingerprinting, behavioral analysis, sensitive operations के लिए step-up authentication। कोई one-size-fits-all snippet नहीं।
API7: Server Side Request Forgery (SSRF)#
अगर API user-provided URLs fetch करती है (webhooks, profile picture URLs, link previews), attacker आपके server से internal resources request करवा सकता है:
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, "Invalid URL");
}
// सिर्फ HTTP(S) allow
if (!["http:", "https:"].includes(parsed.protocol)) {
throw new ApiError(400, "Only HTTP(S) URLs are allowed");
}
// Hostname resolve करें, check करें private IP है या नहीं
const addresses = await dns.resolve4(parsed.hostname);
for (const addr of addresses) {
if (isPrivateIP(addr)) {
throw new ApiError(400, "Internal addresses are not allowed");
}
}
// Timeout और size limit के साथ fetch
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(userProvidedUrl, {
signal: controller.signal,
redirect: "error", // Redirects follow न करें (internal IPs पर redirect कर सकते हैं)
});
return response;
} finally {
clearTimeout(timeout);
}
}Key details: DNS पहले resolve करें और request बनाने से पहले IP check करें। Redirects block करें — attacker URL host कर सकता है जो http://169.254.169.254/ (AWS metadata endpoint) पर redirect करे ताकि URL-level check bypass हो।
API8: Security Misconfiguration#
Default credentials unchanged, unnecessary HTTP methods enabled, production में verbose error messages, directory listing enabled, CORS misconfigured। यह "दरवाज़ा lock करना भूल गए" category है।
// Production में stack traces leak न करें
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
logger.error("Unhandled error", {
error: err.message,
stack: err.stack,
path: req.path,
method: req.method,
});
if (process.env.NODE_ENV === "production") {
// Generic error message — internals reveal न करें
res.status(500).json({
error: "Internal server error",
requestId: req.id, // Debugging के लिए request ID
});
} else {
// Development में full error दिखाएं
res.status(500).json({
error: err.message,
stack: err.stack,
});
}
});
// Unnecessary HTTP methods disable
app.use((req, res, next) => {
const allowed = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
if (!allowed.includes(req.method)) {
return res.status(405).json({ error: "Method not allowed" });
}
next();
});API9: Improper Inventory Management#
API का v2 deploy किया लेकिन v1 बंद करना भूल गए। या /debug/ endpoint है जो development में useful था और अभी भी production में चल रहा। या staging server publicly accessible है production data के साथ।
यह code fix नहीं — ops discipline है। सभी API endpoints, deployed versions, और environments की list maintain करें। Exposed services ढूंढने के लिए automated scanning use करें। जो ज़रूरत नहीं बंद करें।
API10: Unsafe Consumption of APIs#
आपकी API third-party APIs consume करती है। क्या उनके responses validate करते हैं? क्या होगा अगर Stripe से webhook payload actually attacker से हो?
import crypto from "crypto";
// Stripe webhook signatures verify
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;
// पुराने timestamps reject (replay attacks रोकें)
const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
if (age > 300) return false; // 5 minute tolerance
const signedPayload = `${timestamp}.${payload}`;
const computedSig = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(computedSig),
Buffer.from(expectedSig)
);
}Webhooks पर हमेशा signatures verify करें। Third-party API responses की structure हमेशा validate करें। Outgoing requests पर हमेशा timeouts set करें। Data पर कभी trust न करें सिर्फ इसलिए कि "trusted partner" से आया।
Audit Logging#
जब कुछ गलत होता है — और होगा — audit logs से पता लगाते हैं क्या हुआ। लेकिन logging double-edged sword है। बहुत कम log करो — अंधे हो। बहुत ज़्यादा — privacy liability बनाओ।
क्या Log करें#
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>; // Additional context
requestId: string; // Application logs से correlate करने के लिए
}
async function auditLog(entry: AuditLogEntry): Promise<void> {
// Separate, append-only data store में लिखें
// वही database नहीं जो application use करती है
await auditDb.collection("audit_logs").insertOne({
...entry,
timestamp: new Date().toISOString(),
});
// Critical actions के लिए immutable external log में भी लिखें
if (isCriticalAction(entry.action)) {
await externalLogger.send(entry);
}
}ये events log करें:
- Authentication: logins, logouts, failed attempts, token refreshes
- Authorization: access denied events (अक्सर attack indicators)
- Data modifications: creates, updates, deletes — किसने क्या बदला, कब
- Admin actions: role changes, user management, configuration changes
- Security events: rate limit triggers, CORS violations, malformed requests
क्या Log न करें#
कभी log न करें:
- Passwords (hashed भी — hash credential है)
- पूरे credit card numbers (सिर्फ last 4 digits)
- Social Security Numbers या government IDs
- API keys या tokens (ज़्यादा से ज़्यादा prefix:
sk_live_...abc) - Personal health information
- पूरी request bodies जिनमें PII हो सकती है
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] = "[REDACTED]";
} else if (typeof value === "object" && value !== null) {
sanitized[key] = sanitizeForLogging(value as Record<string, unknown>);
} else {
sanitized[key] = value;
}
}
return sanitized;
}Tamper-Evident Logs#
अगर attacker system तक access पा ले, पहली चीज़ logs modify करना होगी tracks cover करने के लिए। Tamper-evident logging इसे detectable बनाती है:
import crypto from "crypto";
let previousHash = "GENESIS"; // Chain में initial hash
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 };
}
// Chain integrity verify करने के लिए:
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; // Chain टूट गई — logs tamper हुए
}
expectedPreviousHash = hash;
}
return true;
}यह blockchain जैसा concept है — हर log entry का hash पिछली entry पर depend करता है। अगर कोई entry modify या delete करे, chain टूट जाती है।
Dependency Security#
आपका code secure हो सकता है। लेकिन node_modules के 847 npm packages? Supply chain problem real है, और सालों में बदतर हुआ है।
npm audit Bare Minimum है#
# CI में चलाएं, high/critical पर build fail करें
npm audit --audit-level=high
# Auto-fix जो हो सके
npm audit fix
# देखें actually क्या pull हो रहा
npm ls --allलेकिन npm audit की limitations हैं। सिर्फ npm advisory database check करता है, severity ratings हमेशा accurate नहीं। Additional tools layer करें:
Automated Dependency Scanning#
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
reviewers:
- "your-team"
labels:
- "dependencies"
# PR noise कम करने के लिए minor/patch group करें
groups:
production-dependencies:
patterns:
- "*"
update-types:
- "minor"
- "patch"Lockfile Security Tool है#
हमेशा package-lock.json (या pnpm-lock.yaml, yarn.lock) commit करें। Lockfile हर dependency का exact version pin करता है, transitive सहित। इसके बिना, npm install test किए से अलग version pull कर सकता है — वो compromised हो सकता है।
# CI में install की जगह ci — lockfile strictly respect करता है
npm cinpm ci fail होता है अगर lockfile package.json से match न करे, silently update करने की बजाय। वो cases पकड़ता है जहां package.json modify हुआ लेकिन lockfile update भूल गए।
Install से पहले Evaluate करें#
Dependency add करने से पहले, पूछें:
- Actually ज़रूरत है? 20 lines में लिख सकता हूं package add करने की बजाय?
- कितने downloads? कम downloads necessarily बुरे नहीं, लेकिन कम लोग review कर रहे।
- कब update हुआ? 3 साल में update नहीं — unpatched vulnerabilities हो सकती हैं।
- कितनी dependencies pull?
is-odddepends onis-numberdepends onkind-of। तीन packages एक line code कर सकती है। - कौन maintain करता है? Single maintainer, single point of compromise।
// इसके लिए package नहीं चाहिए:
const isEven = (n: number): boolean => n % 2 === 0;
// या इसके:
const leftPad = (str: string, len: number, char = " "): string =>
str.padStart(len, char);
// या इसके:
const isNil = (value: unknown): value is null | undefined =>
value === null || value === undefined;Pre-Deploy Checklist#
यह actual checklist है जो मैं हर production deployment से पहले use करता हूं। Exhaustive नहीं — security कभी "done" नहीं — लेकिन सबसे ज़्यादा matter करने वाली गलतियां पकड़ती है।
| # | Check | Pass Criteria | Priority |
|---|---|---|---|
| 1 | Authentication | JWTs explicit algorithm, issuer, audience से verified। कोई alg: none नहीं। | Critical |
| 2 | Token expiration | Access tokens 15 min या कम में expire। Refresh tokens use पर rotate। | Critical |
| 3 | Token storage | Refresh tokens httpOnly secure cookies में। localStorage में कोई tokens नहीं। | Critical |
| 4 | हर endpoint पर Authorization | हर data-access endpoint object-level permissions check करे। BOLA tested। | Critical |
| 5 | Input validation | सारा user input Zod या equivalent से validated। Queries में कोई raw req.body नहीं। | Critical |
| 6 | SQL/NoSQL injection | सभी queries parameterized या ORM methods। कोई string concatenation नहीं। | Critical |
| 7 | Rate limiting | Auth endpoints: 5/15min। General API: 60/min। Rate limit headers returned। | High |
| 8 | CORS | Explicit origin allowlist। Credentials के साथ wildcard नहीं। Preflight cached। | High |
| 9 | Security headers | CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy सब present। | High |
| 10 | Error handling | Production errors generic messages। कोई stack traces, SQL errors exposed नहीं। | High |
| 11 | Secrets | Code या git history में कोई secrets नहीं। .env .gitignore में। Startup पर validated। | Critical |
| 12 | Dependencies | npm audit clean (कोई high/critical नहीं)। Lockfile committed। CI में npm ci। | High |
| 13 | HTTPS only | HSTS with preload enabled। HTTP HTTPS पर redirect। Secure cookie flag set। | Critical |
| 14 | Logging | Auth events, access denied, data mutations logged। Logs में कोई PII नहीं। | Medium |
| 15 | Request size limits | Body parser limited (1MB default)। File uploads capped। Pagination enforced। | Medium |
| 16 | SSRF protection | User-provided URLs validated। Private IPs blocked। Redirects disabled या validated। | Medium |
| 17 | Account lockout | 5 tries के बाद failed login lockout trigger। Lockout logged। | High |
| 18 | Webhook verification | सभी incoming webhooks signatures से verified। Timestamp replay protection। | High |
| 19 | Admin endpoints | सभी admin routes पर role-based access control। Attempts logged। | Critical |
| 20 | Mass assignment | Update endpoints allowlisted fields वाली Zod schema। कोई raw body spread नहीं। | High |
इसे GitHub issue template रखता हूं। Release tag करने से पहले, team में किसी को हर row check और sign off करना होता है। Glamorous नहीं, लेकिन काम करता है।
Mindset Shift#
Security feature नहीं है जो end में add करें। Sprint नहीं जो साल में एक बार करें। यह हर line code के बारे में सोचने का तरीका है।
Endpoint लिखते समय, सोचें: "अगर कोई unexpected data भेजे?" Parameter add करते समय, सोचें: "अगर कोई किसी और की ID में बदल दे?" Dependency add करते समय, सोचें: "अगर यह package अगले मंगलवार compromised हो जाए?"
सब कुछ नहीं पकड़ पाएंगे। कोई नहीं पकड़ता। लेकिन checklist methodically चलाना, हर deployment से पहले, सबसे ज़्यादा matter करने वाली चीज़ें पकड़ता है। Easy wins। Obvious holes। वो गलतियां जो बुरे दिन को data breach में बदलती हैं।
आदत बनाएं। Checklist चलाएं। Confidence से ship करें।