CORS em Node.js: configuração segura

CORS (Cross-Origin Resource Sharing) é o mecanismo que permite, ou bloqueia, que um frontend em domínio diferente consuma sua API. Configurar Access-Control-Allow-Origin como "*" é a forma mais rápida de criar uma API, mas também a mais perigosa. Configuração correta com whitelist de origens protege contra ataques CSRF cross-origin.

CORS com whitelist de origens no Express

Configuração segura com lista de origens permitidas:

typescript
// npm install cors @types/cors

import cors from 'cors'
import type { CorsOptions } from 'cors'

const ORIGENS_PERMITIDAS = [
  'https://seudominio.com.br',
  'https://www.seudominio.com.br',
  'https://app.seudominio.com.br',
  // Desenvolvimento:
  ...(process.env.NODE_ENV === 'development'
    ? ['http://localhost:3000', 'http://localhost:5173']
    : []),
]

const corsOptions: CorsOptions = {
  origin(origin, callback) {
    // Permitir requests sem origin (curl, Postman, server-to-server):
    if (!origin) return callback(null, true)

    if (ORIGENS_PERMITIDAS.includes(origin)) {
      callback(null, true)
    } else {
      callback(new Error(`Origem não permitida pelo CORS: ${origin}`))
    }
  },
  credentials: true,          // permitir cookies e headers de auth
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key', 'X-Org-Id'],
  exposedHeaders: ['X-RateLimit-Limit', 'X-RateLimit-Remaining'],
  maxAge: 86400,              // cache do preflight por 24h (reduz OPTIONS requests)
}

app.use(cors(corsOptions))

// Para rotas específicas com CORS diferente:
app.get('/api/public/embed', cors({ origin: '*' }), embedHandler)

Preflight requests e OPTIONS

Como o browser verifica o CORS antes de requisições complexas:

bash
# O browser envia um preflight OPTIONS antes de:
# - Métodos não-simples (PUT, DELETE, PATCH)
# - Headers customizados (Authorization, X-API-Key)
# - Content-Type != text/plain ou multipart/form-data

# Exemplo de preflight:
# OPTIONS /api/pedidos HTTP/1.1
# Origin: https://seudominio.com.br
# Access-Control-Request-Method: POST
# Access-Control-Request-Headers: Content-Type, Authorization

# Resposta esperada do servidor:
# HTTP/1.1 204 No Content
# Access-Control-Allow-Origin: https://seudominio.com.br
# Access-Control-Allow-Methods: GET, POST, PUT, DELETE
# Access-Control-Allow-Headers: Content-Type, Authorization
# Access-Control-Max-Age: 86400
# Vary: Origin

# Garantir que o Express responde ao OPTIONS antes de outros middlewares:
app.options('*', cors(corsOptions))  // habilitar preflight para todas as rotas

# Para Nginx — adicionar headers CORS no proxy (alternativa ao Express):
# /etc/nginx/conf.d/api.conf:
server {
    location /api/ {
        add_header Access-Control-Allow-Origin $http_origin always;
        add_header Access-Control-Allow-Credentials true always;
        add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;

        if ($request_method = OPTIONS) {
            add_header Access-Control-Max-Age 86400;
            return 204;
        }
        proxy_pass http://127.0.0.1:3000;
    }
}

CORS com subdomínios dinâmicos

Permitir qualquer subdomínio do seu domínio principal:

typescript
// Para multi-tenant onde cada cliente tem seu subdomínio:
// cliente1.seuapp.com.br, cliente2.seuapp.com.br, ...

const corsOptions: CorsOptions = {
  origin(origin, callback) {
    if (!origin) return callback(null, true)

    // Regex para subdomínios do domínio principal:
    const dominioBase = /^https:\/\/([a-z0-9-]+\.)?seuapp\.com\.br$/

    if (dominioBase.test(origin)) {
      callback(null, true)
    } else if (process.env.NODE_ENV === 'development' && origin.startsWith('http://localhost:')) {
      callback(null, true)
    } else {
      callback(new Error('Origem não permitida'))
    }
  },
  credentials: true,
}

// Para APIs públicas com autenticação por API Key:
// Não precisa de CORS restritivo — a API Key já autentica
// Mas NUNCA use credentials: true com origin: '*'
// (browsers não suportam essa combinação por razões de segurança)

// Verificar a configuração atual em produção:
curl -I -H "Origin: https://seudominio.com.br" \
     -H "Access-Control-Request-Method: POST" \
     -X OPTIONS \
     https://api.seudominio.com.br/api/pedidos
