Introdução

Quando eu vejo um erro de CORS no console do navegador, eu quase sempre vejo o mesmo reflexo no time alguns minutos depois: alguém coloca Access-Control-Allow-Origin: *, desabilita alguma proteção no browser de desenvolvimento e declara a tarefa como resolvida. Isso não resolve nada. Só mascara a regra de segurança que está impedindo um site qualquer de ler respostas sensíveis da sua API.

CORS não é um capricho do Chrome, do Firefox ou do Edge. Ele é o mecanismo que o servidor usa para dizer ao navegador quais origins podem ler uma resposta cross-origin. O browser aplica a regra, mas a decisão vem do backend ou da camada de borda. Se eu configuro isso de forma frouxa, eu não apenas elimino o erro visual do console: eu amplio a superfície de ataque da minha aplicação.

Este é o quinto artigo da série Segurança para Devs. No artigo sobre JWT, OAuth2 e OpenID Connect, eu cobri identidade e tokens. No artigo sobre BFF, eu mostrei como tirar token do browser. No artigo sobre API Gateway, eu discuti política centralizada de segurança na borda. Agora eu fecho uma lacuna que aparece em praticamente toda SPA, portal administrativo e integração web: como configurar CORS sem transformar uma proteção importante em enfeite decorativo.

Ao final deste artigo, eu vou mostrar o que CORS realmente protege, como a Same-Origin Policy funciona, quando o browser envia preflight OPTIONS, o que cada header faz, quais erros eu mais encontro em pentests e code reviews, e como configurar CORS corretamente no NGINX e no ASP.NET Core. Também vou separar uma confusão comum: CORS não substitui autenticação, autorização nem proteção contra CSRF.

⚠️ Atenção: Desligar CORS porque ele atrapalha é como remover o cinto de segurança porque ele aperta. O incômodo é sinal de que a proteção ainda está funcionando.

Pré-requisitos

Para acompanhar os exemplos com contexto real, eu recomendo:

  • Conhecimento básico de HTTP, headers e métodos como GET, POST, PUT e OPTIONS.
  • Familiaridade mínima com navegador, DevTools e requisições fetch.
  • No backend, alguma experiência com ASP.NET Core e pipeline de middlewares.
  • Na camada de borda, leitura básica de configuração NGINX.

Também ajuda ter lido ao menos os artigos anteriores da série, porque eu vou referenciar autorização, cookies HttpOnly, BFF e gateway várias vezes. Mesmo assim, este texto é autocontido e responde diretamente à pergunta principal: por que CORS existe e como eu configuro isso com segurança.

O Problema que o CORS Resolve: Same-Origin Policy

Antes de falar de headers, eu preciso falar de origin. Origin é a combinação de esquema + host + porta. Para o navegador, https://app.com e http://app.com não são a mesma coisa. https://app.com e https://api.app.com também não. A tabela abaixo resume os casos mais comuns:

URL da requisiçãoOrigin da páginaSame-origin?Motivo
https://app.com/apihttps://app.comSimmesmo esquema, host e porta
http://app.com/apihttps://app.comNãoesquema diferente
https://api.app.comhttps://app.comNãohost diferente
https://app.com:8443https://app.comNãoporta diferente

A partir dessa definição entra a Same-Origin Policy, ou SOP. Essa política do browser impede que um script carregado em uma origin leia livremente respostas vindas de outra. O ponto central aqui é o verbo ler. O navegador pode até permitir que alguns requests cross-origin sejam disparados, mas ele não entrega a resposta ao JavaScript da página se a política não for satisfeita.

Sem SOP, um site malicioso aberto pela vítima poderia enviar um fetch para o webmail dela, para o internet banking ou para o painel administrativo em que ela já está autenticada, reaproveitando cookies enviados automaticamente pelo navegador. Se a resposta voltasse livremente para o JavaScript do atacante, bastaria parsear o JSON ou o HTML e exfiltrar tudo. A SOP existe justamente para quebrar esse caminho.

