CORS no Express: liberar o front sem abrir a API inteira
Por que o navegador bloqueia a chamada, o que o preflight OPTIONS faz e como configurar origem, credenciais e headers sem liberar tudo com asterisco.
CORS, sigla de Cross-Origin Resource Sharing, é o protocolo que permite ao servidor declarar quais origens podem ler suas respostas no navegador. Quando a página e a API têm protocolo, domínio ou porta diferentes, o navegador só entrega a resposta ao JavaScript se os headers de CORS autorizarem aquela origem.
Pense num prédio com portaria. A página é a visitante, a API é o apartamento e
o header Access-Control-Allow-Origin é a autorização deixada pelo morador. Em
algumas visitas, o porteiro telefona antes para perguntar método e headers: essa
consulta é o preflight OPTIONS. Tecnicamente, quem aplica o bloqueio é o
navegador; a API pode até processar a requisição, mas sem a autorização correta
o JavaScript da página não recebe a resposta.
Todos os exemplos rodam na API da Padaria Pão Nosso: /cardapio lista o que saiu
do forno, /pedidos recebe pedido do balcão e /admin/relatorio mostra o caixa
do dia. A API sobe em http://localhost:3200 (a porta 3000 estava ocupada nesta
máquina) e o painel do balcão é servido em http://localhost:5173. Duas portas
diferentes já são duas origens diferentes.
node -v
npm ls express corsO bloqueio é do navegador, e o log do servidor prova
Esta é a API sem uma linha de CORS. Repare no middleware de log: ele imprime todo par requisição/resposta que o Express realmente atendeu.
import express from 'express';
const app = express();
app.use(express.json());
app.use((req, res, next) => {
res.on('finish', () => {
console.log(`${req.method} ${req.url} -> ${res.statusCode}`);
});
next();
});
const cardapio = [
{ id: 1, nome: 'Pão francês', preco: 0.9 },
{ id: 2, nome: 'Sonho de doce de leite', preco: 7.5 },
{ id: 3, nome: 'Café coado 200ml', preco: 4.0 },
];
app.get('/cardapio', (req, res) => res.json(cardapio));
app.post('/pedidos', (req, res) => {
res.status(201).json({ id: 87, itens: req.body.itens ?? [] });
});
app.listen(3200, () => console.log('API da padaria em http://localhost:3200'));Pelo terminal, com curl, a rota responde perfeitamente:
curl -s -i http://localhost:3200/cardapio[{“id”:1,“nome”:“Pão francês”,“preco”:0.9},{“id”:2,“nome”:“Sonho de doce de leite”,“preco”:7.5},{“id”:3,“nome”:“Café coado 200ml”,“preco”:4}]
Agora o mesmo /cardapio, chamado com fetch de
uma página servida na porta 5173:
<script type="module">
const resposta = await fetch('http://localhost:3200/cardapio');
const itens = await resposta.json();
document.querySelector('#lista').innerHTML = itens
.map((i) => `<li>${i.nome} — R$ ${i.preco.toFixed(2)}</li>`)
.join('');
</script>E o log do servidor, depois de abrir essa página no Chrome e também disparar um
POST /pedidos com Content-Type: application/json:
Duas informações importantes aqui. A primeira: o GET /cardapio chegou no
servidor e foi respondido com 200. O navegador recebeu a resposta inteira e a
jogou fora antes de entregar ao seu código. A segunda: não existe linha
POST /pedidos. O POST nunca saiu do navegador — no lugar dele saiu um
OPTIONS, que é o assunto de duas seções abaixo.
A mensagem que o Chrome escreve no console
Esta é a saída literal do console do Google Chrome 151, capturada nas duas chamadas da seção anterior:
Leia a mensagem por pedaços, porque cada um aponta para um lugar diferente:
| pedaço | o que significa | onde se conserta |
|---|---|---|
from origin 'http://localhost:5173' |
quem está chamando | é o valor que a API precisa autorizar |
blocked by CORS policy |
quem bloqueou | o navegador, não a API |
No 'Access-Control-Allow-Origin' header |
o que faltou na resposta | no servidor, no header |
Response to preflight request |
o bloqueio foi na consulta prévia | na resposta ao OPTIONS |
Do lado do JavaScript, o fetch rejeita com um genérico TypeError: Failed to fetch. Ele não conta o motivo de propósito: se contasse, a página maliciosa
descobriria coisas sobre a API só de tentar. O motivo fica só no console.
Preflight: a requisição OPTIONS que você não escreveu
Requisições consideradas simples pelo navegador — GET, HEAD e POST de
formulário, sem header customizado — vão direto. Qualquer coisa fora disso ganha
uma consulta prévia, o preflight: um OPTIONS que pergunta “posso mandar um
POST com Content-Type: application/json daqui?”.
Mandar JSON já é motivo suficiente. É por isso que quase toda API REST moderna vive com preflight em cima.
Dá para reproduzir o preflight na mão, sem navegador nenhum:
curl -s -i -X OPTIONS http://localhost:3200/pedidos \
-H "Origin: http://localhost:5173" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type"POST
Repare no detalhe cruel: o Express respondeu 200. Ele tem um tratamento
automático para OPTIONS e devolve o header Allow com os métodos que existem
naquele caminho. Um 200 bonito, e mesmo assim o navegador barra — porque nenhum
Access-Control-* apareceu na resposta. Status bom não é resposta de preflight
válida.
O pacote cors: uma linha e o painel volta a carregar
O middleware oficial resolve isso. Ele lê o Origin da requisição e escreve os
headers certos, inclusive interceptando o OPTIONS antes que ele chegue às suas
rotas.
npm i corsimport express from 'express';
import cors from 'cors';
const app = express();
app.use(cors());
app.use(express.json());
app.get('/cardapio', (req, res) => res.json(cardapio));
app.post('/pedidos', (req, res) => {
res.status(201).json({ id: 87, itens: req.body.itens ?? [] });
});O mesmo curl -X OPTIONS de antes, agora:
Mudou o status (204, que é o certo para preflight), apareceram os três headers e
o Access-Control-Allow-Headers veio ecoando exatamente o que o navegador pediu.
No Chrome, o painel do balcão e o botão de novo pedido passam a funcionar:
pedido criado: {id: 87, itens: Array(2)}
Onde você registra o cors() importa tanto quanto registrar. Ele é um
middleware comum: vale para o que vem depois
dele na fila. app.use(cors()) na primeira linha cobre a API inteira, inclusive
os erros — e é isso que você quer na maioria dos casos.
O asterisco funciona até você precisar de cookie
O cors() sem opção nenhuma responde Access-Control-Allow-Origin: *: qualquer
site do mundo pode ler /cardapio. Para um cardápio público, tudo bem. Para o
relatório do caixa, não.
E existe um limite técnico, não só de gosto. O relatório depende de um cookie de
sessão, então o front precisa mandar credentials: 'include':
await fetch('http://localhost:3200/login', {
method: 'POST',
credentials: 'include',
});
const resposta = await fetch('http://localhost:3200/admin/relatorio', {
credentials: 'include',
});
console.log('relatório:', await resposta.json());A especificação proíbe a combinação. Faria sentido * com cookie? Seria dizer
“qualquer site pode ler dados autenticados deste usuário” — exatamente o ataque
que CORS existe para impedir. Com credencial na jogada, a origem tem que ser
nomeada.
credentials: true é um par, e os dois lados precisam bater
Trocar o asterisco por uma origem específica ainda não basta:
app.use(cors({ origin: 'http://localhost:5173' }));São duas chaves separadas, e as duas precisam estar giradas: credentials: 'include' no fetch e credentials: true no servidor.
app.use(cors({ origin: 'http://localhost:5173', credentials: true }));Os headers da resposta mudam de forma:
O Vary: Origin entrou sozinho e não é enfeite. Ele avisa proxies e CDNs de que
a resposta muda conforme o Origin da requisição. Sem ele, um cache poderia
guardar a resposta com o Access-Control-Allow-Origin de um cliente e servir
para outro — e aí você tem um bug que só acontece em produção, atrás do CDN, de
forma intermitente. O pacote cors põe o Vary para você; quem escreve os
headers na mão costuma esquecer.
Liberando por lista: dev, staging e produção
Na vida real são três ou quatro origens, e elas mudam por ambiente. A opção
origin aceita um array — e o valor vem de
variável de ambiente, nunca cravado no
código:
const origensLiberadas = (process.env.ORIGENS_LIBERADAS ?? 'http://localhost:5173')
.split(',')
.map((origem) => origem.trim())
.filter(Boolean);
app.use(
cors({
origin: origensLiberadas,
credentials: true,
exposedHeaders: ['X-Ultima-Fornada'],
maxAge: 600,
}),
);Subindo a API com a lista dos dois ambientes:
ORIGENS_LIBERADAS="http://localhost:5173,https://painel.paonosso.com.br" node api.jsAgora batendo na mesma rota com três origens diferentes:
curl -s -i -H "Origin: http://localhost:5173" http://localhost:3200/cardapio | grep -i allow-origin
curl -s -i -H "Origin: https://painel.paonosso.com.br" http://localhost:3200/cardapio | grep -i allow-origin
curl -s -i -H "Origin: https://clone-da-padaria.com" http://localhost:3200/cardapio | grep -i allow-originA origem não autorizada recebeu 200 com o cardápio inteiro no corpo — só sem o header. De novo: a API entrega, o navegador é que descarta. Se aquele dado não pode vazar, o que falta ali é autenticação, não CORS.
O maxAge: 600 manda o navegador guardar o resultado do preflight por dez
minutos, poupando um OPTIONS por chamada. Confira no preflight completo:
curl -s -i -X OPTIONS http://localhost:3200/pedidos \
-H "Origin: http://localhost:5173" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type" | grep -Ei "^HTTP|^Access-Control|^Vary"O header que a API mandou e o front não consegue ler
Esse exposedHeaders resolve um problema que confunde muita gente. A rota
/cardapio devolve a hora da última fornada num header próprio:
app.get('/cardapio', (req, res) => {
res.set('X-Ultima-Fornada', '06:40');
res.json(cardapio);
});No curl ele aparece. No navegador, sem exposedHeaders, some:
const resposta = await fetch('http://localhost:3200/cardapio');
console.log('X-Ultima-Fornada:', resposta.headers.get('X-Ultima-Fornada'));O navegador só entrega ao JavaScript uma lista curta e fixa de headers de
resposta — Cache-Control, Content-Language, Content-Length,
Content-Type, Expires, Last-Modified e Pragma. Qualquer outro precisa
ser liberado por nome. Com exposedHeaders: ['X-Ultima-Fornada'] na
configuração, a mesma linha imprime:
Dá para ver a peneira funcionando. Esta linha lista o que o fetch enxerga:
const resposta = await fetch('http://localhost:3200/cardapio');
console.log('headers visíveis:', [...resposta.headers.keys()].join(', '));Nessa mesma resposta o curl mostra doze headers, incluindo ETag, Date e
Vary. O navegador escondeu tudo que não estava na lista fixa nem no
exposedHeaders. É a mesma armadilha de quem devolve total de páginas em
X-Total-Count e vê null no front achando que o back não mandou.
Preflight que morre em 401: a ordem do cors na fila
Este é o bug que mais consome tempo. Para isolá-lo, o relatório subiu numa segunda API, na porta 3201: ela exige token, e o CORS foi escrito na mão, depois da autenticação.
app.use((req, res, next) => {
res.set('Access-Control-Allow-Origin', 'http://localhost:5173');
res.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
next();
});
app.use((req, res, next) => {
if (!req.headers.authorization) {
return res.status(401).json({ erro: 'token ausente' });
}
next();
});
app.get('/admin/relatorio', (req, res) => res.json({ vendasHoje: 1284.5 }));Parece certo: os headers estão lá. Mas o preflight é um OPTIONS sem
Authorization — o navegador nunca manda credencial na consulta prévia. Ele cai
no middleware de autenticação e leva 401:
{“erro”:“token ausente”}
E no Chrome:
It does not have HTTP ok status é a assinatura desse bug: os headers estavam
certos, o status é que não. A correção é registrar o cors() antes de
qualquer middleware que possa responder sozinho — ele intercepta o OPTIONS e
devolve 204 sem deixar a fila continuar:
app.use(cors({ origin: 'http://localhost:5173' }));
app.use((req, res, next) => {
if (!req.headers.authorization) {
return res.status(401).json({ erro: 'token ausente' });
}
next();
});O app.options('*', cors()) que os tutoriais mandam copiar
Metade das respostas de fórum ainda manda escrever esta linha. No Express 5 ela não sobe:
import express from 'express';
import cors from 'cors';
const app = express();
app.options('*', cors());
app.listen(3203);O Express 5 trocou o roteador e '*' sozinho deixou de ser um caminho válido; a
forma nova é '/{*qualquer}'. Só que a linha inteira é desnecessária: como você
viu no curl de preflight, app.use(cors()) já responde ao OPTIONS de todas
as rotas. Apague em vez de corrigir.
CORS resolvido e ainda dá 401: são dois problemas
Com o CORS certo, o erro para de ser CORS — e passa a ser o seu. Aqui o painel
esqueceu o credentials: 'include' na chamada do relatório:
const resposta = await fetch('http://localhost:3200/admin/relatorio');
console.log('status:', resposta.status);
console.log('corpo:', JSON.stringify(await resposta.json()));Compare com o começo do artigo. Lá o fetch rejeitava e você não conseguia
ler nada. Aqui ele resolveu normalmente, e o seu código leu o status e o corpo
do erro. Essa é a linha divisória, e ela vale como diagnóstico:
| sintoma no front | o que é | onde mexer |
|---|---|---|
TypeError: Failed to fetch e erro vermelho de CORS no console |
o navegador bloqueou | header no servidor |
status legível (401, 403, 404, 500) |
a resposta chegou inteira | a lógica da rota, o token, a sessão |
resposta chega mas headers.get(...) é null |
header não exposto | exposedHeaders |
| funciona no Insomnia e falha no navegador | só o navegador aplica CORS | header no servidor |
Se o status code aparece no seu
console.log, feche a aba do CORS: o problema virou autenticação, validação ou
rota. E é aí que entram
login e rota protegida com JWT.
O que vem depois
A configuração final desta lição é curta e cabe na primeira rota que você já
escreveu em Express do zero: uma lista de
origens vinda do ambiente; credentials: true quando a autenticação depende de
cookie; exposedHeaders para o que o front precisa ler; e o cors() sempre em
primeiro na fila.
O próximo passo natural é dar à API um lugar único para tratar tudo que dá
errado, em vez de espalhar try/catch por rota. A
trilha de Node segue por aí.
Faça o diagnóstico completo com duas chamadas. Primeiro, rode curl -i -X OPTIONS enviando Origin e Access-Control-Request-Method; confirme status
2xx e Access-Control-Allow-Origin. Depois faça o fetch no navegador. Se o
curl não trouxer os headers, corrija o Express; se trouxer e o front ler um
status como 401, encerre a investigação de CORS e trate a autenticação.
Prefere aprender em vídeo?
Tem uma aula sobre este assunto no nosso canal.
Perguntas frequentes
Por que o Insomnia e o Postman funcionam e o navegador não?
Dá para resolver CORS mexendo só no front-end?
Aquela extensão do Chrome que desativa o CORS resolve?
Preciso de CORS se o front e a API estão no mesmo domínio?
O que é uma requisição simples, que não dispara preflight?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em Node 24.16.0, Express 5.2.1, cors 2.8.6 e Google Chrome 151, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- MDN — Cross-Origin Resource Sharing (CORS) — developer.mozilla.org
- Express — CORS middleware — expressjs.com
- Fetch Standard — CORS protocol — fetch.spec.whatwg.org



