Ir para o conteúdo
Backend

Segurança em APIs Node.js: OWASP Top 10 na Prática

Marcos Soares
Atualizado em 
12 minutos de leitura
Ilustracao 3D de estrutura de servidor blindada com fissuras luminosas representando seguranca OWASP em APIs Node.js
Ouça este artigo
0:00Segurança em APIs Node.js: OWASP Top 10 na Prática--:--

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.

Neste artigo

A maioria das APIs Node.js em produção falha em pelo menos 4 dos 10 itens do OWASP

O OWASP Top 10 para APIs (edição 2023) não é uma lista teórica. É um catálogo de vulnerabilidades que aparecem em auditorias reais, em APIs reais, escritas por times competentes. O problema é que a maioria dos devs lê o documento uma vez, concorda com tudo e não implementa nada.

Este post pega os itens mais relevantes para APIs Node.js (Express e Fastify), mostra o código vulnerável, explica por que quebra e entrega a correção funcional. Sem abstrações vagas: cada bloco de código roda.

Se você já tem uma API em produção e quer saber onde estão os buracos, comece pela matriz de priorização no final. Se quer entender cada vulnerabilidade, leia na ordem.

API1:2023 — Broken Object Level Authorization (BOLA)

A vulnerabilidade mais comum em APIs REST. O endpoint recebe um ID, busca o recurso e retorna sem verificar se o usuário autenticado tem permissão para acessá-lo.

TypeScript
// VULNERÁVEL: qualquer usuário autenticado acessa qualquer pedido
import express from "express";
import { prisma } from "./db";
 
const app = express();
 
app.get("/orders/:id", async (req, res) => {
  const order = await prisma.order.findUnique({
    where: { id: req.params.id },
  });
 
  if (!order) return res.status(404).json({ error: "Pedido não encontrado" });
 
  // Retorna sem checar se o pedido pertence ao usuário logado
  return res.json(order);
});

A correção é filtrar no nível da query, não depois:

TypeScript
// CORRIGIDO: a query já inclui o userId como filtro
import express from "express";
import { prisma } from "./db";
import { authenticate } from "./middleware/auth";
 
const app = express();
 
app.get("/orders/:id", authenticate, async (req, res) => {
  const order = await prisma.order.findFirst({
    where: {
      id: req.params.id,
      // Filtrar na query garante que o banco nunca retorna dado alheio,
      // mesmo que a lógica de aplicação tenha bugs depois
      userId: req.user.id,
    },
  });
 
  if (!order) return res.status(404).json({ error: "Pedido não encontrado" });
 
  return res.json(order);
});

A diferença entre findUnique + checagem posterior e findFirst com filtro composto parece sutil, mas importa: se você checa depois da query, qualquer refatoração que esqueça o if vaza dados. Quando o filtro está na query, o banco é a barreira. Esse padrão se conecta diretamente com a modelagem de domínio que discutimos em Domain-Driven Design na Prática com TypeScript e Prisma: o aggregate root controla o acesso.

API2:2023 — Broken Authentication

Tokens JWT sem validação adequada, sem expiração curta, sem rotação. O erro clássico é confiar no payload do token sem verificar a assinatura com o algoritmo correto.

TypeScript
// VULNERÁVEL: aceita qualquer algoritmo que o token declare
import jwt from "jsonwebtoken";
 
function verifyToken(token: string) {
  // algorithms não especificado = aceita "none" em algumas versões
  return jwt.verify(token, process.env.JWT_SECRET!);
}
TypeScript
// CORRIGIDO: força algoritmo, valida issuer e audience
import jwt from "jsonwebtoken";
 
interface TokenPayload {
  sub: string;
  role: string;
}
 
function verifyToken(token: string): TokenPayload {
  const payload = jwt.verify(token, process.env.JWT_SECRET!, {
    // Forçar HS256 impede ataques de troca de algoritmo (ex: "none" ou RS256 com chave pública)
    algorithms: ["HS256"],
    issuer: "vivo-de-codigo-api",
    audience: "vivo-de-codigo-client",
    maxAge: "15m",
  }) as TokenPayload;
 
  return payload;
}

