DEV Community

João Abreu
João Abreu

Posted on AI-assisted

Why my GETs worked but every POST got a 403: a CORS bug behind nginx in Docker

I'm a CS student building a tutoring management system: a Spring Boot REST API, a React frontend and PostgreSQL. While preparing the v1.2 release, I added a Docker Compose setup so anyone could run the whole stack with one command.

It almost worked. The pages loaded, the lists showed up... and every time I clicked a button that changed something, the API answered:

403 Forbidden
Invalid CORS request
Enter fullscreen mode Exit fullscreen mode

This post is about why that happened, why only some requests failed, and the one-line fix.

The setup

Three containers:

services:
  db:        # PostgreSQL
  api:       # Spring Boot, port 8080
  frontend:  # nginx serving the React build, exposed as localhost:3000
Enter fullscreen mode Exit fullscreen mode

The frontend doesn't call the API directly. nginx proxies everything under /api/ to the api container, so from the browser's point of view the frontend and the API live on the same origin, http://localhost:3000:

location /api/ {
    proxy_pass http://api:8080/;
    proxy_set_header Host $host;
}
Enter fullscreen mode Exit fullscreen mode

On the Spring side, I already had a CORS config for local development, when the frontend runs on Vite's dev server:

registry.addMapping("/**")
        .allowedOrigins("http://localhost:5173")
        .allowedMethods("GET", "POST", "PATCH");
Enter fullscreen mode Exit fullscreen mode

Same origin through the proxy, so CORS shouldn't even matter here. Right?

The weird part: only POST and PATCH failed

Listing subjects and sessions (GET) worked fine. Suggesting a subject, joining a session or approving something (POST/PATCH) all returned 403.

That was the clue. Browsers send the Origin header on POST and PATCH requests even when they are same-origin, but not on same-origin GETs. Spring only runs its CORS checks when an Origin header is present. So the GETs never went through the CORS logic at all, and the others did.

How Spring decides whether a request is "cross-origin"

When a request has an Origin header, Spring compares it with the request's own scheme, host and port. Spring builds that from the Host header it receives. If they match, it's a same-origin request and CORS is skipped. If they don't, it's a CORS request and the origin must be in allowedOrigins.

Now look at what each side saw:

Value
Origin sent by the browser http://localhost:3000
Host forwarded by nginx with $host localhost (no port)
What Spring thinks it is http://localhost (port 80)

localhost:3000 ≠ localhost:80, so Spring treated a same-origin request as cross-origin. And since http://localhost:3000 wasn't in my allowed origins (only the Vite one was), it rejected it with Invalid CORS request.

The culprit was $host. In nginx, $host is the host name without the port. $http_host is the Host header exactly as the browser sent it, with the port.

The fix

One variable:

location /api/ {
    proxy_pass http://api:8080/;
    # $http_host keeps the port (localhost:3000), so Spring sees the same origin as the
    # browser and skips CORS; $host drops it and every POST/PATCH failed with 403.
    proxy_set_header Host $http_host;
}
Enter fullscreen mode Exit fullscreen mode

Now Spring sees Host: localhost:3000, which matches Origin: http://localhost:3000, and the request is same-origin again. No CORS check, no 403.

Why I didn't just add localhost:3000 to allowedOrigins

That would also make the error go away, and it's the first answer you find online. But it hides the real problem: the requests are same-origin, the proxy was just lying to the backend about where they came from. Fixing the header keeps the CORS config meaningful (it only lists origins that are really different, like the Vite dev server) and avoids a whitelist that has to change every time the port changes.

What I took away from it

  • "Only some methods fail" is a strong hint. In this case it pointed straight at the Origin header.
  • A reverse proxy changes what the backend sees. Headers like Host decide how the app understands the request, so they're worth checking first.
  • Fix the cause, not the symptom. Widening CORS would have worked, but it would have been the wrong fix.

The project is open source if you want to see the full Docker setup: tutoring-management-system. The fix is in v1.2.

Have you ever been bitten by a proxy header? I'd like to hear how you debugged it.


🇧🇷 Versão em português

Por que meus GETs funcionavam, mas todo POST dava 403: um bug de CORS atrás do nginx no Docker

Sou estudante de Ciência da Computação e estou construindo um sistema de gestão de monitorias: uma API REST em Spring Boot, um frontend em React e PostgreSQL. Preparando a versão 1.2, criei um Docker Compose pra qualquer pessoa rodar o sistema inteiro com um único comando.