# Deve retornar: Access-Control-Allow-Origin: https://seudominio.com.br

CORS no Fastify

Configuração equivalente para APIs Fastify:

typescript
// npm install @fastify/cors

import Fastify from 'fastify'
import cors from '@fastify/cors'

const app = Fastify({ logger: true })

await app.register(cors, {
  origin: (origin, cb) => {
    if (!origin) return cb(null, true)

    const permitidas = ['https://seudominio.com.br', 'https://app.seudominio.com.br']
    if (permitidas.includes(origin)) {
      cb(null, true)
    } else {
      cb(new Error('Origem não permitida'), false)
    }
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  maxAge: 86400,
  preflight: true,
  strictPreflight: true,  // rejeitar preflights com headers não declarados
})

// Sobrescrever CORS para rota específica:
app.get('/embed', {
  config: {
    cors: { origin: '*', credentials: false },
  },
}, async (req, reply) => {
  return { embeddable: true }
})

Erros comuns de CORS e como resolver

Diagnóstico dos erros mais frequentes:

bash
# Erro: "has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header"
# Causa: servidor não retorna o header CORS
# Solução: verificar se o middleware cors está ANTES das rotas
# Errado:
app.get('/api/dados', handler)
app.use(cors())  # ← cors depois das rotas não funciona!
# Correto:
app.use(cors())  # ← primeiro
app.get('/api/dados', handler)

# Erro: "has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin'
#        header must not be the wildcard '*' when credentials mode is 'include'"
# Causa: credentials: true com origin: '*'
# Solução: especificar a origem exata:
# credentials: true + origin: 'https://seudominio.com.br'  ✅
# credentials: true + origin: '*'  ❌ (inválido por spec)

# Erro: "CORS preflight response did not succeed"
# Causa: servidor retorna 4xx/5xx no OPTIONS
# Solução: adicionar app.options('*', cors()) ANTES de middlewares de auth:
app.options('*', cors(corsOptions))  # antes de qualquer auth middleware

# Erro: "Response to preflight request doesn't pass access control check:
#        It does not have HTTP ok status."
# Causa: middleware de auth bloqueando o OPTIONS
# Solução: pular auth para OPTIONS:
app.use((req, res, next) => {
  if (req.method === 'OPTIONS') return next()
  return autenticarMiddleware(req, res, next)
})

Não quer configurar manualmente?

Implante o VPS para APIs em menos de 5 minutos com a Runstack. Infraestrutura da OPEN Datacenter, com servidores em São Paulo.

Perguntas frequentes

Do browser. O servidor retorna headers indicando quais origens são permitidas; o browser decide se executa ou bloqueia a resposta. Um cliente como curl, Postman ou server-to-server ignora o CORS completamente, recebe a resposta independente dos headers. Por isso, CORS não substitui autenticação e autorização no servidor: ele apenas controla o acesso de browsers.

Para APIs verdadeiramente públicas sem cookies ou headers de autenticação: widgets embeds, APIs de dados públicos (clima, câmbio), SDKs JavaScript que não usam credenciais. Nunca com credentials: true. Nunca para endpoints autenticados. Se a API retorna dados pessoais ou realiza ações, sempre use whitelist de origens.

Não, são mecanismos diferentes. CORS: controla quais origens podem fazer fetch/XHR para a API (do frontend para o backend). CSP (Content-Security-Policy): controla quais recursos (scripts, imagens, iframes) podem ser carregados pela página (do backend para o frontend). CORS é configurado na API; CSP é configurado no servidor de frontend ou via meta tag. Ambos são necessários para segurança completa.

maxAge define por quanto tempo o browser pode usar o resultado de um preflight sem enviar um novo OPTIONS. Sem maxAge (ou com valor baixo): o browser envia um OPTIONS antes de cada POST/PUT/DELETE, latência extra em cada requisição não-simples. Com maxAge: 86400 (1 dia): o browser usa o cache e não envia OPTIONS repetidos. Reduz significativamente o número de requests em aplicações de alto tráfego.

Nginx: CORS resolvido antes de chegar ao Node.js, zero overhead na aplicação, configuração centralizada para múltiplas APIs. Express/Fastify: mais flexível (CORS dinâmico por rota, whitelist programática, lógica condicional), mais fácil de testar. Para produção com APIs complexas: aplicação (mais controle). Para APIs simples atrás de Nginx: Nginx (menos código, menos dependências).