CSP for API Routes in SvelteKit
Table of Contents
SvelteKit makes CSP pretty nice for pages, but API routes are where people get sloppy.
I see this a lot: a team carefully sets kit.csp for HTML pages, then their /api/* endpoints return JSON with no security headers at all. That is usually fine for script-src and friends — browsers don’t execute JSON as a document — but API routes still matter for CSP in a few real cases:
- an endpoint returns HTML, SVG, PDF, or other renderable content
- an endpoint is used as a file download
- an endpoint serves inline bootstrapped JS or config
- you want a consistent policy surface across the app
- you need to control embedding with
frame-ancestors - you’re debugging weird browser behavior around MIME sniffing and resource loading
So the short version is this:
Most JSON API routes do not need a full CSP.
But some API routes absolutely should send one, and you should know when.
The rule of thumb
Use these defaults:
- JSON API response: usually no CSP needed, but set
Content-Type: application/json; charset=utf-8andX-Content-Type-Options: nosniff - HTML response from an API route: send a real CSP
- SVG/XML/text that could be interpreted in a browser: lock it down
- File downloads: prefer
Content-Disposition: attachment - Anything embeddable: set
frame-ancestors
If you only remember one thing, remember this: CSP is mainly for documents and executable content, not plain JSON blobs.
SvelteKit page CSP vs API route CSP
SvelteKit’s built-in CSP config covers rendered pages. That lives in svelte.config.js and is great for normal app routes.
But API routes like:
// src/routes/api/user/+server.ts
export async function GET() {
return new Response(JSON.stringify({ ok: true }), {
headers: {
'content-type': 'application/json; charset=utf-8'
}
});
}
do not magically need or inherit a useful API-specific CSP strategy. If you want headers on API routes, set them yourself or use a hook.
Good baseline for JSON API routes
For a normal JSON endpoint, I usually ship this:
// src/routes/api/user/+server.ts
export async function GET() {
return new Response(JSON.stringify({ id: 1, name: 'Ada' }), {
headers: {
'content-type': 'application/json; charset=utf-8',
'x-content-type-options': 'nosniff',
'cache-control': 'no-store'
}
});
}
That covers the stuff that actually matters for JSON:
- correct MIME type
- no MIME sniffing
- cache behavior you control
You can add CORS if needed, but that is a separate concern from CSP.
When CSP does matter on an API route
1. API route returns HTML
Some teams generate emails previews, embed widgets, or auth callback pages from +server.ts. If the response is HTML, treat it like a page.
// src/routes/api/embed-preview/+server.ts
const csp = [
"default-src 'none'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"base-uri 'none'",
"form-action 'none'",
"frame-ancestors 'none'"
].join('; ');
export async function GET() {
const html = `
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Preview</title>
<style>body{font:16px system-ui;padding:2rem}</style>
</head>
<body>
<h1>Embed preview</h1>
</body>
</html>
`;
return new Response(html, {
headers: {
'content-type': 'text/html; charset=utf-8',
'content-security-policy': csp,
'x-content-type-options': 'nosniff'
}
});
}
I like default-src 'none' for these little utility documents. Start closed, then allow only what you need.
2. API route returns SVG
SVG is where people get burned. Browsers can render it, and SVG can carry script-like behavior depending on context. If your route serves dynamic SVG badges, charts, avatars, or placeholders, lock it down.
// src/routes/api/badge/+server.ts
const csp = [
"default-src 'none'",
"style-src 'unsafe-inline'",
"img-src data:",
"script-src 'none'",
"object-src 'none'",
"base-uri 'none'"
].join('; ');
export async function GET() {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="40">
<rect width="160" height="40" fill="#111827"/>
<text x="12" y="26" fill="white" font-family="sans-serif" font-size="16">Build: passing</text>
</svg>
`;
return new Response(svg, {
headers: {
'content-type': 'image/svg+xml; charset=utf-8',
'content-security-policy': csp,
'x-content-type-options': 'nosniff'
}
});
}
If you can force download instead of inline rendering, even better:
'content-disposition': 'attachment; filename="badge.svg"'
3. API route serves downloadable files
For CSV, PDFs, generated reports, and similar assets, CSP is less useful than making the browser treat the response as a download.
// src/routes/api/export/+server.ts
export async function GET() {
const csv = 'id,name\n1,Ada\n2,Grace\n';
return new Response(csv, {
headers: {
'content-type': 'text/csv; charset=utf-8',
'content-disposition': 'attachment; filename="users.csv"',
'x-content-type-options': 'nosniff'
}
});
}
That avoids the “browser tries to render weird content” class of problems.
Setting CSP globally for selected API routes
If you have several API endpoints that return browser-renderable content, add headers in handle.
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const response = await resolve(event);
if (event.url.pathname.startsWith('/api/embed')) {
response.headers.set(
'content-security-policy',
[
"default-src 'none'",
"img-src 'self' data:",
"style-src 'self' 'unsafe-inline'",
"base-uri 'none'",
"frame-ancestors 'none'"
].join('; ')
);
response.headers.set('x-content-type-options', 'nosniff');
}
return response;
};
This is cleaner than repeating the same header in every route.
Copy-paste CSP policies for common SvelteKit API cases
Strict JSON API route
Not really a CSP example, because JSON usually does not need one:
headers: {
'content-type': 'application/json; charset=utf-8',
'x-content-type-options': 'nosniff',
'cache-control': 'no-store'
}
HTML from API route
Content-Security-Policy: default-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'
Dynamic SVG
Content-Security-Policy: default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; img-src data:; object-src 'none'; base-uri 'none'
Embeddable widget preview
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; frame-ancestors https://partner.example
If you want more ready-made patterns, https://csp-examples.com is useful for quick policy drafting.
Working from a real production-style policy
Here’s the real CSP sample you gave me, from headertest.com:
content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-NzYzYzQzNGQtODhmNy00NjI1LTgzOGItMGZiYmJkNGE0MGQ4' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com; frame-src 'self' https://consentcdn.cookiebot.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
That is a document CSP, not something I’d slap onto a JSON API route.
Why?
Because directives like these mostly matter for pages:
script-srcstyle-srcimg-srcframe-srcconnect-src
For a plain /api/users response, this is noise. It won’t hurt much, but it gives a false sense that you “secured the API with CSP.” You didn’t. You secured a browser document context that probably doesn’t exist for that route.
Where this kind of policy does fit is an API endpoint that renders HTML for previews, dashboards, popups, or embedded flows.
Nonces on API HTML responses
If your API route emits HTML with inline scripts, use a nonce. SvelteKit page rendering handles nonce workflows better than ad hoc server routes, but you can still do it yourself.
// src/routes/api/boot/+server.ts
import crypto from 'node:crypto';
export async function GET() {
const nonce = crypto.randomUUID();
const csp = [
"default-src 'none'",
`script-src 'nonce-${nonce}'`,
"style-src 'unsafe-inline'",
"base-uri 'none'",
"object-src 'none'"
].join('; ');
const html = `
<!doctype html>
<html>
<body>
<script nonce="${nonce}">
window.__BOOT__ = { env: "prod" };
</script>
</body>
</html>
`;
return new Response(html, {
headers: {
'content-type': 'text/html; charset=utf-8',
'content-security-policy': csp
}
});
}
If you need a nonce-heavy setup across many HTML responses, I’d strongly consider moving that logic back into standard SvelteKit page routes.
Don’t confuse CSP with CORS
This trips people up constantly.
- CSP controls what the browser may load or execute in a document.
- CORS controls whether another origin can read your response.
For API routes consumed cross-origin, you probably need CORS headers:
headers: {
'access-control-allow-origin': 'https://app.example.com',
'vary': 'origin'
}
That has nothing to do with connect-src. connect-src is enforced by the calling page’s CSP, not the API server’s response CSP.
Practical recommendation
My default SvelteKit API checklist looks like this:
- Set the right
Content-Type - Add
X-Content-Type-Options: nosniff - Add
Content-Disposition: attachmentfor downloads when possible - Use CSP only for API responses that browsers may render as documents or active content
- Use
frame-ancestors 'none'for sensitive rendered endpoints - Handle CORS separately
That’s the sane version. Not the cargo-cult version.
For SvelteKit’s official CSP behavior on app routes, check the official docs:
https://svelte.dev/docs/kit/configuration
And if you want a quick starting point for policy strings before adapting them to your route type, https://csp-examples.com is handy.
The main thing is knowing which responses need CSP at all. Once you get that right, the rest is just header plumbing.