E onde entra o CORS? CORS é a exceção controlada a essa regra. O servidor de destino informa, por headers HTTP, quais origins podem ler sua resposta. O browser recebe a resposta, verifica esses headers e decide se libera ou não o conteúdo para o JavaScript da página.

Esse detalhe explica uma confusão que eu vejo toda semana: curl, Postman, Insomnia e chamadas backend-para-backend não têm problema de CORS porque não existe navegador aplicando SOP. Se o endpoint responde 200 no Postman e falha no browser, o problema não é “a API está quebrada”. O problema é que o navegador está exigindo uma política explícita para aquela leitura cross-origin.

ℹ️ Informação: O header Origin vem do navegador e não pode ser sobrescrito pelo JavaScript da página. É por isso que o servidor consegue tomar decisão de política com base nele dentro de um contexto browser.

Anatomia de uma Requisição CORS: Simples vs Preflight

Nem toda requisição cross-origin passa pelo mesmo fluxo. O browser separa essas chamadas entre requisições simples e requisições com preflight.

Uma requisição simples é aquela que usa GET, HEAD ou POST, com headers limitados e Content-Type restrito a formatos como text/plain, application/x-www-form-urlencoded ou multipart/form-data. Nesses casos, o navegador envia a chamada real com o header Origin e avalia a resposta. Se o servidor devolver Access-Control-Allow-Origin compatível, o JavaScript pode ler o resultado.

Quando eu saio desse envelope e uso PUT, DELETE, PATCH, Authorization, Content-Type: application/json ou outros headers customizados, o browser entra em modo mais cauteloso. Antes da requisição real ele envia um OPTIONS chamado preflight perguntando se o servidor aceita aquela combinação de origin, método e headers.

sequenceDiagram participant B as Browser (app.com) participant S as Servidor (api.com) B->>S: OPTIONS /pedidos (Origin, Access-Control-Request-Method: PUT) S-->>B: 204 (Access-Control-Allow-Origin, -Allow-Methods, -Allow-Headers) Note over B: Browser valida o preflight B->>S: PUT /pedidos (Origin, Authorization, body JSON) S-->>B: 200 (Access-Control-Allow-Origin)

Fluxo de preflight CORS mostrando OPTIONS, headers de permissão e a requisição real

No fluxo acima, o browser quer garantir duas coisas antes de disparar a chamada real: se aquela origin pode acessar o recurso e se os elementos potencialmente mais sensíveis da chamada são permitidos. E aqui eu já chego em uma regra prática importante: se o seu OPTIONS falha, a requisição real nem chega a acontecer.

Este é um exemplo realista de preflight:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
OPTIONS /api/security/cors/pedidos HTTP/1.1
Origin: https://app.zocate.li
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Authorization, Content-Type

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.zocate.li
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600
Vary: Origin

Se essa resposta vier correta, o browser segue com a chamada real. Se vier incompleta, duplicada, com status incorreto ou sem os headers esperados, o DevTools mostra erro de CORS e o JavaScript não acessa a resposta.

💡 Dica: Access-Control-Max-Age reduz latência porque permite ao navegador cachear o resultado do preflight. Sem ele, cada chamada não-simples pode gerar um OPTIONS extra antes do request real.

Os Headers do CORS, Um a Um

Quando eu ensino CORS em mentoria, eu tento sair do “coloca qualquer coisa até parar o erro” e ir para uma leitura literal dos headers. Eles são poucos, mas cada um tem papel bem definido.

HeaderDireçãoFunção
OriginRequestidentifica a origin da página que fez a chamada
Access-Control-Allow-OriginResponseinforma qual origin pode ler a resposta
Access-Control-Allow-MethodsResponseinforma quais métodos são permitidos no recurso
Access-Control-Allow-HeadersResponseinforma quais headers customizados podem ser enviados
Access-Control-Allow-CredentialsResponsepermite envio de cookies e credenciais quando true
Access-Control-Expose-HeadersResponselibera leitura de headers de resposta pelo JavaScript
Access-Control-Max-AgeResponsedefine cache do preflight em segundos
Access-Control-Request-MethodRequestinforma qual método a chamada real deseja usar
Access-Control-Request-HeadersRequestinforma quais headers a chamada real quer enviar

