Introduction
Cross-Site Request Forgery (CSRF) attacks have been a persistent security concern for web applications for over two decades. SvelteKit ships with built-in CSRF protection that automatically guards your form actions, but many teams still stumble when deploying behind proxies or misunderstand how the protection actually works. Understanding CSRF protection in SvelteKit: what developers should know isn't just about ticking a security checkbox—it's about understanding the mechanics so you can configure it correctly in production without accidentally weakening it.
How CSRF attacks work and why form actions are vulnerable
A CSRF attack exploits the fact that browsers automatically send cookies with cross-origin requests. Imagine a user is logged into their bank at bank.example.com. If that same user visits a malicious site (in another tab), that site can silently make a form submission to bank.example.com/transfer without the user knowing. Since the browser sends the session cookie automatically, the bank's server treats it as a legitimate request from the user.
Form actions in web frameworks are particularly vulnerable because they typically change server state (creating, updating, or deleting data). A POST, PUT, or DELETE request originating from a malicious domain shouldn't succeed if it's targeting your application.
SvelteKit's answer: check the Origin header on every form submission and verify it matches an allowed origin.
Form actions check the Origin header
Every time you define a form action in SvelteKit—whether it's a +page.server.js with an exported actions object or a server load function that modifies data—SvelteKit automatically validates the request's Origin header.
// src/routes/profile/+page.server.js
export const actions = {
updateProfile: async ({ request, locals }) => {
// SvelteKit checks Origin header before this runs
const data = await request.formData();
const name = data.get('name');
// Update user profile
await db.users.update(locals.user.id, { name });
return { success: true };
}
};
Behind the scenes, SvelteKit compares the incoming request's Origin header against your configured origins. By default, it allows requests from the same origin (the domain serving your application). If the Origin header doesn't match, SvelteKit rejects the request with a 403 Forbidden response before your action code ever runs.
This is synchronous, automatic, and doesn't require you to install or configure anything in most cases. The framework does the heavy lifting.
How the check actually works
SvelteKit extracts the Origin header from the incoming request and compares it against the request's host. For example, if your application is served at https://app.example.com and a form submission arrives with Origin: https://malicious.com, the check fails immediately.
The framework also handles the case where no Origin header is present. Older browsers and certain scenarios (like same-origin requests from some contexts) may omit the header. SvelteKit falls back to checking the Referer header as a secondary measure.
Set ORIGIN in production behind proxies
This is where most teams encounter problems. When your SvelteKit application runs behind a proxy (Nginx, load balancer, API gateway, or reverse proxy), the incoming request's host header may not reflect the public-facing domain users visit.
Imagine your infrastructure looks like this:
User → Nginx (proxy) → Node.js (SvelteKit app running on localhost:3000)
From the SvelteKit process's perspective, the request appears to come from localhost:3000. But the actual public domain is app.example.com. If a form submission arrives with Origin: https://app.example.com, SvelteKit compares it against localhost:3000 and rejects it—even though the request is completely legitimate.
The solution: set the ORIGIN environment variable in production.
// svelte.config.js
import adapter from '@sveltejs/adapter-auto';
export default {
kit: {
adapter: adapter(),
// ORIGIN is read from the environment variable
// No explicit config needed—SvelteKit handles it
}
};
In your deployment configuration (Docker, systemd, Kubernetes, AWS Lambda, etc.), set the environment variable:
// Docker example
ENV ORIGIN=https://app.example.com
// Or in Kubernetes manifests
env:
- name: ORIGIN
value: https://app.example.com
// Or in .env (development only, never commit this for production secrets)
ORIGIN=http://localhost:5173
When SvelteKit starts up, it reads this variable and uses it as the canonical origin for CSRF checks. Now when a form submission arrives with Origin: https://app.example.com, it matches and the request is allowed through.
Make sure your proxy also passes the original Host header and ideally the X-Forwarded-Proto header (so SvelteKit knows whether the request came in over HTTP or HTTPS). Nginx, most load balancers, and API gateways do this by default, but double-check your configuration if you're debugging CSRF issues.
Multiple origins and subdomains
If your application serves multiple domains (e.g., app.example.com and app.example.co.uk), you can use a relative URL or the ORIGIN variable should contain only the primary origin. For advanced multi-tenancy with truly different domains, you may need to handle CSRF differently or use a token-based approach (covered later).
Avoid disabling checks
SvelteKit's CSRF protection can technically be disabled, and we've seen teams do it in production—usually out of frustration when debugging forms behind misconfigured proxies.
// svelte.config.js
export default {
kit: {
csrf: {
checkOrigin: false // ⚠️ DO NOT DO THIS IN PRODUCTION
}
}
};
Setting checkOrigin: false disables the origin check entirely. Your forms become vulnerable to CSRF attacks. We've seen incidents where teams set this during development and accidentally committed it to production.
If you're tempted to disable CSRF protection, the problem is almost always one of these:
- Missing or incorrect ORIGIN variable – Set it correctly and the checks will pass.
- Proxy not forwarding headers correctly – Fix the proxy configuration instead.
- Cross-origin form submission by design – Use token-based CSRF protection (see the APIs section below).
Disabling CSRF protection should never be a solution. If you find yourself considering it, step back and investigate the root cause.
APIs need their own protection
SvelteKit's automatic CSRF protection covers form submissions via HTML forms. But if you're building APIs that accept POST, PUT, or DELETE requests from JavaScript clients (whether that's a separate frontend, a mobile app, or a third-party integrator), the origin check alone isn't sufficient.
Browser form submissions automatically include the Origin header, so SvelteKit's check catches CSRF attacks. But a fetch request from an attacker's site can also include an Origin header—it's just a regular HTTP header.
Token-based CSRF protection for APIs
For APIs accessed by JavaScript clients, implement token-based CSRF protection:
// src/routes/api/data/+server.js
export async function POST({ request, locals }) {
const csrfToken = request.headers.get('x-csrf-token');
const sessionToken = locals.csrfToken;
if (!csrfToken || csrfToken !== sessionToken) {
return new Response('CSRF token invalid', { status: 403 });
}
const body = await request.json();
// Process the request
return new Response(JSON.stringify({ success: true }));
}
Generate a CSRF token server-side and include it in your page HTML or API response. The client must send it back as a custom header (e.g., X-CSRF-Token) with mutation requests. An attacker's site can't read this token from a legitimate API response due to browser same-origin policy, so they can't forge the request.
SvelteKit provides a built-in csrf export that makes this easier:
// src/routes/+layout.server.js
import { csrf } from '@sveltejs/kit/secret';
export async function load({ locals }) {
return {
csrfToken: crypto.randomUUID()
};
}
On the client side, read the token from your page data and include it in fetch requests:
// src/lib/api.js
export async function createItem(data, csrfToken) {
const response = await fetch('/api/items', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify(data)
});
return response.json();
}
When to use token-based protection
Use token-based CSRF for:
- JSON APIs consumed by a separate frontend (Next.js, React SPA, Vue, etc.)
- Mobile apps making requests to your backend
- Public APIs where you need to verify request origin
- Requests that don't use cookies for authentication
For traditional form submissions via HTML forms, SvelteKit's built-in origin check is sufficient and requires no additional code from you.
Common mistakes
| Mistake | Why it's wrong | Fix |
|---|---|---|
| Not setting ORIGIN behind a proxy | Legitimate requests get rejected as CSRF | Set ORIGIN env var to your public domain |
| Disabling CSRF checks in production | Opens the app to CSRF attacks | Fix the root cause instead (usually missing ORIGIN) |
| Assuming form action CSRF protection covers APIs | JSON APIs are vulnerable without token-based protection | Implement token-based CSRF for JSON endpoints |
| Ignoring proxy header forwarding | ORIGIN variable won't work if proxy strips headers | Verify proxy forwards Host and X-Forwarded-Proto |
| Hardcoding origins in code | Different origins needed per environment (dev, staging, prod) | Always use the ORIGIN environment variable |
Key takeaways
- SvelteKit automatically protects form actions by checking the
Originheader—no configuration needed in simple deployments. - When running behind a proxy, always set the
ORIGINenvironment variable to your public domain so SvelteKit knows which origins are legitimate. - Never disable CSRF checks with
checkOrigin: falsein production. If you're seeing CSRF rejections, the issue is almost always a missing or incorrect ORIGIN setting. - Form actions and JSON APIs are different. Use SvelteKit's built-in checks for forms and implement token-based CSRF protection for JSON endpoints.
- Verify your proxy configuration forwards the
HostandX-Forwarded-Protoheaders so SvelteKit can determine the correct origin. - Test your CSRF protection locally with a proxy (use
ORIGINin development too) to catch configuration issues before production.






Comments
Loading comments…