Boas Práticas de Segurança de API: O Checklist Que Rodo em Todo Projeto
Autenticação, autorização, validação de input, rate limiting, CORS, gestão de secrets e o OWASP API Top 10. O que eu verifico antes de todo deploy em produção.
Já enviei APIs completamente abertas. Não por maldade, nem por preguiça — eu simplesmente não sabia o que não sabia. Um endpoint que retornava todos os campos do objeto user, incluindo senhas hasheadas. Um rate limiter que só verificava endereços IP, o que significava que qualquer pessoa atrás de um proxy podia bombardear a API. Uma implementação de JWT onde esqueci de verificar a claim iss, então tokens de um serviço completamente diferente funcionavam perfeitamente.
Cada um desses erros chegou à produção. Cada um deles foi descoberto — alguns por mim, alguns por utilizadores, um por um pesquisador de segurança que foi gentil o suficiente para me enviar um email em vez de postar no Twitter.
Este post é o checklist que construí a partir desses erros. Percorro-o antes de cada deploy em produção. Não porque sou paranoico, mas porque aprendi que bugs de segurança são os que mais doem. Um botão avariado incomoda os utilizadores. Um fluxo de autenticação avariado vaza os dados deles.
Autenticação vs Autorização#
Estas duas palavras são usadas de forma intercambiável em reuniões, na documentação, até em comentários no código. Não são a mesma coisa.
Autenticação responde: "Quem é você?" É o passo de login. Nome de utilizador e senha, fluxo OAuth, magic link — o que quer que prove a sua identidade.
Autorização responde: "O que é que você pode fazer?" É o passo de permissões. Este utilizador pode eliminar este recurso? Pode aceder a este endpoint de admin? Pode ler os dados de outro utilizador?
O bug de segurança mais comum que já vi em APIs em produção não é um fluxo de login avariado. É uma verificação de autorização em falta. O utilizador está autenticado — tem um token válido — mas a API nunca verifica se ele tem permissão para executar a ação que está a solicitar.
JWT: Anatomia e os Erros Que Importam#
Os JWTs estão em todo o lado. Também são mal compreendidos em todo o lado. Um JWT tem três partes, separadas por pontos:
header.payload.signature
O header indica qual algoritmo foi usado. O payload contém claims (ID do utilizador, papéis, expiração). A assinatura prova que ninguém alterou as duas primeiras partes.
Aqui está uma verificação de JWT correta em 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"], // Never allow "none"
issuer: "api.yourapp.com",
audience: "yourapp.com",
clockTolerance: 30, // 30 seconds leeway for clock skew
}) 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");
}
}Alguns pontos a notar:
-
algorithms: ["HS256"]— Isto é crítico. Se não especificar o algoritmo, um atacante pode enviar um token com"alg": "none"no header e ignorar completamente a verificação. Este é o ataquealg: none, e já afetou sistemas reais em produção. -
issuereaudience— Sem estes, um token criado para o Serviço A funciona no Serviço B. Se executar vários serviços a partilhar o mesmo secret (o que não deveria, mas as pessoas fazem), é assim que acontece o abuso de tokens entre serviços. -
Tratamento de erros específico — Não retorne
"invalid token"para todas as falhas. Distinguir entre expirado e inválido ajuda o cliente a saber se deve renovar ou re-autenticar.
Rotação de Refresh Token#
Os access tokens devem ser de curta duração — 15 minutos é o padrão. Mas não quer que os utilizadores voltem a inserir a senha a cada 15 minutos. É aí que entram os refresh tokens.
O padrão que realmente funciona em produção:
import { randomBytes } from "crypto";
import { redis } from "./redis";
interface RefreshTokenData {
userId: string;
family: string; // Token family for rotation detection
createdAt: number;
}
async function rotateRefreshToken(
oldRefreshToken: string
): Promise<{ accessToken: string; refreshToken: string }> {
const tokenData = await redis.get(`refresh:${oldRefreshToken}`);
if (!tokenData) {
// Token not found — either expired or already used.
// If already used, this is a potential replay attack.
// Invalidate the entire token family.
const parsed = decodeRefreshToken(oldRefreshToken);
if (parsed?.family) {
await invalidateTokenFamily(parsed.family);
}
throw new ApiError(401, "Invalid refresh token");
}
const data: RefreshTokenData = JSON.parse(tokenData);
// Delete the old token immediately — single use only
await redis.del(`refresh:${oldRefreshToken}`);
// Generate new tokens
const newRefreshToken = randomBytes(64).toString("hex");
const newAccessToken = generateAccessToken(data.userId);
// Store the new refresh token with the same family
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> {
// Scan for all tokens in this family and delete them.
// This is the nuclear option — if someone replays a refresh token,
// we kill every token in the family, forcing re-authentication.
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);
}
}
}
}O conceito de token family é o que torna isto seguro. Cada refresh token pertence a uma família (criada no login). Quando faz a rotação, o novo token herda a família. Se um atacante repete um refresh token antigo, você deteta a reutilização e mata a família inteira. O utilizador legítimo fica desconectado, mas o atacante não entra.
Armazenamento de Tokens: O Debate httpOnly Cookie vs localStorage#
Este debate já dura anos, e a resposta é clara: httpOnly cookies para refresh tokens, sempre.
O localStorage é acessível a qualquer JavaScript que corra na sua página. Se tiver uma única vulnerabilidade XSS — e em escala, eventualmente terá — o atacante pode ler o token e extraí-lo. Game over.
Os httpOnly cookies não são acessíveis ao JavaScript. Ponto final. Uma vulnerabilidade XSS ainda pode fazer pedidos em nome do utilizador (porque os cookies são enviados automaticamente), mas o atacante não consegue roubar o token em si. Essa é uma diferença significativa.
// Setting a secure refresh token cookie
function setRefreshTokenCookie(res: Response, token: string): void {
res.cookie("refresh_token", token, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: "strict", // No cross-site requests
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
path: "/api/auth", // Only sent to auth endpoints
});
}O path: "/api/auth" é um detalhe que a maioria das pessoas perde. Por padrão, os cookies são enviados para todos os endpoints no seu domínio. O seu refresh token não precisa de ir para /api/users ou /api/products. Restrinja o caminho, reduza a superfície de ataque.
Para access tokens, eu guardo-os em memória (uma variável JavaScript). Não no localStorage, não no sessionStorage, não num cookie. Em memória. São de curta duração (15 minutos), e quando a página atualiza, o cliente silenciosamente faz um pedido ao endpoint de refresh para obter um novo. Sim, isto significa um pedido extra no carregamento da página. Vale a pena.
Validação de Input: Nunca Confie no Cliente#
O cliente não é seu amigo. O cliente é um estranho que entrou em sua casa e disse "Tenho permissão para estar aqui." Você verifica o documento na mesma.
Cada dado que vem de fora do seu servidor — corpo do pedido, parâmetros de query, parâmetros de URL, headers — é input não confiável. Não importa que o seu formulário React tenha validação. Alguém vai contorná-la com curl.
Zod para Validação Type-Safe#
O Zod é a melhor coisa que aconteceu à validação de input em Node.js. Dá-lhe validação em runtime com tipos TypeScript de graça:
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") // Prevent 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" is intentionally not an option here.
// Admin role assignment goes through a separate, privileged endpoint.
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
// Usage in an Express handler
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 is fully typed as CreateUserInput
const user = await createUser(result.data);
return res.status(201).json({ id: user.id, email: user.email });
});Alguns detalhes relevantes para segurança:
max(128)na senha — o bcrypt tem um limite de input de 72 bytes, e algumas implementações simplesmente truncam silenciosamente. Mas mais importante, se permitir uma senha de 10MB, o bcrypt vai gastar tempo significativo a fazer o hash. Isso é um vetor de DoS.max(254)no email — o RFC 5321 limita endereços de email a 254 caracteres. Qualquer coisa mais longa não é um email válido.- Enum para role, sem admin — Mass assignment é uma das vulnerabilidades de API mais antigas. Se aceitar o role do corpo do pedido sem validar, alguém vai enviar
"role": "admin"e esperar pelo melhor.
SQL Injection Não Está Resolvido#
"Basta usar um ORM" não o protege se escrever queries raw por performance. E toda a gente acaba por escrever queries raw por performance.
// 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]);Com Prisma, está maioritariamente seguro — mas o $queryRaw ainda pode apanhá-lo:
// VULNERABLE — template literal in $queryRaw
const users = await prisma.$queryRaw`
SELECT * FROM users WHERE name LIKE '%${searchTerm}%'
`;
// SAFE — using Prisma.sql for parameterization
import { Prisma } from "@prisma/client";
const users = await prisma.$queryRaw(
Prisma.sql`SELECT * FROM users WHERE name LIKE ${`%${searchTerm}%`}`
);NoSQL Injection#
O MongoDB não usa SQL, mas não é imune a injection. Se passar input não sanitizado do utilizador como um objeto de query, as coisas correm mal:
// VULNERABLE — if req.body.username is { "$gt": "" }
// this returns the first user in the collection
const user = await db.collection("users").findOne({
username: req.body.username,
});
// SAFE — explicitly coerce to string
const user = await db.collection("users").findOne({
username: String(req.body.username),
});
// BETTER — validate with Zod first
const LoginSchema = z.object({
username: z.string().min(1).max(50),
password: z.string().min(1).max(128),
});A correção é simples: valide os tipos de input antes de chegarem ao driver da base de dados. Se username deve ser uma string, assegure-se de que é uma string.
Path Traversal#
Se a sua API serve ficheiros ou lê de um caminho que inclui input do utilizador, o path traversal vai arruinar a sua semana:
import path from "path";
import { access, constants } from "fs/promises";
const ALLOWED_DIR = "/app/uploads";
async function resolveUserFilePath(userInput: string): Promise<string> {
// Normalize and resolve to an absolute path
const resolved = path.resolve(ALLOWED_DIR, userInput);
// Critical: verify the resolved path is still within the allowed directory
if (!resolved.startsWith(ALLOWED_DIR + path.sep)) {
throw new ApiError(403, "Access denied");
}
// Verify the file actually exists
await access(resolved, constants.R_OK);
return resolved;
}
// Without this check:
// GET /api/files?name=../../../etc/passwd
// resolves to /etc/passwdO padrão path.resolve + startsWith é a abordagem correta. Não tente remover ../ manualmente — há demasiados truques de encoding (..%2F, ..%252F, ....//) que vão contornar o seu regex.
Rate Limiting#
Sem rate limiting, a sua API é um buffet livre para bots. Ataques de força bruta, credential stuffing, exaustão de recursos — o rate limiting é a primeira defesa contra todos eles.
Token Bucket vs Sliding Window#
Token bucket: Tem um balde que contém N tokens. Cada pedido custa um token. Os tokens recarregam a uma taxa fixa. Se o balde estiver vazio, o pedido é rejeitado. Isto permite picos — se o balde estiver cheio, pode fazer N pedidos instantaneamente.
Sliding window: Conta pedidos dentro de uma janela de tempo móvel. Mais previsível, mais difícil de ultrapassar em pico.
Eu uso sliding window para a maioria das coisas porque o comportamento é mais fácil de raciocinar e explicar à equipa:
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();
// Remove entries outside the window
multi.zremrangebyscore(key, 0, windowStart);
// Count entries in the window
multi.zcard(key);
// Add the current request (we'll remove it if over limit)
multi.zadd(key, now.toString(), `${now}:${Math.random()}`);
// Set expiry on the key
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 — remove the entry we just added
await redis.zremrangebyscore(key, now, now);
return {
allowed: false,
remaining: 0,
resetAt: windowStart + windowMs,
};
}
return {
allowed: true,
remaining: limit - count - 1,
resetAt: now + windowMs,
};
}Rate Limits em Camadas#
Um único rate limit global não é suficiente. Diferentes endpoints têm diferentes perfis de risco:
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 — more 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}`;
}Repare: utilizadores autenticados são limitados pelo ID de utilizador, não pelo IP. Isto é importante porque muitos utilizadores legítimos partilham IPs (redes corporativas, VPNs, operadoras móveis). Se limitar apenas por IP, vai bloquear escritórios inteiros.
Headers de Rate Limit#
Informe sempre o cliente do que se passa:
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),
});
}
}Configuração de CORS#
O CORS é provavelmente o mecanismo de segurança mais mal compreendido no desenvolvimento web. Metade das respostas no Stack Overflow sobre CORS são "basta definir Access-Control-Allow-Origin: * e funciona." Isso é tecnicamente verdade. Também é assim que abre a sua API para qualquer site malicioso na internet.
O Que o CORS Realmente Faz (e Não Faz)#
O CORS é um mecanismo do browser. Diz ao browser se JavaScript da Origem A tem permissão para ler a resposta da Origem B. É isso.
O que o CORS não faz:
- Não protege a sua API de curl, Postman ou pedidos server-to-server
- Não autentica pedidos
- Não encripta nada
- Não previne CSRF por si só (embora ajude quando combinado com outros mecanismos)
O que o CORS faz:
- Previne que site-malicioso.com faça pedidos fetch à sua-api.com e leia a resposta no browser do utilizador
- Previne que o JavaScript do atacante exfiltre dados através da sessão autenticada da vítima
A Armadilha do Wildcard#
// DANGEROUS — allows any website to read your API responses
app.use(cors({ origin: "*" }));
// ALSO DANGEROUS — this is a common "dynamic" approach that's just * with extra steps
app.use(
cors({
origin: (origin, callback) => {
callback(null, true); // Allows everything
},
})
);O problema com * é que torna as respostas da sua API legíveis por qualquer JavaScript em qualquer página. Se a sua API retorna dados de utilizador e o utilizador está autenticado via cookies, qualquer site que o utilizador visite pode ler esses dados.
Ainda pior: Access-Control-Allow-Origin: * não pode ser combinado com credentials: true. Então, se precisar de cookies (para autenticação), literalmente não pode usar o wildcard. Mas já vi pessoas tentarem contornar isto refletindo o header Origin de volta — o que é equivalente a * com credenciais, o pior dos dois mundos.
A Configuração Correta#
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) => {
// Allow requests with no origin (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, // Allow cookies
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
allowedHeaders: ["Content-Type", "Authorization"],
exposedHeaders: ["X-RateLimit-Limit", "X-RateLimit-Remaining"],
maxAge: 86400, // Cache preflight for 24 hours
})
);Decisões chave:
- Conjunto de origens explícito, não um regex. Regexes são traiçoeiros —
yourapp.compode coincidir comevilyourapp.comse o seu regex não estiver corretamente ancorado. credentials: trueporque usamos httpOnly cookies para refresh tokens.maxAge: 86400— Pedidos preflight (OPTIONS) adicionam latência. Dizer ao browser para guardar em cache o resultado do CORS por 24 horas reduz roundtrips desnecessários.exposedHeaders— Por padrão, o browser só expõe um punhado de headers de resposta "simples" ao JavaScript. Se quer que o cliente leia os seus headers de rate limit, tem de os expor explicitamente.
Pedidos Preflight#
Quando um pedido não é "simples" (usa um header não padrão, um método não padrão ou um content type não padrão), o browser envia um pedido OPTIONS primeiro para pedir permissão. Este é o preflight.
Se a sua configuração de CORS não tratar OPTIONS, os pedidos preflight vão falhar, e o pedido real nunca será enviado. A maioria das bibliotecas de CORS trata isto automaticamente, mas se estiver a usar um framework que não trata, precisa de o fazer:
// Manual preflight handling (most frameworks do this for you)
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();
});Headers de Segurança#
Os headers de segurança são a melhoria de segurança mais barata que pode fazer. São headers de resposta que dizem ao browser para ativar funcionalidades de segurança. A maioria deles é uma única linha de configuração, e protegem contra classes inteiras de ataques.
Os Headers Que Importam#
import helmet from "helmet";
// One line. This is the fastest security win in any Express app.
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // Needed for many 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" },
})
);O que cada header faz:
Content-Security-Policy (CSP) — O header de segurança mais poderoso. Diz ao browser exatamente quais fontes são permitidas para scripts, estilos, imagens, fonts, etc. Se um atacante injetar uma tag <script> que carrega de evil.com, o CSP bloqueia-a. Esta é a defesa individual mais eficaz contra XSS.
Strict-Transport-Security (HSTS) — Diz ao browser para usar sempre HTTPS, mesmo que o utilizador escreva http://. A diretiva preload permite submeter o seu domínio à lista HSTS integrada do browser, para que até o primeiro pedido seja forçado a HTTPS.
X-Frame-Options — Impede que o seu site seja incorporado num iframe. Isto bloqueia ataques de clickjacking onde um atacante sobrepõe a sua página com elementos invisíveis. O Helmet define isto como SAMEORIGIN por padrão. O substituto moderno é frame-ancestors no CSP.
X-Content-Type-Options: nosniff — Impede o browser de adivinhar (sniffing) o tipo MIME de uma resposta. Sem isto, se servir um ficheiro com o Content-Type errado, o browser pode executá-lo como JavaScript.
Referrer-Policy — Controla quanta informação de URL é enviada no header Referer. strict-origin-when-cross-origin envia o URL completo para pedidos same-origin mas apenas a origem para pedidos cross-origin. Isto previne a fuga de parâmetros de URL sensíveis para terceiros.
Testar os Seus Headers#
Após o deploy, verifique a sua pontuação em securityheaders.com. Aponte para uma classificação A+. Demora cerca de cinco minutos de configuração para lá chegar.
Também pode verificar headers programaticamente:
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 removes this
});
});A verificação do x-powered-by é subtil mas importante. O Express define X-Powered-By: Express por padrão, informando os atacantes exatamente qual framework está a usar. O Helmet remove-o.
Gestão de Secrets#
Este deveria ser óbvio, mas ainda o vejo em pull requests: chaves de API, senhas de base de dados e JWT secrets hardcoded em ficheiros de código. Ou commitados em ficheiros .env que não estavam no .gitignore. Uma vez que está no histórico do git, está lá para sempre, mesmo que elimine o ficheiro no commit seguinte.
As Regras#
-
Nunca faça commit de secrets no git. Nem no código, nem no
.env, nem em ficheiros de configuração, nem em ficheiros Docker Compose, nem em comentários "só para testes". -
Use
.env.examplecomo template. Documenta que variáveis de ambiente são necessárias, sem conter valores reais:
# .env.example — commit this
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 — NEVER commit this
# Listed in .gitignore- Valide variáveis de ambiente no arranque. Não espere até que um pedido chegue a um endpoint que precisa do URL da base de dados. Falhe cedo:
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); // Don't start with bad config
}
return result.data;
}
export const env = validateEnv();- Use um gestor de secrets em produção. As variáveis de ambiente funcionam para configurações simples, mas têm limitações: são visíveis na listagem de processos, persistem na memória e podem vazar através de logs de erro.
Para sistemas em produção, use um gestor de secrets adequado:
- AWS Secrets Manager ou SSM Parameter Store
- HashiCorp Vault
- Google Secret Manager
- Azure Key Vault
- Doppler (se quiser algo que funcione em todas as clouds)
O padrão é o mesmo independentemente de qual usar: a aplicação busca os secrets no arranque do gestor de secrets, não das variáveis de ambiente.
- Rode secrets regularmente. Se tem usado o mesmo JWT secret há dois anos, é hora de rodar. Implemente rotação de chaves: suporte múltiplas chaves de assinatura válidas simultaneamente, assine novos tokens com a nova chave, verifique com a antiga e a nova, e retire a chave antiga depois de todos os tokens existentes expirarem.
interface SigningKey {
id: string;
secret: string;
createdAt: Date;
active: boolean; // Only the active key signs new tokens
}
async function verifyWithRotation(token: string): Promise<TokenPayload> {
const keys = await getSigningKeys(); // Returns all valid keys
for (const key of keys) {
try {
return jwt.verify(token, key.secret, {
algorithms: ["HS256"],
}) as TokenPayload;
} catch {
continue; // Try the next key
}
}
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, // Include key ID in the header
});
}OWASP API Security Top 10#
O OWASP API Security Top 10 é a lista padrão da indústria de vulnerabilidades de API. É atualizado periodicamente, e cada item na lista é algo que já vi em codebases reais. Vou percorrer cada um.
API1: Broken Object Level Authorization (BOLA)#
A vulnerabilidade de API mais comum. O utilizador está autenticado, mas a API não verifica se ele tem acesso ao objeto específico que está a solicitar.
// VULNERABLE — any authenticated user can access any user's data
app.get("/api/users/:id", authenticate, async (req, res) => {
const user = await db.users.findById(req.params.id);
return res.json(user);
});
// FIXED — verify the user is accessing their own data (or is an 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);
});A versão vulnerável está em todo o lado. Passa todas as verificações de autenticação — o utilizador tem um token válido — mas não verifica se está autorizado a aceder a este recurso específico. Mude o ID no URL e obtém os dados de outra pessoa.
API2: Broken Authentication#
Mecanismos de login fracos, MFA em falta, tokens que nunca expiram, senhas armazenadas em texto simples. Isto cobre a camada de autenticação em si.
A correção é tudo o que discutimos na secção de autenticação: requisitos de senha fortes, bcrypt com rounds suficientes, access tokens de curta duração, rotação de refresh token, bloqueio de conta após tentativas falhadas.
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))) {
// Increment failed attempts
await redis.multi()
.incr(lockoutKey)
.pexpire(lockoutKey, LOCKOUT_DURATION)
.exec();
// Same error message for both cases — don't reveal whether the email exists
throw new ApiError(401, "Invalid email or password");
}
// Reset failed attempts on successful login
await redis.del(lockoutKey);
return generateTokens(user);
}O comentário sobre "mesma mensagem de erro" é importante. Se a sua API retorna "utilizador não encontrado" para emails inválidos e "senha errada" para emails válidos com senhas erradas, está a dizer a um atacante quais emails existem no seu sistema.
API3: Broken Object Property Level Authorization#
Retornar mais dados do que o necessário, ou permitir que utilizadores modifiquem propriedades que não deveriam.
// VULNERABLE — returns the entire user object, including 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 includes: passwordHash, internalNotes, billingId, ...
});
// FIXED — explicit allowlist of returned fields
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,
});
});Nunca retorne objetos inteiros da base de dados. Escolha sempre os campos que quer expor. Isto aplica-se a escritas também — não espalhe o corpo inteiro do pedido na sua query de atualização:
// VULNERABLE — mass assignment
app.put("/api/users/:id", authenticate, async (req, res) => {
await db.users.update(req.params.id, req.body);
// Attacker sends: { "role": "admin", "verified": true }
});
// FIXED — pick allowed fields
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: Consumo de Recursos Sem Restrições#
A sua API é um recurso. CPU, memória, largura de banda, conexões à base de dados — são todos finitos. Sem limites, um único cliente pode esgotar todos eles.
Isto vai além do rate limiting. Inclui:
// Limit request body size
app.use(express.json({ limit: "1mb" }));
// Limit query complexity
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),
});
// Limit file upload size
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"));
}
},
});
// Timeout long-running requests
app.use((req, res, next) => {
res.setTimeout(30000, () => {
res.status(408).json({ error: "Request timeout" });
});
next();
});API5: Broken Function Level Authorization#
Diferente do BOLA. Isto é sobre aceder a funções (endpoints) às quais não deveria ter acesso, não objetos. O exemplo clássico: um utilizador regular a descobrir endpoints de admin.
// Middleware that checks role-based access
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)) {
// Log the attempt — this might be an 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();
};
}
// Apply to routes
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);Não confie em esconder endpoints. "Segurança por obscuridade" não é segurança. Mesmo que o URL do painel de admin não esteja ligado em lado nenhum, alguém vai encontrar /api/admin/users por fuzzing.
API6: Acesso Sem Restrições a Fluxos de Negócio Sensíveis#
Abuso automatizado de funcionalidade de negócio legítima. Pense: bots a comprar itens de stock limitado, criação automatizada de contas para spam, scraping de preços de produtos.
As mitigações são específicas ao contexto: CAPTCHAs, device fingerprinting, análise comportamental, step-up authentication para operações sensíveis. Não há um snippet de código universal.
API7: Server Side Request Forgery (SSRF)#
Se a sua API busca URLs fornecidos pelo utilizador (webhooks, URLs de foto de perfil, previews de links), um atacante pode fazer o seu servidor solicitar recursos internos:
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");
}
// Only allow HTTP(S)
if (!["http:", "https:"].includes(parsed.protocol)) {
throw new ApiError(400, "Only HTTP(S) URLs are allowed");
}
// Resolve the hostname and check if it's a 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");
}
}
// Now fetch with a timeout and size limit
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(userProvidedUrl, {
signal: controller.signal,
redirect: "error", // Don't follow redirects (they could redirect to internal IPs)
});
return response;
} finally {
clearTimeout(timeout);
}
}Detalhes chave: resolva o DNS primeiro e verifique o IP antes de fazer o pedido. Bloqueie redirecionamentos — um atacante pode hospedar um URL que redireciona para http://169.254.169.254/ (endpoint de metadados da AWS) para contornar a sua verificação ao nível do URL.
API8: Configuração de Segurança Incorreta#
Credenciais padrão não alteradas, métodos HTTP desnecessários ativados, mensagens de erro verbosas em produção, listagem de diretórios ativada, CORS mal configurado. Esta é a categoria "esqueceu-se de trancar a porta".
// Don't leak stack traces in production
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 — don't reveal internals
res.status(500).json({
error: "Internal server error",
requestId: req.id, // Include a request ID for debugging
});
} else {
// In development, show the full error
res.status(500).json({
error: err.message,
stack: err.stack,
});
}
});
// Disable unnecessary HTTP methods
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: Gestão de Inventário Imprópria#
Fez deploy da v2 da API mas esqueceu-se de desligar a v1. Ou há um endpoint /debug/ que era útil durante o desenvolvimento e ainda está a correr em produção. Ou um servidor de staging que é publicamente acessível com dados de produção.
Isto não é uma correção de código — é disciplina de operações. Mantenha uma lista de todos os endpoints da API, todas as versões implantadas e todos os ambientes. Use scanning automatizado para encontrar serviços expostos. Desative o que não precisa.
API10: Consumo Inseguro de APIs#
A sua API consome APIs de terceiros. Valida as respostas deles? O que acontece se um webhook payload do Stripe for na verdade de um atacante?
import crypto from "crypto";
// Verify Stripe webhook signatures
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;
// Reject old timestamps (prevent 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)
);
}Verifique sempre assinaturas em webhooks. Valide sempre a estrutura das respostas de APIs de terceiros. Defina sempre timeouts em pedidos enviados. Nunca confie em dados só porque vieram de "um parceiro de confiança."
Audit Logging#
Quando algo corre mal — e vai correr — os logs de auditoria são como descobre o que aconteceu. Mas o logging é uma espada de dois gumes. Registe muito pouco e fica cego. Registe demasiado e cria uma responsabilidade de privacidade.
O Que Registar#
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; // For correlating with application logs
}
async function auditLog(entry: AuditLogEntry): Promise<void> {
// Write to a separate, append-only data store
// This should NOT be the same database your application uses
await auditDb.collection("audit_logs").insertOne({
...entry,
timestamp: new Date().toISOString(),
});
// For critical actions, also write to an immutable external log
if (isCriticalAction(entry.action)) {
await externalLogger.send(entry);
}
}Registe estes eventos:
- Autenticação: logins, logouts, tentativas falhadas, renovações de token
- Autorização: eventos de acesso negado (estes são frequentemente indicadores de ataque)
- Modificações de dados: criações, atualizações, eliminações — quem mudou o quê, quando
- Ações de admin: alterações de papéis, gestão de utilizadores, alterações de configuração
- Eventos de segurança: ativações de rate limit, violações de CORS, pedidos mal formados
O Que NÃO Registar#
Nunca registe:
- Senhas (mesmo hasheadas — o hash é uma credencial)
- Números completos de cartão de crédito (registe apenas os últimos 4 dígitos)
- Números de Segurança Social ou documentos de identidade
- Chaves de API ou tokens (registe no máximo um prefixo:
sk_live_...abc) - Informação pessoal de saúde
- Corpos de pedido completos que possam conter 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;
}Logs à Prova de Adulteração#
Se um atacante ganhar acesso ao seu sistema, uma das primeiras coisas que vai fazer é modificar os logs para cobrir os seus rastros. O logging à prova de adulteração torna isto detetável:
import crypto from "crypto";
let previousHash = "GENESIS"; // The initial hash in the chain
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 };
}
// To verify the chain integrity:
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 is broken — logs have been tampered with
}
expectedPreviousHash = hash;
}
return true;
}Este é o mesmo conceito que um blockchain — o hash de cada entrada de log depende da entrada anterior. Se alguém modificar ou eliminar uma entrada, a cadeia quebra.
Segurança de Dependências#
O seu código pode ser seguro. Mas e os 847 pacotes npm no seu node_modules? O problema da cadeia de fornecimento é real, e tem piorado ao longo dos anos.
npm audit É o Mínimo#
# Run this in CI, fail the build on high/critical vulnerabilities
npm audit --audit-level=high
# Fix what can be auto-fixed
npm audit fix
# See what you're actually pulling in
npm ls --allMas o npm audit tem limitações. Só verifica a base de dados de avisos do npm, e as suas classificações de severidade nem sempre são precisas. Adicione ferramentas adicionais:
Scanning Automatizado de Dependências#
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
reviewers:
- "your-team"
labels:
- "dependencies"
# Group minor and patch updates to reduce PR noise
groups:
production-dependencies:
patterns:
- "*"
update-types:
- "minor"
- "patch"O Lockfile É uma Ferramenta de Segurança#
Faça sempre commit do seu package-lock.json (ou pnpm-lock.yaml, ou yarn.lock). O lockfile fixa versões exatas de cada dependência, incluindo as transitivas. Sem ele, o npm install pode puxar uma versão diferente da que testou — e essa versão diferente pode estar comprometida.
# In CI, use ci instead of install — it respects the lockfile strictly
npm ciO npm ci falha se o lockfile não coincidir com o package.json, em vez de atualizá-lo silenciosamente. Isto apanha casos onde alguém modificou o package.json mas esqueceu-se de atualizar o lockfile.
Avalie Antes de Instalar#
Antes de adicionar uma dependência, pergunte:
- Preciso mesmo disto? Posso escrever isto em 20 linhas em vez de adicionar um pacote?
- Quantos downloads tem? Contagens de downloads baixas não são necessariamente más, mas significam menos olhos a rever o código.
- Quando foi atualizado pela última vez? Um pacote que não é atualizado há 3 anos pode ter vulnerabilidades não corrigidas.
- Quantas dependências puxa?
is-odddepende deis-numberque depende dekind-of. São três pacotes para fazer algo que uma linha de código consegue. - Quem mantém? Um único mantenedor é um único ponto de compromisso.
// You don't need a package for this:
const isEven = (n: number): boolean => n % 2 === 0;
// Or this:
const leftPad = (str: string, len: number, char = " "): string =>
str.padStart(len, char);
// Or this:
const isNil = (value: unknown): value is null | undefined =>
value === null || value === undefined;O Checklist de Pré-Deploy#
Este é o checklist real que uso antes de cada deploy em produção. Não é exaustivo — a segurança nunca está "concluída" — mas apanha os erros que mais importam.
| # | Verificação | Critério de Aprovação | Prioridade |
|---|---|---|---|
| 1 | Autenticação | JWTs verificados com algoritmo, emissor e audiência explícitos. Sem alg: none. | Crítica |
| 2 | Expiração de token | Access tokens expiram em 15 min ou menos. Refresh tokens rodam no uso. | Crítica |
| 3 | Armazenamento de token | Refresh tokens em httpOnly secure cookies. Sem tokens no localStorage. | Crítica |
| 4 | Autorização em cada endpoint | Cada endpoint de acesso a dados verifica permissões ao nível do objeto. BOLA testado. | Crítica |
| 5 | Validação de input | Todo input de utilizador validado com Zod ou equivalente. Sem req.body raw em queries. | Crítica |
| 6 | SQL/NoSQL injection | Todas as queries usam queries parametrizadas ou métodos ORM. Sem concatenação de strings. | Crítica |
| 7 | Rate limiting | Endpoints de auth: 5/15min. API geral: 60/min. Headers de rate limit retornados. | Alta |
| 8 | CORS | Allowlist de origens explícita. Sem wildcard com credenciais. Preflight em cache. | Alta |
| 9 | Headers de segurança | CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy todos presentes. | Alta |
| 10 | Tratamento de erros | Erros em produção retornam mensagens genéricas. Sem stack traces, sem erros SQL expostos. | Alta |
| 11 | Secrets | Sem secrets no código ou histórico git. .env no .gitignore. Validados no arranque. | Crítica |
| 12 | Dependências | npm audit limpo (sem high/critical). Lockfile commitado. npm ci no CI. | Alta |
| 13 | Apenas HTTPS | HSTS ativado com preload. HTTP redireciona para HTTPS. Flag secure no cookie definida. | Crítica |
| 14 | Logging | Eventos de auth, acesso negado e mutações de dados registados. Sem PII nos logs. | Média |
| 15 | Limites de tamanho de pedido | Body parser limitado (1MB padrão). Uploads limitados. Paginação de queries aplicada. | Média |
| 16 | Proteção SSRF | URLs fornecidos pelo utilizador validados. IPs privados bloqueados. Redirecionamentos desativados ou validados. | Média |
| 17 | Bloqueio de conta | Tentativas de login falhadas ativam bloqueio após 5 tentativas. Bloqueio registado. | Alta |
| 18 | Verificação de webhooks | Todos os webhooks recebidos verificados com assinaturas. Proteção contra replay via timestamp. | Alta |
| 19 | Endpoints de admin | Controle de acesso baseado em papéis em todas as rotas de admin. Tentativas registadas. | Crítica |
| 20 | Mass assignment | Endpoints de atualização usam schema Zod com campos na allowlist. Sem spread de body raw. | Alta |
Mantenho isto como um template de issue no GitHub. Antes de marcar uma release, alguém da equipa tem de verificar cada linha e aprovar. Não é glamoroso, mas funciona.
A Mudança de Mentalidade#
A segurança não é uma funcionalidade que se adiciona no final. Não é um sprint que se faz uma vez por ano. É uma forma de pensar sobre cada linha de código que escreve.
Quando escreve um endpoint, pense: "E se alguém enviar dados que não espero?" Quando adiciona um parâmetro, pense: "E se alguém mudar isto para o ID de outra pessoa?" Quando adiciona uma dependência, pense: "O que acontece se este pacote for comprometido na próxima terça-feira?"
Não vai apanhar tudo. Ninguém apanha. Mas percorrer este checklist — metodicamente, antes de cada deploy — apanha as coisas que mais importam. As vitórias fáceis. Os buracos óbvios. Os erros que transformam um dia mau numa violação de dados.
Construa o hábito. Percorra o checklist. Faça deploy com confiança.