I’ve seen a lot of Deno Deploy apps ship with one of two CSP setups:
- no CSP at all
- a giant copy-pasted header that nobody wants to touch
Both are bad, just in different ways.
Deno Deploy makes it pretty easy to set headers at the edge, but that doesn’t automatically give you a sane Content Security Policy. The hard part is always the same: your app is simple on day one, then analytics, consent tooling, inline hydration, and a couple of third-party widgets show up. Suddenly your clean policy turns into a junk drawer.
Here’s a real-world style case study for a Deno Deploy app, with a before-and-after policy and the code to make it work.
The setup
Picture a Deno Deploy marketing app with:
- server-rendered HTML
- a small inline boot script
- Google Tag Manager
- Google Analytics
- Cookiebot
- a backend API
- a websocket endpoint for live updates
That’s not hypothetical. It’s a very normal stack.
The “real data” baseline I’m using comes from a live CSP header observed on headertest.com:
content-security-policy: default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com; script-src 'self' 'nonce-YWI1M2FhNjgtYTNlMS00MTYyLTlkMWUtZTdjMDIyMjgzMmM2' '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'
Honestly, this is a decent example of a production CSP that grew around actual business requirements instead of a toy demo.
Before: the lazy Deno Deploy version
This is what I usually find first: a Deno app that only sets a couple of security headers, or worse, none.
Deno.serve(async (req) => {
const html = `
<!doctype html>
<html>
<head>
<title>Demo</title>
<script>
window.APP_CONFIG = { apiBase: "/api" };
</script>
<script src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXX"></script>
</head>
<body>
<h1>Hello</h1>
</body>
</html>
`;
return new Response(html, {
headers: {
"content-type": "text/html; charset=utf-8",
"x-frame-options": "DENY",
},
});
});
Problems:
- inline script with no nonce or hash
- third-party script execution with no restrictions
- no protection against injected script, style, object, or framing abuse
- no
base-uri - no
form-action - no
connect-src, so future additions become guesswork
This kind of app works fine right up until somebody introduces an XSS bug. Then it works for the attacker too.
The first bad “fix”
The next stage is usually panic-CSP:
Content-Security-Policy: default-src * 'unsafe-inline' 'unsafe-eval' data: blob:;
I hate this pattern. It gives teams a false sense of security while effectively allowing almost everything that matters in an XSS scenario.
If you need 'unsafe-inline' for scripts, and your default source is *, you haven’t really locked down script execution.
After: a Deno Deploy CSP that matches the app
The right move is to build the policy around actual resource flows, then wire your HTML generation to support it.
For this app, I’d start here:
function createNonce() {
return crypto.randomUUID().replaceAll("-", "");
}
function buildCsp(nonce: string) {
return [
"default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com",
`script-src 'self' 'nonce-${nonce}' '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.example.com wss://live.example.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'",
].join("; ");
}
Deno.serve(async (_req) => {
const nonce = createNonce();
const csp = buildCsp(nonce);
const html = `
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Deno Deploy CSP Demo</title>
<script nonce="${nonce}">
window.APP_CONFIG = { apiBase: "/api" };
</script>
<script
nonce="${nonce}"
src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXX">
</script>
</head>
<body>
<h1>Hello</h1>
</body>
</html>
`;
return new Response(html, {
headers: {
"content-type": "text/html; charset=utf-8",
"content-security-policy": csp,
"referrer-policy": "strict-origin-when-cross-origin",
"x-content-type-options": "nosniff",
},
});
});
That’s already a major improvement.
Why this version is better
1. Nonces make inline script intentional
The boot script is inline, but it’s explicitly allowed with a per-response nonce.
That matters because inline script is usually where CSP goes to die. Teams add one inline snippet, then cave and use 'unsafe-inline' in script-src. Don’t do that unless you absolutely have no choice.
On Deno Deploy, generating a nonce per request is trivial, so there’s not much excuse.
2. strict-dynamic keeps script trust tighter
I like strict-dynamic when the app uses nonced bootstrap scripts or trusted script loaders. It tells the browser to trust scripts loaded by an already trusted nonced script, instead of relying purely on static host allowlists.
That’s useful in the real world because third-party script ecosystems are messy. Tag managers especially tend to load more scripts.
One caveat: you still need to test browser behavior across the clients you care about. CSP is one of those areas where “supported enough” and “understood by the team” are not the same thing.
3. object-src 'none' and frame-ancestors 'none' are cheap wins
These are low-drama directives with real value.
object-src 'none'shuts down legacy plugin embeddingframe-ancestors 'none'blocks clickjacking by preventing your app from being framed
I almost always add both unless there’s a specific business reason not to.
4. base-uri 'self' closes a weird but real hole
This one gets missed a lot. If an attacker can inject a <base> tag, they can change how relative URLs resolve. That can create some very ugly script and navigation behavior.
base-uri 'self' is easy to set and worth it.
The thing teams usually get wrong on Deno Deploy
They generate the nonce correctly, but forget to thread it through every server-rendered script tag.
For example, this breaks:
const html = `
<script nonce="${nonce}">
window.__BOOTSTRAP__ = {};
</script>
<script src="/static/app.js"></script>
`;
Why? Because if script-src is nonce-based and you’re relying on that trust model, your external script loading strategy needs to match it. Depending on your exact policy and browser behavior, the plain external script may not be allowed the way you expect.
Safer version:
const html = `
<script nonce="${nonce}">
window.__BOOTSTRAP__ = {};
</script>
<script nonce="${nonce}" src="/static/app.js"></script>
`;
I prefer being explicit. Future me is always less confused when every intentional script has the nonce.
What about styles?
The real header includes:
style-src 'self' 'unsafe-inline' ...
I get why. Consent tools and tag tooling often inject styles in annoying ways.
Still, I’d treat 'unsafe-inline' in style-src as technical debt, not success. It’s less dangerous than allowing inline scripts, but it’s still broader than I want.
If you can move styles to external stylesheets or use nonces/hashes where supported by your stack, do it. If not, document why the exception exists.
A tighter production version
If your Deno Deploy app doesn’t need all those third parties, don’t inherit them. Keep the policy specific to your app.
Example:
function buildCsp(nonce: string) {
return [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
"style-src 'self'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self' https://api.example.com wss://live.example.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
].join("; ");
}
That’s the version I’d want as a starting point for most apps.
Then add exceptions only when the app actually breaks.
Rollout advice from painful experience
Don’t ship a strict CSP blind on a busy app and hope for the best.
Use report-only first if the app has a lot of legacy behavior:
headers: {
"content-security-policy-report-only": csp,
}
Watch what gets blocked, clean up violations, then enforce.
Also, don’t let marketing or analytics vendors dictate your whole security posture. If a tool requires absurdly broad permissions, that’s not a CSP problem. That’s a vendor problem.
Final before-and-after snapshot
Before
Content-Security-Policy: default-src * 'unsafe-inline' 'unsafe-eval' data: blob:;
After
Content-Security-Policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-randomperrequestnonce' '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.example.com wss://live.example.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’s not perfect. Real CSPs rarely are. But it’s operational, intentional, and vastly harder to abuse than the usual default.
If you want ready-made policy patterns to adapt for your app, https://csp-examples.com is useful for quick starting points, and the official Deno docs are still the right place to verify runtime behavior and header handling: https://docs.deno.com/.
That’s the practical standard I’d hold for a Deno Deploy app: generate a nonce per request, keep the policy narrow, and make every exception earn its place.