O header mais famoso é Access-Control-Allow-Origin, mas ele também é o mais mal configurado. Se eu coloco *, eu estou dizendo que qualquer origin pode ler aquela resposta. Isso pode fazer sentido em um recurso totalmente público, como um catálogo estático sem autenticação. Em API autenticada, isso costuma ser sinal de erro conceitual.

Existe ainda uma regra que pega muita gente de surpresa: Access-Control-Allow-Origin: * não pode ser combinado com Access-Control-Allow-Credentials: true. O browser rejeita essa combinação porque credenciais exigem uma origin explícita. Se cookies, certificado de cliente ou Authorization estão em jogo, o servidor precisa ecoar uma origin validada, não um wildcard.

Outro header subestimado é Vary: Origin. Se a minha aplicação reflete dinamicamente a origin válida, caches intermediários e CDNs precisam saber que a resposta varia com esse header. Sem Vary: Origin, eu corro o risco de servir para https://app.zocate.li uma resposta cacheada montada para outra origin.

Por fim, Access-Control-Expose-Headers aparece quando o backend envia metadados úteis no response, como X-Total-Count, Location ou algum correlation id, e eu quero que o JavaScript possa lê-los. Sem esse header, muitos headers de resposta continuam invisíveis para a página, mesmo que a chamada tenha sido permitida.

ℹ️ Informação: O CORS confia no Origin como metadado do navegador. Ele não é mecanismo de autenticação. Se um endpoint for sensível, ele continua precisando validar token, sessão, claims e autorização no backend.

Os Erros Clássicos que Todo Time Comete

Depois de revisar dezenas de APIs, eu diria que os erros de CORS se repetem com padrão quase industrial. O primeiro é o mais óbvio: usar Access-Control-Allow-Origin: * em API autenticada. Muitas vezes o time faz isso para “destravar o front” sem perceber que está anunciando leitura global para qualquer página da internet. Se a API depende de cookies ou credenciais, o browser ainda vai bloquear em vários cenários. Se não depende, o estrago pode continuar existindo da mesma forma em recursos sensíveis expostos.

O segundo erro é mais perigoso porque parece sofisticado: refletir o Origin recebido sem validar. O código faz algo como response.Headers["Access-Control-Allow-Origin"] = request.Headers["Origin"] e pronto. Isso equivale a aceitar qualquer origin, mas com um detalhe pior: agora funciona inclusive com credenciais. Em pentest, esse tipo de configuração entra imediatamente na lista de achados relevantes.

O terceiro erro é a validação frouxa da allowlist. Eu vejo muito endsWith("exemplo.com"), que aceita evil-exemplo.com, e startsWith("https://exemplo.com"), que aceita https://exemplo.com.evil.com. Se a minha política depende de string matching ingênuo, a proteção já nasceu comprometida.

O quarto erro é tentar “resolver CORS no frontend”. Isso aparece como plugin do navegador, proxy improvisado indo para produção, flag para desabilitar web security ou gambiarra no dev server mascarando a ausência da política real. Em ambiente local isso pode até reduzir atrito temporariamente, mas não corrige o backend e ainda empurra a decisão de segurança para quem não deveria tomá-la.

O quinto erro é confundir CORS com autenticação ou autorização. Se uma origin está permitida, isso só significa que o browser poderá ler a resposta. Não significa que aquele usuário está autenticado, que o token é válido ou que o recurso pode ser acessado. A verificação de permissão continua sendo obrigação do backend.

O sexto erro, e talvez o mais irritante em produção, é fazer o OPTIONS passar por autenticação ou por redirect. Se o preflight recebe 401, 302 ou alguma resposta diferente do que o browser espera, a chamada real morre ali. Preflight precisa ser tratado de forma pública, rápida e previsível.

