Minimal reproducible example for a regression in Astro 6 where the dev server
returns 403 Cross-site POST form submissions are forbidden for every form
submission when running behind a TLS-terminating reverse proxy (Caddy, Traefik,
nginx, etc.).
Astro's checkOrigin CSRF middleware does a strict equality check:
request.headers.get("origin") === context.url.origin
The browser sends Origin: https://mre.local (the public HTTPS URL).
The dev server builds context.url.origin as http://mre.local.
They never match → every form POST is rejected with 403.
In Astro 6, vite-plugin-app/app.ts constructs the request URL using
loader.isHttps(), which returns !!vite.server.https. Unless you configure
Vite's own HTTPS server, this is always false, so the
origin is always built with http:// regardless of what the upstream proxy
sends in X-Forwarded-Proto.
In Astro 5, vite-plugin-astro-server/request.ts read X-Forwarded-Proto
directly and used it to set the protocol. That behaviour was dropped as part of
the Vite Environments rewrite introduced in Astro 6.
Any Astro 6 dev server running behind a proxy that terminates TLS:
Browser ──HTTPS──▶ Caddy / Traefik / nginx ──HTTP──▶ astro dev
Caddy (and others) forward X-Forwarded-Proto: https, but the dev server
ignores it.
Prerequisites: Docker, Docker Compose, and mre.local resolving to
127.0.0.1 (add to /etc/hosts if needed).
echo "127.0.0.1 mre.local" | sudo tee -a /etc/hosts
docker compose up --build -d
# Trust Caddy's local CA (needed once per machine)
docker compose exec caddy caddy trust
./test.shExpected output:
── GET https://mre.local/ ──────────────────────────────
Status: 200
── POST https://mre.local/ ─────────────────────────────
Status: 403
Body : Cross-site POST form submissions are forbidden
PASS — bug confirmed.
You can also open https://mre.local/ in a browser and submit the form to see
the 403 live.
| Request | Expected | Why |
|---|---|---|
GET / |
200 | No Origin header on GET — no CSRF check |
POST / |
403 | Browser sends Origin: https://mre.local; dev server built url.origin = "http://mre.local" → mismatch |
The diagnostic table on the page shows context.url.origin, the Origin
request header, and X-Forwarded-Proto so you can see the mismatch directly.
handleRequest in vite-plugin-app/app.ts should read X-Forwarded-Proto
before falling back to isHttps:
const forwardedProto = (incomingRequest.headers['x-forwarded-proto'] as string | undefined)
?.split(',')[0]
.trim();
const protocol = forwardedProto ?? (isHttps ? 'https' : 'http');
const origin = `${protocol}://${
incomingRequest.headers[':authority'] ?? incomingRequest.headers.host
}`;This restores the Astro 5 behaviour where the protocol is derived from the proxy-forwarded header rather than from Vite's own HTTPS configuration.