O maxAge: "15m" não substitui refresh tokens. Para APIs com sessões longas, combine access token curto (15 min) com refresh token opaco armazenado no banco, revogável. O post sobre APIs reais que não quebram em produção cobre a arquitetura de camadas onde essa validação se encaixa.

API3:2023 — Broken Object Property Level Authorization

O usuário envia campos que não deveria poder alterar. Exemplo: um PATCH que aceita { role: "admin" } porque o handler faz spread direto do body.

TypeScript
// VULNERÁVEL: mass assignment clássico
app.patch("/users/:id", authenticate, async (req, res) => {
  const updated = await prisma.user.update({
    where: { id: req.params.id },
    // req.body pode conter role, isVerified, creditBalance...
    data: req.body,
  });
  return res.json(updated);
});
TypeScript
// CORRIGIDO: allowlist explícita com Zod
import { z } from "zod";
 
const UpdateUserSchema = z.object({
  name: z.string().min(1).max(100).optional(),
  email: z.string().email().optional(),
  // role, isVerified, creditBalance NÃO estão no schema.
  // Zod descarta campos extras com .strict() ou simplesmente não os parseia.
});
 
app.patch("/users/:id", authenticate, async (req, res) => {
  const result = UpdateUserSchema.safeParse(req.body);
 
  if (!result.success) {
    return res.status(400).json({ errors: result.error.flatten().fieldErrors });
  }
 
  const updated = await prisma.user.update({
    where: { id: req.params.id },
    data: result.data,
  });
 
  return res.json(updated);
});

Validação de payload com Zod (ou qualquer schema validator) é a primeira linha de defesa. Sem isso, cada endpoint é uma superfície de mass assignment. A abordagem de API Layers em Aplicações Fullstack centraliza essa validação na camada certa.

API4:2023 — Unrestricted Resource Consumption (Rate Limiting)

Uma API sem rate limiting aceita 10 mil requests por segundo de um único IP. Isso não é hipotético: bots fazem credential stuffing em endpoints de login sem throttle o tempo todo.

TypeScript
// Rate limiting com express-rate-limit
import express from "express";
import rateLimit from "express-rate-limit";
 
const app = express();
 
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutos
  max: 100, // 100 requests por janela por IP
  standardHeaders: true,
  legacyHeaders: false,
  // Mensagem clara para o consumidor da API
  message: { error: "Limite de requests excedido. Tente novamente em 15 minutos." },
});
 
// Limiter mais agressivo para login: 5 tentativas por minuto
const loginLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 5,
  message: { error: "Muitas tentativas de login. Aguarde 1 minuto." },
});
 
app.use("/api/", apiLimiter);
app.use("/api/auth/login", loginLimiter);

Para Fastify, o equivalente é @fastify/rate-limit:

TypeScript
import Fastify from "fastify";
import rateLimitPlugin from "@fastify/rate-limit";
 
const app = Fastify();
 
await app.register(rateLimitPlugin, {
  max: 100,
  timeWindow: "15 minutes",
  // Redis como store quando há múltiplas instâncias atrás de load balancer
  // redis: redisClient,
});
 
app.get("/api/health", async () => ({ status: "ok" }));

Se a API roda em múltiplas instâncias (Kubernetes, ECS, Workers), o rate limiter in-memory não funciona: cada instância conta separadamente. Nesse cenário, use Redis como store compartilhado. O post sobre DevOps para Devs detalha essa topologia.

API5:2023 — Broken Function Level Authorization

Endpoints administrativos acessíveis por usuários comuns. O middleware de autenticação existe, mas o de autorização não.

TypeScript
// VULNERÁVEL: qualquer usuário autenticado deleta qualquer usuário
app.delete("/admin/users/:id", authenticate, async (req, res) => {
  await prisma.user.delete({ where: { id: req.params.id } });
  return res.status(204).send();
});
TypeScript
// CORRIGIDO: middleware de autorização por role
import { Request, Response, NextFunction } from "express";
 
