Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIntermediáriocódigo testado

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.

Rodolfo Mori11 min de leitura

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.

bash
node -v
npm ls express cors
v24.16.0 padaria@1.0.0 /private/tmp/padaria ├── cors@2.8.6 └── express@5.2.1

O 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.

js
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:

bash
curl -s -i http://localhost:3200/cardapio
HTTP/1.1 200 OK X-Powered-By: Express Content-Type: application/json; charset=utf-8 Content-Length: 144 ETag: W/"90-r8BiWkOdOfzv2P0Wj7RWI6BH0XU" Date: Sat, 22 Aug 2026 22:56:11 GMT Connection: keep-alive

[{“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:

html
<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:

API da padaria em http://localhost:3200 GET /cardapio -> 200 OPTIONS /pedidos -> 200

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:

Access to fetch at 'http://localhost:3200/cardapio' from origin 'http://localhost:5173' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. Failed to load resource: net::ERR_FAILED Access to fetch at 'http://localhost:3200/pedidos' from origin 'http://localhost:5173' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

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.

Navegador :5173 API :3200 OPTIONS /pedidos + Origin + Access-Control-Request-Method 204 + Access-Control-Allow-Origin / Methods / Headers sem esses headers, o fluxo para aqui POST /pedidos com o JSON de verdade 201 + Access-Control-Allow-Origin

Dá para reproduzir o preflight na mão, sem navegador nenhum:

bash
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"
HTTP/1.1 200 OK X-Powered-By: Express Allow: POST Content-Length: 4 Content-Type: text/plain X-Content-Type-Options: nosniff Date: Sat, 22 Aug 2026 22:56:49 GMT Connection: keep-alive

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.

bash
npm i cors
js
import 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:

HTTP/1.1 204 No Content X-Powered-By: Express Access-Control-Allow-Origin: * Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE Vary: Access-Control-Request-Headers Access-Control-Allow-Headers: content-type Content-Length: 0

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:

Pão francês — R$ 0.90 Sonho de doce de leite — R$ 7.50 Café coado 200ml — R$ 4.00

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 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':

js
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());
Access to fetch at 'http://localhost:3200/login' from origin 'http://localhost:5173' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.

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:

js
app.use(cors({ origin: 'http://localhost:5173' }));
Access to fetch at 'http://localhost:3200/login' from origin 'http://localhost:5173' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'.

São duas chaves separadas, e as duas precisam estar giradas: credentials: 'include' no fetch e credentials: true no servidor.

js
app.use(cors({ origin: 'http://localhost:5173', credentials: true }));
relatório: {"vendasHoje":1284.5,"pedidos":96}

Os headers da resposta mudam de forma:

HTTP/1.1 200 OK X-Powered-By: Express Access-Control-Allow-Origin: http://localhost:5173 Vary: Origin Access-Control-Allow-Credentials: true Content-Type: application/json; charset=utf-8

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:

js
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:

bash
ORIGENS_LIBERADAS="http://localhost:5173,https://painel.paonosso.com.br" node api.js
origens liberadas: [ 'http://localhost:5173', 'https://painel.paonosso.com.br' ] API da padaria em http://localhost:3200

Agora batendo na mesma rota com três origens diferentes:

bash
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-origin
Access-Control-Allow-Origin: http://localhost:5173 Access-Control-Allow-Origin: https://painel.paonosso.com.br (nenhuma linha)

A 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:

bash
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"
HTTP/1.1 204 No Content Vary: Origin, Access-Control-Request-Headers Access-Control-Allow-Origin: http://localhost:5173 Access-Control-Allow-Credentials: true Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE Access-Control-Allow-Headers: content-type Access-Control-Max-Age: 600 Access-Control-Expose-Headers: X-Ultima-Fornada

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:

js
app.get('/cardapio', (req, res) => {
  res.set('X-Ultima-Fornada', '06:40');
  res.json(cardapio);
});

No curl ele aparece. No navegador, sem exposedHeaders, some:

js
const resposta = await fetch('http://localhost:3200/cardapio');
console.log('X-Ultima-Fornada:', resposta.headers.get('X-Ultima-Fornada'));
X-Ultima-Fornada: null

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:

X-Ultima-Fornada: 06:40

Dá para ver a peneira funcionando. Esta linha lista o que o fetch enxerga:

js
const resposta = await fetch('http://localhost:3200/cardapio');
console.log('headers visíveis:', [...resposta.headers.keys()].join(', '));
headers visíveis: content-length, content-type, x-ultima-fornada

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.

js
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:

HTTP/1.1 401 Unauthorized Access-Control-Allow-Origin: http://localhost:5173 Access-Control-Allow-Headers: Content-Type, Authorization

{“erro”:“token ausente”}

E no Chrome:

Access to fetch at 'http://localhost:3201/admin/relatorio' from origin 'http://localhost:5173' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: It does not have HTTP ok status.

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:

js
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();
});
status: 200

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:

