“Cross-site POST form submissions are forbidden”: SvelteKit got the wrong origin.
SvelteKit rejects a form POST with a 403 when the browser’s Origin header doesn’t match the origin it believes it serves. After a deploy, that is almost always a wrong ORIGIN variable (or paths.origin in SvelteKit 3), or a proxy that rewrites the Host header. Fix the origin. Don’t turn the check off.
Facts checked
What the check compares
The response is a plain-text 403 with the body Cross-site POST form submissions are forbidden (the method name changes for PUT, PATCH or DELETE; a request that accepts JSON gets {"message": …}). It comes from SvelteKit’s CSRF protection, which only runs in production builds. That is why it never shows up under vite dev.
With @sveltejs/adapter-node, the origin SvelteKit compares against is built like this:
// What the check compares (production builds only, not vite dev)
self origin = paths.origin (SvelteKit 3) or ORIGIN (adapter-node 5)
or else <PROTOCOL_HEADER value, or "https"> :// <HOST_HEADER value, or Host>
browser sends Origin: https://shop.example.com
no match on a POST, PUT, PATCH or DELETE form -> 403
Cross-site POST form submissions are forbiddenWhy it starts after a deploy
| What happened | Why the origins differ | Fix |
|---|---|---|
ORIGIN=http://localhost:3000 came along from a .env file † | The browser sends https://your-domain | Remove ORIGIN behind a proxy that keeps Host, or set it to the real public URL |
ORIGIN names one host, visitors use another: www and the apex, the platform subdomain and a custom domain, or preview URLs † | ORIGIN holds exactly one origin | Leave it unset so each request uses its own host, or redirect every host to one |
The app is served over plain http, such as testing on http://<ip>:3000 † | adapter-node assumes https | Set ORIGIN to the http:// URL for that test, or serve it over HTTPS |
A proxy rewrites Host: nginx sends Host: $proxy_host (the upstream, for example 127.0.0.1:3000) unless told otherwise † † | The origin is built from the upstream address | Add proxy_set_header Host $host;, or set HOST_HEADER=x-forwarded-host if the proxy sets that header |
| You upgraded to SvelteKit 3 and adapter-node 6 † | The ORIGIN variable was removed; it now does nothing | Set paths.origin in vite.config.js, or rely on the request headers |
| A third party posts a form to you: a payment page, a sign-in provider † | That origin really is another site | Add it to csrf.trustedOrigins, by full origin |
† Competitor details come from each vendor’s own published pages on the dates shown; prices and plans change, so check the vendor before you decide. `ORIGIN=http://localhost:3000` came along from a `.env` file (2026-10-29) · `ORIGIN` names one host, visitors use another: `www` and the apex, the platform subdomain and a custom domain, or preview URLs (2026-10-29) · The app is served over plain http, such as testing on `http://<ip>:3000` (2026-10-29) · A proxy rewrites `Host`: nginx sends `Host: $proxy_host` (the upstream, for example `127.0.0.1:3000`) unless told otherwise (2026-10-29) · A proxy rewrites `Host`: nginx sends `Host: $proxy_host` (the upstream, for example `127.0.0.1:3000`) unless told otherwise (2026-10-29) · You upgraded to SvelteKit 3 and adapter-node 6 (2026-10-29) · A third party posts a form to you: a payment page, a sign-in provider (2026-10-29)
PROTOCOL_HEADER and HOST_HEADER
When no origin is configured, adapter-node can read the scheme and host from proxy headers: PROTOCOL_HEADER=x-forwarded-proto and HOST_HEADER=x-forwarded-host (and PORT_HEADER=x-forwarded-port if the public port isn’t the default). The SvelteKit docs are explicit: set these only behind a trusted reverse proxy, because a client can otherwise send them itself.
These are runtime variables, read when the server starts. paths.origin in SvelteKit 3 is config, baked in at build time, and it holds one origin for every host the app answers on.
On Canner: leave the origin unset
- How it runs. Canner detects SvelteKit and starts
node ./build/index.js, the adapter-node server. Without adapter-node the build stops withSvelteKit build did not produce build/index.js — install @sveltejs/adapter-node and set it as the adapter in your SvelteKit config (svelte.config.js, or vite.config.js on SvelteKit 3).@sveltejs/adapter-vercel,-netlifyand-cloudflareare refused at detection, with a message naming adapter-node. - What the proxy sends. Requests reach your app through Caddy, which terminates HTTPS, passes the
Hostheader unchanged and setsX-Forwarded-Proto,X-Forwarded-ForandX-Forwarded-Host. By default, Caddy discards anyX-Forwarded-*values a client sends and writes its own. - So the defaults work. With no
ORIGINand nopaths.origin, adapter-node buildshttps://plus the host the visitor typed. That matches on<slug>.canner.app, on each custom domain and on preview URLs, all at once. The optional caching proxy keepsHosttoo. - Don’t set ORIGIN from `CANNER_URL`.
CANNER_URLis the deploy’scanner.appaddress (or the preview’s), so visitors on your custom domain would get the 403. The same goes forpaths.origin. - Want it explicit?
PROTOCOL_HEADER=x-forwarded-protoandHOST_HEADER=x-forwarded-hostread the values Caddy writes, so they give the same result. Set them under environment variables and redeploy. You don’t need them. - Node version. SvelteKit 3 needs Node 22.17 or later. Canner compares only major versions, so pick Node 24 in the project settings to be sure. See pin the Node.js version.
Don’t switch the check off
The old fix, csrf: { checkOrigin: false }, is gone in SvelteKit 3: the build fails, saying checkOrigin has been removed in favour of csrf.trustedOrigins. Its replacement, trustedOrigins: ['*'], turns the protection off for every site, which the SvelteKit docs advise against. Your form actions then accept a POST from any page on the internet that a logged-in user visits.
When an outside site really does post to you, list that one origin:
// vite.config.js (SvelteKit 3)
import adapter from '@sveltejs/adapter-node';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
sveltekit({
adapter: adapter(),
// Only origins that legitimately post forms to you, such as a payment page
csrf: { trustedOrigins: ['https://checkout.example-payments.com'] }
})
]
});Questions
Why does it work with npm run dev but not in production?
SvelteKit’s CSRF check only runs in production builds. vite dev never compares origins, so a wrong ORIGIN or proxy setup only shows up once deployed.
Do I need to set ORIGIN for adapter-node?
Not behind a proxy that passes the original Host and serves HTTPS, which is the case on Canner. Set it when the proxy rewrites Host and can’t be fixed, or when you serve plain http.
Where did the ORIGIN variable go in SvelteKit 3?
adapter-node 6 removed it. Set paths.origin in the sveltekit() options in vite.config.js if you need a fixed origin; it is fixed at build time.
Is it safe to set csrf.checkOrigin to false?
No, and SvelteKit 3 removed the option. Turning the check off lets any site submit forms on behalf of your logged-in users. Trust specific origins with csrf.trustedOrigins instead.
Deploy SvelteKit with no origin settings to get wrong.
Canner runs adapter-node behind a proxy that keeps your host and serves HTTPS. Deploy from git or the CLI.