function authorize(...allowedRoles: string[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user || !allowedRoles.includes(req.user.role)) {
      // 403 e não 401: o usuário está autenticado, mas sem permissão
      return res.status(403).json({ error: "Sem permissão para esta operação" });
    }
    next();
  };
}
 
app.delete(
  "/admin/users/:id",
  authenticate,
  authorize("admin"),
  async (req, res) => {
    await prisma.user.delete({ where: { id: req.params.id } });
    return res.status(204).send();
  }
);

A distinção entre 401 e 403 importa para consumidores da API. 401 significa "não sei quem você é", 403 significa "sei quem você é, mas você não pode fazer isso". Misturar os dois dificulta o debug no frontend. Se você trabalha com feature flags, o mesmo middleware de autorização pode condicionar acesso a funcionalidades em rollout gradual.

O que NÃO fazer: anti-patterns de segurança que parecem corretos

Sanitização manual de SQL

TypeScript
// ERRADO: sanitização manual é frágil e incompleta
const query = `SELECT * FROM users WHERE email = '${email.replace(/'/g, "''")}'`;

Essa abordagem falha com encodings multibyte, unicode tricks e edge cases que ninguém lembra. Use prepared statements sempre:

TypeScript
// CORRETO: prepared statement via Prisma (ou qualquer ORM/query builder)
const user = await prisma.user.findFirst({
  where: { email: email },
});
 
// Se precisar de raw query, use $queryRaw com template literal tagged
const users = await prisma.$queryRaw`
  SELECT id, email FROM users WHERE email = ${email}
`;
// Prisma escapa o parâmetro automaticamente com template tagged

Helmet com configuração default e falsa sensação de segurança

TypeScript
// INSUFICIENTE: helmet() sem configuração explícita
import helmet from "helmet";
app.use(helmet());
// "Pronto, estou seguro" — não está
TypeScript
// MELHOR: configuração explícita dos headers que importam para APIs
import helmet from "helmet";
 
app.use(
  helmet({
    // APIs JSON não servem HTML, então CSP é irrelevante
    contentSecurityPolicy: false,
    // Impede que o browser faça MIME sniffing
    noSniff: true,
    // Remove header que expõe o framework
    hidePoweredBy: true,
  })
);
 
// CORS explícito: não use origin: "*" em produção
import cors from "cors";
app.use(
  cors({
    origin: ["https://app.seudominio.com"],
    methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
    credentials: true,
  })
);

Logs que vazam dados sensíveis

TypeScript
// ERRADO: loga o body inteiro, incluindo senhas e tokens
app.use((req, res, next) => {
  console.log("Request body:", JSON.stringify(req.body));
  next();
});
TypeScript
// CORRETO: redact de campos sensíveis antes de logar
import pino from "pino";
 
const logger = pino({
  redact: {
    paths: ["req.headers.authorization", "req.body.password", "req.body.token", "req.body.creditCard"],
    censor: "[REDACTED]",
  },
});

Esse tipo de vazamento em logs é API8:2023 (Security Misconfiguration) na prática. Logs vão para Datadog, CloudWatch, Elasticsearch, e qualquer pessoa com acesso a essas ferramentas vê senhas em texto plano. O post sobre lacunas críticas em APIs toca nesse problema de observabilidade mal configurada.

Matriz de priorização: o que corrigir primeiro

Nem todos os itens do OWASP Top 10 têm o mesmo impacto em toda API. A prioridade depende do tipo de aplicação:

Vulnerabilidade OWASPAPI pública (SaaS B2B)API interna (microserviço)API mobile (BFF)
BOLA (API1)CríticaAltaCrítica
Broken Auth (API2)CríticaMédia (rede interna)Crítica
Mass Assignment (API3)AltaAltaAlta
Rate Limiting (API4)CríticaBaixaAlta
Broken Function Auth (API5)CríticaAltaAlta
Server-Side Request Forgery (API7)MédiaAlta (acesso a rede interna)Baixa
Security Misconfiguration (API8)AltaAltaAlta
Improper Inventory (API9)Alta (endpoints esquecidos)Crítica (shadow APIs)Média

