Introduction
The Security Challenge of Server-Rendered Content
When building modern web applications with Svelte 5, server-side rendering (SSR) provides significant benefits: faster initial page loads, better SEO, and improved user experience. However, SSR introduces a security consideration that many developers overlook until they encounter cryptic browser errors: Content Security Policy (CSP) violations.
The hydratable function in Svelte 5 is a powerful tool that prevents redundant data fetching during hydration. When you use hydratable, Svelte serializes your server-fetched data and embeds it in an inline <script> block within the document’s <head>. This approach is elegant and efficient—but it collides head-on with strict Content Security Policies that block inline scripts.
Starting with Svelte 5.46.0, the render function now includes a csp option that allows you to specify either a nonce or request hashes for these inline scripts. This seemingly small addition unlocks the ability to use hydratable in security-conscious environments without resorting to the dangerous unsafe-inline directive.
Let’s explore this feature comprehensively—from understanding why it matters to implementing it in real-world scenarios.
Understanding Content Security Policy
Before diving into Svelte’s CSP support, let’s establish a solid understanding of what CSP is and why it exists.
What is CSP?
Content Security Policy is a security standard that helps prevent cross-site scripting (XSS), clickjacking, and other code injection attacks. It works by allowing you to specify exactly which resources the browser should trust and execute.
A CSP is delivered via an HTTP response header:
Content-Security-Policy: script-src 'self'; style-src 'self' https://fonts.googleapis.com This header tells the browser: “Only execute scripts from my own domain, and only load styles from my domain or Google Fonts.”
The Inline Script Problem
By default, when CSP is enabled with a script-src directive, the browser blocks all inline scripts. This includes:
<!-- This will be blocked -->
<script>
const user = { name: 'Alice', role: 'admin' }
window.__SVELTE_DATA__ = user
</script> The rationale is sound: inline scripts are a primary vector for XSS attacks. If an attacker can inject HTML into your page, they can inject malicious JavaScript that executes immediately.
However, legitimate applications—like Svelte with hydratable—need to embed inline scripts for valid reasons. The solution? Nonces and hashes.
Nonces vs. Hashes: Two Approaches
Nonces (Number Used Once) are randomly generated values that you:
- Generate on the server for each HTTP response
- Include in your CSP header:
script-src 'nonce-abc123' - Add to your script tags:
<script nonce="abc123">
The browser then allows only scripts with the matching nonce to execute.
Hashes are cryptographic fingerprints of script content:
- You compute the SHA-256 (or SHA-384/SHA-512) hash of your script content
- Include the hash in your CSP header:
script-src 'sha256-abc123...' - The browser computes the hash of any inline script and checks if it matches
Here’s a comparison to help you choose:
| Aspect | Nonces | Hashes |
|---|---|---|
| Generation | Random, per-request | Computed from content |
| Caching | Breaks response caching | Allows cached responses |
| Dynamic content | Ideal for SSR | Requires static content |
| Setup complexity | Requires server coordination | Can be preconfigured |
| Streaming SSR | ✅ Compatible | ❌ May interfere |
As the Svelte documentation notes: use nonces over hashes if you can, because hashes will interfere with streaming SSR in the future.
The Hydratable Function: A Quick Review
Before exploring CSP integration, let’s understand what hydratable does and why it generates inline scripts.
The Hydration Problem
Consider this Svelte component:
<script>
import { getUser } from 'my-database-library'
// This runs on the server during SSR
const user = await getUser()
</script>
<h1>Welcome, {user.name}!</h1> During SSR, Svelte fetches the user and renders the HTML. But during hydration (when the client-side JavaScript takes over), Svelte encounters that same await getUser() call. Without any mechanism to pass the server data to the client, Svelte re-fetches the user—wasting time and potentially causing UI flickers if the data differs.
How Hydratable Solves This
The hydratable function elegantly bridges this gap:
<script>
import { hydratable } from 'svelte'
import { getUser } from 'my-database-library'
const user = await hydratable('user', () => getUser())
</script>
<h1>Welcome, {user.name}!</h1> Here’s what happens at each stage:
Server rendering:
hydratablecallsgetUser(), serializes the result, and embeds it in a<script>block in the<head>.Hydration:
hydratablefinds the serialized data by the key'user'and returns it immediately—no network request needed.Post-hydration: If
hydratableis called again (e.g., in a reactive context), it simply callsgetUser()normally.
The serialized data uses devalue, which can handle complex types like Map, Set, Date, URL, BigInt, and even Promise.
The Generated Script
When you use hydratable, Svelte generates something like this in your document’s <head>:
<script>
(function() {
const __SVELTE_HYDRATABLE_DATA__ = /* serialized data */;
// Registration logic
})();
</script> This inline script is what CSP blocks—and what the new csp option allows you to protect.
Using the CSP Option in render()
The render function from svelte/server now accepts a csp option with two modes: nonce and hash.
Basic API
import { render } from 'svelte/server'
import App from './App.svelte'
// Option 1: Using a nonce
const { head, body } = await render(App, {
csp: { nonce: 'your-random-nonce' }
})
// Option 2: Using hashes
const { head, body, hashes } = await render(App, {
csp: { hash: true }
}) Notice that when using hashes, the render function returns an additional hashes property containing the computed hash values.
The RenderOutput Type
For TypeScript users, here’s the structure:
interface RenderOutput {
head: string
body: string
hashes?: {
script: string[] // Array of hash strings like ['sha256-abc123...']
}
} Practical Implementation: Nonce-Based CSP
Let’s build a complete example using nonces in a Node.js/Express environment.
Step 1: Generate a Nonce
Every HTTP response needs a unique, cryptographically secure nonce:
// server.js
import crypto from 'node:crypto'
import express from 'express'
import { render } from 'svelte/server'
import App from './App.svelte'
const app = express()
app.get('*', async (req, res) => {
// Generate a cryptographically secure random nonce
const nonce = crypto.randomBytes(16).toString('base64')
// Render the Svelte app with the nonce
const { head, body } = await render(App, {
props: { url: req.url },
csp: { nonce }
})
// Set the CSP header with the nonce
res.setHeader(
'Content-Security-Policy',
`script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'`
)
// Send the HTML response
res.send(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
${head}
</head>
<body>
${body}
<script nonce="${nonce}" src="/app.js"></script>
</body>
</html>
`)
})
app.listen(3000) Step 2: Understanding What Svelte Does
When you pass csp: { nonce } to render, Svelte adds the nonce attribute to its generated script:
<!-- Without CSP option -->
<script>
/* hydratable data */
</script>
<!-- With csp: { nonce: 'abc123' } -->
<script nonce="abc123">
/* hydratable data */
</script> Step 3: Don’t Forget Your Own Scripts
Any other inline scripts or script tags you add must also include the nonce:
<!-- Main application bundle -->
<script nonce="${nonce}" src="/build/app.js"></script>
<!-- Analytics (if inline) -->
<script nonce="${nonce}">
// Tracking code
</script> Security Considerations for Nonces
Several critical security practices apply to nonces:
Never reuse nonces: Generate a new random value for every single HTTP response.
Use cryptographic randomness:
Math.random()is not secure. Usecrypto.randomBytes()orcrypto.randomUUID().Don’t expose nonces in URLs: Nonces should travel only in HTTP headers and HTML attributes, never in query strings or form data.
Combine with strict-dynamic: Modern CSP supports
'strict-dynamic', which allows scripts loaded by trusted scripts to execute:
Content-Security-Policy: script-src 'nonce-abc123' 'strict-dynamic' Practical Implementation: Hash-Based CSP
For static site generation or when you can’t generate per-request nonces, hashes provide an alternative.
Step 1: Render with Hash Option
// build.js
import { render } from 'svelte/server'
import fs from 'node:fs'
import App from './App.svelte'
async function buildPage() {
const { head, body, hashes } = await render(App, {
csp: { hash: true }
})
// hashes.script contains something like ['sha256-xyz789...']
console.log('Script hashes:', hashes.script)
// Generate the CSP header value
const scriptSrc = hashes.script.map((h) => `'${h}'`).join(' ')
// For static sites, embed CSP in a meta tag
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy"
content="script-src 'self' ${scriptSrc}">
${head}
</head>
<body>
${body}
<script src="/app.js"></script>
</body>
</html>
`
fs.writeFileSync('dist/index.html', html)
// Store hashes for server configuration
fs.writeFileSync('dist/csp-hashes.json', JSON.stringify(hashes))
}
buildPage() Step 2: Configure Your Server
For production deployment, configure your web server to send the CSP header. Here’s an example for Nginx:
# nginx.conf
server {
location / {
add_header Content-Security-Policy "script-src 'self' 'sha256-xyz789...'";
try_files $uri $uri/ /index.html;
}
} Or for Apache:
# .htaccess
Header set Content-Security-Policy "script-src 'self' 'sha256-xyz789...'" Important Caveat: Content Changes Break Hashes
If your hydratable data changes between builds, the hash changes too. This means:
- You must rebuild and update CSP headers whenever content changes
- Hash-based CSP is primarily suitable for truly static content
- For dynamic SSR, prefer nonces
Integration with SvelteKit
SvelteKit has its own CSP configuration system that works alongside (but separately from) Svelte’s hydratable CSP support. Understanding how they interact is crucial.
SvelteKit’s Built-in CSP
SvelteKit allows you to configure CSP in svelte.config.js:
// svelte.config.js
export default {
kit: {
csp: {
mode: 'auto', // 'hash', 'nonce', or 'auto'
directives: {
'script-src': ['self'],
'style-src': ['self', 'unsafe-inline']
}
}
}
} This configuration handles SvelteKit’s own inline scripts and styles. The mode: 'auto' setting uses hashes for prerendered pages and nonces for dynamically rendered pages.
Using Hydratable with SvelteKit
For most SvelteKit applications, you won’t call render() directly—SvelteKit handles that. The hydratable function is typically used behind the scenes by data-fetching libraries.
However, if you’re building a custom SSR setup or integrating with hydratable directly, you can use SvelteKit’s handle hook to coordinate CSP:
// src/hooks.server.js
import crypto from 'node:crypto'
export async function handle({ event, resolve }) {
// Generate a nonce for this request
const nonce = crypto.randomUUID()
// Store it for use in load functions if needed
event.locals.nonce = nonce
const response = await resolve(event, {
transformPageChunk: ({ html }) => {
// Inject nonce into any custom scripts
return html.replace(/<script>/g, `<script nonce="${nonce}">`)
}
})
// Add CSP header
response.headers.set(
'Content-Security-Policy',
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'; style-src 'self' 'unsafe-inline'`
)
return response
} SvelteKit’s %sveltekit.nonce% Placeholder
SvelteKit provides a special placeholder for nonces in src/app.html:
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
%sveltekit.head%
</head>
<body>
%sveltekit.body%
<script nonce="%sveltekit.nonce%">
// Your inline script here
</script>
</body>
</html> SvelteKit automatically replaces %sveltekit.nonce% with the generated nonce when CSP mode includes nonces.
Real-World Example: Secure Data Fetching
Let’s build a complete example that demonstrates hydratable with CSP in a realistic scenario.
The Scenario
You’re building a dashboard that displays user-specific data. The data should be fetched once on the server and passed to the client without re-fetching.
Server Setup (Node.js)
// server.js
import express from 'express'
import crypto from 'node:crypto'
import { render } from 'svelte/server'
import Dashboard from './Dashboard.svelte'
const app = express()
// Middleware to generate nonce
app.use((req, res, next) => {
res.locals.nonce = crypto.randomBytes(16).toString('base64url')
next()
})
// API endpoint for client-side fetches
app.get('/api/user/:id', async (req, res) => {
const user = await fetchUserFromDatabase(req.params.id)
res.json(user)
})
// SSR route
app.get('/dashboard', async (req, res) => {
const { nonce } = res.locals
try {
const { head, body } = await render(Dashboard, {
props: {
userId: req.session.userId
},
csp: { nonce }
})
// Build CSP header
const cspDirectives = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: https:`,
`connect-src 'self'`,
`font-src 'self'`,
`object-src 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`frame-ancestors 'none'`
].join('; ')
res.setHeader('Content-Security-Policy', cspDirectives)
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard</title>
${head}
</head>
<body>
<div id="app">${body}</div>
<script nonce="${nonce}" type="module" src="/build/main.js"></script>
</body>
</html>
`)
} catch (error) {
console.error('Render error:', error)
res.status(500).send('Internal Server Error')
}
})
async function fetchUserFromDatabase(id) {
// Simulated database fetch
return {
id,
name: 'Alice Johnson',
email: 'alice@example.com',
role: 'admin',
lastLogin: new Date()
}
}
app.listen(3000, () => {
console.log('Server running on http://localhost:3000')
}) Svelte Component
<!-- Dashboard.svelte -->
<script>
import { hydratable } from 'svelte'
import UserCard from './UserCard.svelte'
import ActivityFeed from './ActivityFeed.svelte'
let { userId } = $props()
// Fetch user data with hydratable
// Server: fetches and serializes
// Client: uses serialized data during hydration
const user = await hydratable(`user-${userId}`, async () => {
const response = await fetch(`/api/user/${userId}`)
if (!response.ok) error('Failed to fetch user')
return response.json()
})
// Multiple hydratable calls can coexist
const activities = await hydratable(`activities-${userId}`, async () => {
const response = await fetch(`/api/activities/${userId}`)
return response.json()
})
</script>
<main class="dashboard">
<header>
<h1>Welcome back, {user.name}!</h1>
<p>Last login: {user.lastLogin.toLocaleString()}</p>
</header>
<div class="dashboard-grid">
<section class="user-section">
<UserCard {user} />
</section>
<section class="activity-section">
<h2>Recent Activity</h2>
<ActivityFeed items={activities} />
</section>
</div>
</main>
<style>
.dashboard {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.dashboard-grid {
display: grid;
grid-template-columns: 300px 1fr;
gap: 2rem;
margin-top: 2rem;
}
</style> Key Points in This Example
Unique keys: Each
hydratablecall uses a unique key that includes the user ID, ensuring data isolation between users.Error handling: The fetch includes error handling that will propagate appropriately during both SSR and hydration.
Complex types: The
user.lastLoginis aDateobject—hydratablehandles this automatically via devalue.Strict CSP: The example uses a comprehensive CSP with
strict-dynamic, which is the recommended modern approach.
Handling Errors and Edge Cases
What Happens Without CSP Configuration?
If you use hydratable without the CSP option and your server sends CSP headers, you’ll see browser console errors like:
Refused to execute inline script because it violates the following
Content Security Policy directive: "script-src 'self'". Either the
'unsafe-inline' keyword, a hash ('sha256-xyz...'), or a nonce
('nonce-...') is required to enable inline execution. The page will render (because SSR completed), but hydration will fail silently, and your app won’t be interactive.
Development vs. Production
During development, you might want to relax CSP to avoid constant nonce generation:
const isDev = process.env.NODE_ENV === 'development'
const cspOptions = isDev
? undefined // No CSP during development
: { nonce: crypto.randomUUID() }
const { head, body } = await render(App, {
csp: cspOptions
}) However, I recommend testing with CSP enabled periodically to catch issues early.
Report-Only Mode for Testing
Before enforcing CSP, use Content-Security-Policy-Report-Only to identify violations without breaking functionality:
res.setHeader(
'Content-Security-Policy-Report-Only',
`script-src 'self' 'nonce-${nonce}'; report-uri /csp-violations`
) Set up an endpoint to collect and log violations:
app.post('/csp-violations', express.json({ type: 'application/csp-report' }), (req, res) => {
console.log('CSP Violation:', req.body)
res.status(204).end()
}) Advanced Patterns
Combining with Third-Party Scripts
When you need to load third-party scripts (analytics, payment processors, etc.), extend your CSP appropriately:
const cspDirectives = [
`script-src 'self' 'nonce-${nonce}' https://js.stripe.com`,
`frame-src https://js.stripe.com`,
`connect-src 'self' https://api.stripe.com`
].join('; ') Or use strict-dynamic with a loader pattern:
<script nonce="${nonce}">
// This trusted script can load other scripts
const stripeScript = document.createElement('script')
stripeScript.src = 'https://js.stripe.com/v3/'
document.head.appendChild(stripeScript)
</script> With 'strict-dynamic', scripts loaded by nonce-protected scripts are automatically trusted.
Server-Sent Events and WebSockets
If your app uses SSE or WebSockets, ensure your connect-src directive allows them:
const cspDirectives = [
`script-src 'self' 'nonce-${nonce}'`,
`connect-src 'self' wss://your-domain.com`
].join('; ') Web Workers
Web Workers loaded from your domain work with script-src 'self'. For inline workers (blob URLs), you’ll need to add blob::
;`script-src 'self' 'nonce-${nonce}' blob:` Performance Considerations
Nonce Generation Overhead
Generating cryptographically secure random values has a cost, though it’s minimal on modern hardware:
// Fast: ~0.01ms per call
crypto.randomBytes(16).toString('base64url')
// Also fast: ~0.01ms per call
crypto.randomUUID() For extremely high-throughput servers, you could pre-generate a pool of nonces, but this is rarely necessary.
Hash Computation
Hash computation is slightly more expensive:
import crypto from 'node:crypto'
function computeHash(content) {
return 'sha256-' + crypto.createHash('sha256').update(content, 'utf8').digest('base64')
} Svelte computes hashes automatically when you use csp: { hash: true }, so this happens during render.
Caching Implications
Nonces: Each response is unique, breaking CDN caching. Use nonces for personalized or dynamic content.
Hashes: Responses can be cached if content is truly static. Ideal for static site generation.
For applications serving both static and dynamic pages, consider:
const cspOptions = isStaticPage ? { hash: true } : { nonce: crypto.randomUUID() } Browser Compatibility
CSP Level 2 (which includes nonces) has excellent browser support:
| Browser | Version |
|---|---|
| Chrome | 40+ |
| Firefox | 31+ |
| Safari | 10+ |
| Edge | 15+ |
For older browsers, you can add fallback directives:
Content-Security-Policy: script-src 'nonce-abc123' 'unsafe-inline' 'strict-dynamic' https: Modern browsers ignore 'unsafe-inline' when a nonce is present, while older browsers fall back to it. The https: fallback provides baseline protection.
Testing Your CSP
Browser Developer Tools
Chrome’s DevTools clearly shows CSP violations in the Console. The Network tab shows blocked resources.
Online Validators
- CSP Evaluator - Google’s tool to analyze your CSP
- Report URI - Hosted CSP reporting and analysis
- Observatory by Mozilla - Comprehensive security scan
Automated Testing
Include CSP validation in your CI/CD pipeline:
// csp.test.js
import { test, expect } from 'vitest'
import { render } from 'svelte/server'
import App from './App.svelte'
test('render includes nonce in script tags', async () => {
const nonce = 'test-nonce-123'
const { head } = await render(App, { csp: { nonce } })
expect(head).toContain(`nonce="${nonce}"`)
})
test('render returns hashes when requested', async () => {
const { hashes } = await render(App, { csp: { hash: true } })
expect(hashes).toBeDefined()
expect(hashes.script).toBeInstanceOf(Array)
expect(hashes.script.length).toBeGreaterThan(0)
expect(hashes.script[0]).toMatch(/^sha256-/)
}) Summary: Best Practices Checklist
Here’s a quick reference for implementing CSP with Svelte’s hydratable:
Choose the Right Mode:
- ✅ Use nonces for server-rendered dynamic content
- ✅ Use hashes for static site generation only
- ❌ Avoid
unsafe-inline—that defeats CSP’s purpose
Implement Securely:
- ✅ Generate nonces with
crypto.randomBytes()orcrypto.randomUUID() - ✅ Use
strict-dynamicto simplify third-party script loading - ✅ Include
object-src 'none'andbase-uri 'self'for defense in depth
Test Thoroughly:
- ✅ Use
Content-Security-Policy-Report-Onlyfirst - ✅ Set up violation reporting
- ✅ Test across browsers and scenarios
Deploy Carefully:
- ✅ Monitor for violations after deployment
- ✅ Have a rollback plan if CSP breaks functionality
- ✅ Document CSP decisions for your team
Further Reading
- MDN: Content Security Policy - Comprehensive CSP reference
- Google’s CSP Guide - Strict CSP implementation guide
- OWASP CSP Cheat Sheet - Security-focused best practices
- Svelte Hydratable Documentation - Official Svelte documentation
- GitHub PR #17338 - The original implementation
Conclusion
The CSP support added to Svelte’s hydratable function in v5.46.0 closes an important gap for security-conscious applications. By providing simple options for both nonces and hashes, Svelte enables developers to enjoy the benefits of optimized hydration while maintaining strict Content Security Policies.
Remember: security is not a feature you add at the end—it’s a mindset you apply throughout development. With this new capability, there’s no excuse not to implement proper CSP in your Svelte applications from day one.