js
import express from 'express';
import cors from 'cors';

const app = express();
app.options('*', cors());
app.listen(3203);
PathError [TypeError]: Missing parameter name at index 1: *; visit https://git.new/pathToRegexpError for info at consumeUntil (/private/tmp/padaria/node_modules/path-to-regexp/dist/index.js:108:27) at parse (/private/tmp/padaria/node_modules/path-to-regexp/dist/index.js:140:26) at pathToRegexp (/private/tmp/padaria/node_modules/path-to-regexp/dist/index.js:274:5) at app.<computed> [as options] (/private/tmp/padaria/node_modules/express/lib/application.js:478:22) { originalPath: '*' } Node.js v24.16.0

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:

js
const resposta = await fetch('http://localhost:3200/admin/relatorio');
console.log('status:', resposta.status);
console.log('corpo:', JSON.stringify(await resposta.json()));
Failed to load resource: the server responded with a status of 401 (Unauthorized) status: 401 corpo: {"erro":"sem sessão"}

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.

Ver todos os vídeos do canal
  • cors
  • express
  • navegador
  • preflight
  • seguranca
  • api

Perguntas frequentes

Por que o Insomnia e o Postman funcionam e o navegador não?
Porque CORS é uma regra que só o navegador aplica. Insomnia, Postman e curl são clientes HTTP comuns: eles não têm o conceito de origem da página, então não têm o que comparar e simplesmente entregam a resposta.
Dá para resolver CORS mexendo só no front-end?
Não. O header vem do servidor, e é ele quem precisa mudar. O que dá para fazer no front, em desenvolvimento, é usar o proxy do servidor de dev (server.proxy no Vite, rewrites no Next): o navegador passa a falar com a própria origem do front, que repassa a chamada para a API por trás — e sem duas origens não existe CORS. Em produção, quem responde é o servidor.
Aquela extensão do Chrome que desativa o CORS resolve?
Resolve na sua máquina e em mais nenhuma. Todo mundo que abrir o site vai continuar tomando o bloqueio. Extensão desse tipo serve, no máximo, para confirmar rapidamente que o problema é CORS mesmo — nunca como correção.
Preciso de CORS se o front e a API estão no mesmo domínio?
Se a origem for idêntica em protocolo, domínio e porta, não. Repare na porta e no subdomínio: painel.exemplo.com e api.exemplo.com são origens diferentes, e localhost:5173 e localhost:3200 também.
O que é uma requisição simples, que não dispara preflight?
GET, HEAD ou POST com Content-Type de formulário (application/x-www-form-urlencoded, multipart/form-data ou text/plain) e sem header customizado. Mandar JSON com Content-Type application/json já sai dessa lista e dispara o preflight.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

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

  1. MDN — Cross-Origin Resource Sharing (CORS) — developer.mozilla.org
  2. Express — CORS middleware — expressjs.com
  3. Fetch Standard — CORS protocol — fetch.spec.whatwg.org

Continue por aqui