⚠️ Atenção: Refletir Origin sem validar junto com Access-Control-Allow-Credentials: true transforma um problema de configuração em uma vulnerabilidade de exfiltração de dados autenticados. Isso aparece com frequência em auditorias OWASP e Burp Suite.

CORS não é CSRF (e não protege contra ele)

Aqui está uma das confusões mais comuns em arquitetura web. CORS e CSRF não são o mesmo problema. CORS regula se o JavaScript de uma origin pode ler a resposta de outra origin. CSRF explora o fato de que o navegador pode enviar cookies automaticamente em uma requisição cross-origin, mesmo quando o atacante não consegue ler o retorno.

Imagine uma transferência bancária disparada por um formulário POST simples hospedado em um site malicioso. O atacante não precisa ler o JSON de resposta para causar estrago. Ele só precisa que o browser da vítima envie os cookies de sessão automaticamente para o domínio legítimo. Isso é CSRF. CORS pode até impedir que o JavaScript malicioso leia o resultado, mas a ação no servidor pode ter acontecido do mesmo jeito.

Por isso a defesa contra CSRF é outra: tokens anti-CSRF, cookies SameSite, validação de Origin ou Referer no servidor e, em muitos cenários modernos, o padrão BFF com controle claro de sessão. O artigo de JWT, OAuth2 e OpenID Connect ajuda a separar identidade de transporte, e o artigo de API Gateway mostra onde políticas transversais entram na arquitetura.

Eu gosto de resumir assim: CORS decide se a página pode ver. CSRF explora o fato de que o navegador pode enviar. São verbos diferentes, riscos diferentes e controles diferentes.

💡 Dica: CORS relaxa a Same-Origin Policy de forma controlada. CSRF abusa do envio automático de cookies. Configurar um não elimina o outro.

Configurando CORS no NGINX

No meu stack, NGINX normalmente aparece como reverse proxy e camada de borda. Isso torna tentadora a ideia de resolver CORS ali mesmo. Em muitos casos isso é uma boa escolha, principalmente para recursos estáticos, APIs consolidadas atrás do mesmo proxy ou quando eu quero centralizar política de borda. O que eu não posso fazer é responder CORS no NGINX e no backend ao mesmo tempo, porque headers duplicados costumam fazer o browser rejeitar tudo.

O anti-pattern clássico é este:

1
2
3
4
5
# Não use isso em API autenticada
location /api/ {
    add_header Access-Control-Allow-Origin *;
    proxy_pass http://backend;
}

Isso parece simples, mas é inseguro para API com credenciais e insuficiente para preflight. A versão segura precisa de allowlist, resposta consistente para OPTIONS, Vary: Origin e always para cobrir inclusive respostas de erro.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
map $http_origin $cors_origin {
    default "";
    "https://app.zocate.li" $http_origin;
    "https://admin.zocate.li" $http_origin;
}

location /api/security/cors/ {
    if ($cors_origin != "") {
        add_header Access-Control-Allow-Origin $cors_origin always;
        add_header Access-Control-Allow-Credentials true always;
        add_header Vary Origin always;
    }

    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin $cors_origin always;
        add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Demo-Token" always;
        add_header Access-Control-Max-Age 600 always;
        return 204;
    }

    proxy_pass http://backend_cors;
}

📂 Código Fonte: O exemplo completo está disponível no repositório de exemplos do blog: BlogSamples/Security/Cors/

Eu uso map porque ele me ajuda a manter a allowlist explícita e evita comparações frouxas de string espalhadas pela configuração. O if no NGINX precisa de cuidado, mas neste caso ele está servindo para aplicar header apenas quando a origin foi previamente validada. Já o always é importante porque, sem ele, vários headers não aparecem em respostas 4xx e 5xx, o que complica diagnóstico e pode quebrar comportamento esperado no browser.

