Defensa en profundidad para APIs: límites, validación y cabeceras
Capas de seguridad más allá del login: rate limiting, validación, codificación de salida, cabeceras y auditoría, con middleware en TypeScript.

La autenticación verifica la identidad. Responde a la pregunta «¿quién eres?». Pero un usuario correctamente autenticado aún puede abusar de tu API: enviando datos malformados, machacando endpoints, explotando la lógica de negocio o extrayendo datos mediante consultas sin límites. Las capas que hay más allá de la autenticación determinan si tu API sobrevive al contacto con el tráfico del mundo real.
La defensa en profundidad significa que cada capa asume que la anterior puede fallar. La limitación de tasa no asume que el cortafuegos bloqueó el ataque. La validación de entradas no asume que el cliente envió datos bien formados. La codificación de salida no asume que la base de datos contenía valores limpios.
Limitación de tasa: proteger la capacidad
La limitación de tasa evita que un solo cliente consuma recursos de forma desproporcionada. Sin ella, un único cliente agresivo —malicioso o con errores— puede denegar el servicio a todos los demás.
// ❌ No rate limiting — any client can overwhelm the API
app.post("/api/search", async (req, res) => {
const results = await db.fullTextSearch(req.body.query);
res.json(results);
// An attacker sends 10,000 requests/second
// Database overwhelmed, other users get timeouts
});// ✅ Layered rate limiting middleware
import { Redis } from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
interface RateLimitConfig {
windowMs: number;
maxRequests: number;
keyPrefix: string;
}
async function checkRateLimit(
key: string,
config: RateLimitConfig
): Promise<{
allowed: boolean;
remaining: number;
resetAt: number;
}> {
const windowKey =
`${config.keyPrefix}:${key}:` +
`${Math.floor(Date.now() / config.windowMs)}`;
const count = await redis.incr(windowKey);
if (count === 1) {
await redis.pexpire(windowKey, config.windowMs);
}
const remaining = Math.max(
0,
config.maxRequests - count
);
const resetAt =
Math.ceil(Date.now() / config.windowMs) *
config.windowMs;
return {
allowed: count <= config.maxRequests,
remaining,
resetAt,
};
}
function rateLimiter(config: RateLimitConfig) {
return async (
req: Request,
res: Response,
next: NextFunction
) => {
// Use authenticated user ID, fall back to IP
const key =
req.user?.id ?? req.ip ?? "unknown";
const result = await checkRateLimit(key, config);
// Always set rate limit headers
res.set({
"X-RateLimit-Limit": String(config.maxRequests),
"X-RateLimit-Remaining": String(result.remaining),
"X-RateLimit-Reset": String(result.resetAt),
});
if (!result.allowed) {
res.status(429).json({
error: "Too many requests",
retryAfter: Math.ceil(
(result.resetAt - Date.now()) / 1000
),
});
return;
}
next();
};
}
// Different limits for different endpoints
app.use(
"/api/search",
rateLimiter({
windowMs: 60_000,
maxRequests: 30,
keyPrefix: "rl:search",
})
);
app.use(
"/api/auth/login",
rateLimiter({
windowMs: 900_000, // 15 minutes
maxRequests: 5, // Strict for auth
keyPrefix: "rl:login",
})
);Validación de entradas: rechazar datos incorrectos cuanto antes
Cada parámetro de una petición es una entrada no confiable. Valida la forma, el tipo y las restricciones antes de procesarla.
import { z } from "zod";
// ❌ Trusting client input
app.post("/api/users", async (req, res) => {
// req.body could be anything — no validation
await db.query(
"INSERT INTO users (name, email, role) VALUES ($1, $2, $3)",
[req.body.name, req.body.email, req.body.role]
// Attacker sets role: "admin" — privilege escalation
);
});// ✅ Strict schema validation with Zod
const createUserSchema = z.object({
name: z
.string()
.min(1)
.max(100)
.regex(
/^[\p{L}\p{N}\s\-'.]+$/u,
"Invalid characters in name"
),
email: z.string().email().max(254),
// Role is NOT user-settable — determined by backend
});
const searchSchema = z.object({
query: z
.string()
.min(1)
.max(200)
.transform((q) => q.trim()),
page: z.coerce
.number()
.int()
.min(1)
.max(100)
.default(1),
limit: z.coerce
.number()
.int()
.min(1)
.max(50)
.default(20),
// Prevent unbounded queries
});
function validate<T>(schema: z.ZodSchema<T>) {
return (
req: Request,
res: Response,
next: NextFunction
) => {
const result = schema.safeParse(req.body);
if (!result.success) {
res.status(400).json({
error: "Validation failed",
details: result.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
})),
});
return;
}
req.body = result.data;
next();
};
}
app.post(
"/api/users",
validate(createUserSchema),
async (req, res) => {
// req.body is now typed and validated
const { name, email } = req.body;
// Role assigned by backend logic, never from input
const role = "user";
await db.createUser({ name, email, role });
res.status(201).json({ name, email, role });
}
);Cabeceras de seguridad: blindar las respuestas
Las cabeceras de seguridad indican a los navegadores cómo manejar tus respuestas. La ausencia de cabeceras deja a los clientes vulnerables a clickjacking, XSS y ataques de degradación de protocolo.
function securityHeaders(
req: Request,
res: Response,
next: NextFunction
) {
// Prevent clickjacking
res.set("X-Frame-Options", "DENY");
// Block MIME-type sniffing
res.set("X-Content-Type-Options", "nosniff");
// Enable strict transport security
res.set(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload"
);
// Content Security Policy for APIs
res.set(
"Content-Security-Policy",
"default-src 'none'; frame-ancestors 'none'"
);
// Control referrer information
res.set("Referrer-Policy", "strict-origin");
// Permissions policy
res.set(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=()"
);
next();
}
app.use(securityHeaders);Codificación de salida: sanitizar las respuestas
Los datos almacenados en tu base de datos pueden contener contenido malicioso. Codifica la salida antes de enviarla a los clientes.
// ❌ Raw database values in response
app.get("/api/comments/:postId", async (req, res) => {
const comments = await db.getComments(req.params.postId);
res.json(comments);
// If a comment contains <script>alert('xss')</script>
// and the client renders it as HTML — XSS
});// ✅ Sanitize output
import DOMPurify from "isomorphic-dompurify";
function sanitizeOutput<T extends Record<string, unknown>>(
obj: T,
htmlFields: string[] = []
): T {
const sanitized = { ...obj };
for (const [key, value] of Object.entries(sanitized)) {
if (typeof value === "string") {
if (htmlFields.includes(key)) {
// Allow safe HTML in designated fields
(sanitized as Record<string, unknown>)[key] =
DOMPurify.sanitize(value, {
ALLOWED_TAGS: [
"b",
"i",
"em",
"strong",
"a",
"p",
"br",
],
ALLOWED_ATTR: ["href"],
});
} else {
// Strip all HTML from non-HTML fields
(sanitized as Record<string, unknown>)[key] =
value
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
}
}
return sanitized;
}
app.get(
"/api/comments/:postId",
async (req, res) => {
const comments = await db.getComments(
req.params.postId
);
const safe = comments.map((c) =>
sanitizeOutput(c, ["body"])
);
res.json(safe);
}
);Registro de auditoría: registrar lo ocurrido
Cuando se produce un incidente de seguridad, los registros de auditoría te dicen qué pasó, cuándo y quién estuvo implicado. Sin ellos, la respuesta a incidentes es un juego de adivinanzas.
interface AuditEvent {
timestamp: string;
userId: string | null;
action: string;
resource: string;
resourceId: string;
ip: string;
userAgent: string;
outcome: "success" | "failure" | "denied";
details?: Record<string, unknown>;
}
class AuditLogger {
async log(event: AuditEvent): Promise<void> {
// Write to append-only audit store
await db.query(
`INSERT INTO audit_log
(timestamp, user_id, action, resource,
resource_id, ip, user_agent, outcome, details)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
event.timestamp,
event.userId,
event.action,
event.resource,
event.resourceId,
event.ip,
event.userAgent,
event.outcome,
JSON.stringify(event.details ?? {}),
]
);
}
}
const audit = new AuditLogger();
// Audit middleware for sensitive operations
function auditAction(action: string, resource: string) {
return async (
req: Request,
res: Response,
next: NextFunction
) => {
const originalJson = res.json.bind(res);
const startTime = new Date().toISOString();
res.json = function (body: unknown) {
const outcome =
res.statusCode >= 400 ? "failure" : "success";
audit.log({
timestamp: startTime,
userId: req.user?.id ?? null,
action,
resource,
resourceId:
req.params.id ?? "unknown",
ip: req.ip ?? "unknown",
userAgent:
req.headers["user-agent"] ?? "unknown",
outcome,
});
return originalJson(body);
};
next();
};
}
app.delete(
"/api/users/:id",
auditAction("delete", "user"),
async (req, res) => {
await db.deleteUser(req.params.id);
res.json({ deleted: true });
// Audit log automatically captures: who deleted
// which user, when, from what IP
}
);Conclusiones clave
La limitación de tasa debe ser por capas y específica de cada endpoint: los endpoints de autenticación necesitan límites estrictos (5 intentos por cada 15 minutos), los de búsqueda necesitan límites moderados y los de lectura pueden ser más permisivos, usando el ID de usuario autenticado como clave principal con la IP como respaldo para peticiones no autenticadas. La validación de entradas con bibliotecas de esquemas como Zod debe rechazar las peticiones inválidas en el límite antes de que se ejecute cualquier lógica de negocio, imponiendo restricciones de tipo, límites de longitud y listas de permitidos explícitas para valores enumerados, sin confiar nunca en los campos de rol o permisos proporcionados por el cliente. Las cabeceras de seguridad forman una capa de defensa pasiva que no cuesta nada implementar: Strict-Transport-Security, X-Content-Type-Options, Content-Security-Policy y X-Frame-Options previenen categorías enteras de ataques con independencia de que la lógica de la aplicación sea correcta. El registro de auditoría en operaciones sensibles crea un rastro de investigación que convierte la respuesta a incidentes en un análisis basado en evidencias en lugar de adivinanzas: registra quién realizó qué acción sobre qué recurso y con qué resultado, y almacena estos registros en un almacén de solo anexado separado de los datos de la aplicación.