APIs internas atrás de service mesh não precisam de rate limiting agressivo, mas são alvos primários de SSRF porque têm acesso a recursos internos. APIs públicas precisam de rate limiting antes de qualquer coisa porque bots chegam no primeiro dia.

Se sua API é um BFF para mobile, BOLA e autenticação são prioridade zero: o app mobile faz requests com IDs que o usuário controla, e qualquer proxy HTTP (Charles, mitmproxy) intercepta e modifica esses IDs. O post sobre fetch, retry e timeout mostra como construir o client HTTP que consome essas APIs de forma resiliente.

Validação de input como política, não como feature

Validação de input não é "uma coisa que você faz no controller". É uma política que permeia toda a API. Cada endpoint que aceita dados externos sem validação é uma superfície de ataque.

A abordagem mais sustentável em APIs Node.js: Zod no handler, validação de tipo no build (TypeScript strict), constraints no banco (Prisma schema com @db.VarChar(255), unique constraints, check constraints).

Três camadas, três momentos, três responsáveis. Se uma falha, as outras seguram. Isso é defesa em profundidade aplicada a uma API REST comum.

Para quem usa migrations com Prisma em produção, as constraints do banco são a última barreira: mesmo que a aplicação tenha bug, o banco rejeita dados inválidos.

FAQ

Helmet resolve segurança de API? Não. Helmet configura headers HTTP que protegem contra ataques baseados em browser (XSS, clickjacking, MIME sniffing). Para APIs que servem JSON e são consumidas por outros serviços ou SPAs, a maioria dos headers do Helmet é irrelevante. O que importa para APIs é CORS configurado corretamente, rate limiting, validação de input e autorização. Helmet não substitui nenhum desses.

JWT ou session cookie para APIs? Depende do consumidor. Se a API é consumida por SPA no mesmo domínio, session cookie com httpOnly, secure e sameSite=strict é mais seguro porque o browser gerencia o cookie e o JavaScript não tem acesso ao token. Se a API é consumida por mobile, CLI ou outros serviços, JWT com access token curto (15 min) e refresh token opaco no banco funciona melhor. Misturar os dois para públicos diferentes é válido.

Preciso implementar todos os 10 itens do OWASP? Não de uma vez. Use a matriz de priorização acima. Comece por BOLA e autenticação (API1 e API2): juntos, cobrem a maioria dos incidentes reais em APIs. Rate limiting (API4) é o terceiro passo. Os demais itens entram conforme a superfície de ataque da sua aplicação cresce.

Zod ou Joi para validação? Zod, se o projeto é TypeScript. A inferência de tipos do Zod (z.infer<typeof Schema>) elimina a duplicação entre schema de validação e tipo TypeScript. Joi funciona, mas exige manutenção paralela de tipos. Para projetos JavaScript puro, tanto faz.

express-rate-limit funciona com múltiplas instâncias? Não com o store padrão (in-memory). Cada instância mantém contadores separados, então um atacante recebe N vezes o limite configurado (onde N é o número de instâncias). Use rate-limit-redis como store compartilhado quando a API roda em mais de uma instância.

Segurança não é checklist, é superfície de ataque contínua

A tentação é tratar OWASP como uma lista de tarefas: implementou os 10, está seguro. Isso é falso. Cada endpoint novo, cada campo aceito no body, cada integração com serviço externo expande a superfície de ataque.

O que funciona na prática é tratar segurança como constraint de design, não como feature. Cada PR que adiciona um endpoint deveria responder: quem pode acessar, o que pode enviar, e o que acontece quando envia lixo. Se essas três perguntas não têm resposta no código (não no comentário, no código), o endpoint não está pronto para review.

Marcos Soares

Escrito por

Marcos Soares

Fullstack Developer · CEO da Agência Poti

Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.

Comentários

Participe da discussão

Seja o primeiro a comentar!

Continue Aprofundando

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.