Outro detalhe relevante: OPTIONS não deve exigir autenticação. O preflight precisa ser respondido antes da chamada real. Se eu redireciono para login ou passo o OPTIONS por middleware de auth sem exceção, o browser nem chega ao endpoint de negócio.

E preciso repetir a regra mais importante desta seção: escolha uma camada para responder CORS. Se o NGINX adiciona Access-Control-Allow-Origin e o ASP.NET Core faz a mesma coisa, o resultado frequente é uma resposta inválida do ponto de vista do navegador.

⚠️ Atenção: Dois headers Access-Control-Allow-Origin na mesma resposta costumam invalidar a política inteira. Se a borda responde CORS, o backend deve parar de responder o mesmo bloco.

Configurando CORS no ASP.NET Core

No ASP.NET Core, eu prefiro trabalhar com named policies. Elas deixam explícita a intenção de cada grupo de endpoints e reduzem a tentação de sair espalhando AllowAnyOrigin() pela aplicação inteira. Para o sample deste artigo, eu criei uma policy chamada FrontendSpa com allowlist fechada, AllowCredentials() e cache de preflight por dez minutos.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
builder.Services.AddCors(options =>
{
    options.AddPolicy("FrontendSpa", policy =>
    {
        policy.SetIsOriginAllowed(origin =>
                  origin is "https://app.zocate.li" or "https://admin.zocate.li")
              .WithMethods("GET", "POST", "PUT", "DELETE")
              .WithHeaders("Authorization", "Content-Type")
              .AllowCredentials()
              .SetPreflightMaxAge(TimeSpan.FromMinutes(10));
    });
});

app.UseCors();

app.MapGroup("/api/security/cors")
   .RequireCors("FrontendSpa");

📂 Código Fonte: O exemplo completo está disponível no repositório de exemplos do blog: BlogSamples/Security/Cors/

Esse modelo tem três vantagens. A primeira é clareza: eu consigo olhar para o nome da policy e saber em que contexto ela se aplica. A segunda é segurança: quando eu uso AllowCredentials(), o framework me empurra para origins explícitas ou para uma validação dinâmica bem pensada. A terceira é manutenção: eu consigo aplicar política diferente para BlazorWasm, para um endpoint público de leitura ou para uma SPA autenticada sem misturar cenários.

Existe ainda uma proteção interessante do próprio framework: AllowAnyOrigin().AllowCredentials() gera exceção em runtime. Isso é bom. O ASP.NET Core está impedindo uma combinação que o browser não aceita e que quase sempre sinaliza decisão insegura.

Outro ponto crítico é a ordem do middleware. Quando eu uso UseCors, ele precisa entrar antes do mapeamento de endpoints e, em pipelines mais complexos, antes da autorização do recurso que depende do preflight. Se a resposta do OPTIONS ficar bloqueada por outra camada, a política não chega a ser aplicada.

No sample do artigo eu também deixei dois comportamentos diferentes para ficar didático: um endpoint público que aceita apenas GET com AllowAnyOrigin() e um endpoint privado que continua retornando 401 se um header de demo não vier. O objetivo é mostrar que CORS aprovado não equivale a acesso autorizado.

Dicas e Boas Práticas

  • Use allowlist explícita para APIs autenticadas. Eu prefiro listar cada origin confiável ou validar contra um conjunto fechado. Isso evita wildcard acidental e reduz espaço para bypass de validação.

  • Escolha uma única camada para responder CORS. Ou o NGINX responde, ou o backend responde. Quando os dois tentam ajudar ao mesmo tempo, o browser costuma interpretar como erro.

  • Sempre envie Vary: Origin quando refletir origin validada. Sem isso, proxies e CDNs podem servir resposta errada para outra origin e abrir um bug difícil de rastrear.

  • Mantenha o preflight público, rápido e previsível. OPTIONS deve responder 204 ou 200 sem redirecionamento para login. Se o preflight falha, a chamada real nem começa.

  • Não trate CORS como autorização. O backend continua responsável por token, sessão, claims e políticas de permissão. CORS só define o que o browser deixa ler.

  • Teste origins maliciosas de forma automatizada. Eu gosto de incluir no teste de integração ao menos um Origin: https://evil.example e verificar que a API não ecoa a origin nem libera credentials.