Quase funcionou. As páginas carregavam, as listas apareciam... e toda vez que eu clicava num botão que alterava alguma coisa, a API respondia:

403 Forbidden
Invalid CORS request
Enter fullscreen mode Exit fullscreen mode

Este post explica por que isso aconteceu, por que só algumas requisições falhavam e a correção de uma linha.

O cenário

Três containers:

services:
  db:        # PostgreSQL
  api:       # Spring Boot, porta 8080
  frontend:  # nginx servindo o build do React, exposto em localhost:3000
Enter fullscreen mode Exit fullscreen mode

O frontend não chama a API diretamente. O nginx repassa tudo que está em /api/ para o container api, então, do ponto de vista do navegador, frontend e API estão na mesma origem, http://localhost:3000:

location /api/ {
    proxy_pass http://api:8080/;
    proxy_set_header Host $host;
}
Enter fullscreen mode Exit fullscreen mode

No Spring, eu já tinha uma configuração de CORS para o desenvolvimento local, quando o frontend roda no servidor do Vite:

registry.addMapping("/**")
        .allowedOrigins("http://localhost:5173")
        .allowedMethods("GET", "POST", "PATCH");
Enter fullscreen mode Exit fullscreen mode

Mesma origem pelo proxy, então o CORS nem deveria importar aqui. Certo?

A parte estranha: só POST e PATCH falhavam

Listar matérias e sessões (GET) funcionava. Sugerir uma matéria, entrar numa sessão ou aprovar algo (POST/PATCH) devolvia 403.

Essa foi a pista. O navegador envia o cabeçalho Origin em requisições POST e PATCH mesmo quando são da mesma origem, mas não em GETs da mesma origem. E o Spring só roda as verificações de CORS quando existe um cabeçalho Origin. Ou seja, os GETs nem passavam pela lógica de CORS, e os outros passavam.

Como o Spring decide se uma requisição é "cross-origin"

Quando a requisição tem Origin, o Spring compara esse valor com o esquema, host e porta da própria requisição, que ele monta a partir do cabeçalho Host que recebe. Se baterem, é mesma origem e o CORS é ignorado. Se não, é uma requisição CORS e a origem precisa estar em allowedOrigins.

Veja o que cada lado enxergava:

Valor
Origin enviado pelo navegador http://localhost:3000
Host repassado pelo nginx com $host localhost (sem porta)
O que o Spring acha que é http://localhost (porta 80)

localhost:3000 ≠ localhost:80, então o Spring tratou uma requisição da mesma origem como cross-origin. E como http://localhost:3000 não estava nas origens permitidas (só a do Vite), ele rejeitou com Invalid CORS request.

O culpado era o $host. No nginx, $host é o nome do host sem a porta. Já $http_host é o cabeçalho Host exatamente como o navegador enviou, com a porta.

A correção

Uma variável:

location /api/ {
    proxy_pass http://api:8080/;
    proxy_set_header Host $http_host;
}
Enter fullscreen mode Exit fullscreen mode

Agora o Spring recebe Host: localhost:3000, que bate com Origin: http://localhost:3000, e a requisição volta a ser da mesma origem. Sem verificação de CORS, sem 403.

Por que não adicionei localhost:3000 no allowedOrigins

Isso também faria o erro sumir, e é a primeira resposta que aparece na internet. Mas esconde o problema real: as requisições são da mesma origem, o proxy é que estava informando errado ao backend de onde elas vinham. Corrigir o cabeçalho mantém a configuração de CORS com sentido (ela só lista origens realmente diferentes, como o servidor do Vite) e evita uma lista que precisa mudar toda vez que a porta mudar.

O que eu levei disso

  • "Só alguns métodos falham" é uma pista forte. Nesse caso, apontou direto para o cabeçalho Origin.
  • Um proxy reverso muda o que o backend enxerga. Cabeçalhos como Host definem como a aplicação entende a requisição, então vale olhar para eles primeiro.
  • Corrija a causa, não o sintoma. Liberar mais origens no CORS funcionaria, mas seria a correção errada.

O projeto é open source, se quiser ver o Docker completo: tutoring-management-system. A correção está na v1.2.

Já foi pego por um cabeçalho de proxy? Conta como você depurou.

Top comments (0)