Exemplo Prático

Vamos colocar tudo em um cenário fim a fim. Imagine uma SPA em https://app.zocate.li consumindo uma API protegida em https://api.zocate.li atrás de NGINX. O frontend envia cookies ou credenciais e precisa consultar um endpoint privado.

No browser, a chamada pode ser assim:

1
2
3
4
5
6
7
await fetch("https://api.zocate.li/api/security/cors/privado", {
  method: "GET",
  credentials: "include",
  headers: {
    "X-Demo-Token": "token-seguro"
  }
});

Se a policy FrontendSpa estiver correta, o browser envia Origin: https://app.zocate.li, recebe Access-Control-Allow-Origin: https://app.zocate.li e segue normalmente. Se eu trocar a origin para https://evil.example, a requisição pode até chegar ao servidor em alguns cenários, mas o navegador não entrega a resposta ao JavaScript porque a política não a autoriza.

No sample do artigo, eu criei um teste de integração exatamente para isso:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
[Fact]
public async Task Get_Com_Origin_Nao_Permitida_Nao_Deve_Retornar_Header_Cors()
{
    using var client = _factory.CreateClient();
    using var request = new HttpRequestMessage(
        HttpMethod.Get,
        "/api/security/cors/diagnostico");

    request.Headers.Add("Origin", "https://evil.example");

    using var response = await client.SendAsync(request);

    Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    Assert.False(response.Headers.TryGetValues("Access-Control-Allow-Origin", out _));
}

📂 Código Fonte: O exemplo completo está disponível no repositório de exemplos do blog: BlogSamples/Security/Cors/

Eu também gosto de testar o preflight, porque ele é onde muita configuração falha por detalhe. No sample, o OPTIONS para /api/security/cors/pedidos valida Origin, Access-Control-Request-Method e Access-Control-Request-Headers, e o teste confirma que os headers de resposta esperados aparecem com status 204 No Content.

O comportamento esperado no DevTools é simples:

  • origin permitida: OPTIONS retorna 204, a chamada real acontece e o frontend lê a resposta.
  • origin não permitida: o request pode aparecer na aba Network, mas o console registra erro de CORS e a página não acessa o response.
  • credencial ausente ou token inválido: CORS passa, mas o backend devolve 401 ou 403 normalmente.

Esse último caso é o mais importante para fechar o conceito: CORS é filtro de leitura no browser; autorização continua sendo regra de negócio e segurança do servidor.

Resumo Objetivo

  • CORS é o mecanismo que permite ao servidor relaxar a Same-Origin Policy no navegador, definindo por headers quais origins podem ler uma resposta cross-origin.
  • Same-Origin Policy compara esquema, host e porta; mudar qualquer um desses elementos transforma a chamada em cross-origin para o browser.
  • Preflight é uma requisição OPTIONS enviada antes de chamadas não-simples, como PUT, DELETE, Authorization ou Content-Type: application/json.
  • Access-Control-Allow-Origin: * não pode ser combinado com Access-Control-Allow-Credentials: true; credenciais exigem uma origin explícita e validada.
  • Refletir Origin sem validar equivale a aceitar qualquer site e pode permitir exfiltração de dados autenticados quando combinado com credentials.
  • NGINX deve responder CORS com allowlist, always e Vary: Origin quando essa for a camada escolhida; duplicar headers com o backend quebra a resposta no browser.
  • ASP.NET Core oferece policies nomeadas e impede em runtime a combinação insegura AllowAnyOrigin().AllowCredentials().
  • CORS não protege contra CSRF; CORS regula leitura de resposta, enquanto CSRF explora o envio automático de cookies e exige defesas específicas.

Leia Também

Referências