Web
Security headers
The Worker puts a Content-Security-Policy and a set of hardening headers on every response it handles, and refuses API writes posted from other sites. You work here when a page needs something from another origin, such as images from a CDN or a script from a vendor.
How it works
server/middleware/security-headers.ts(Licensees only)
runs on every request and sets:
| Header | Value |
|---|---|
content-security-policy | The policy below, plus security.cspExtend |
x-frame-options | DENY |
x-content-type-options | nosniff |
referrer-policy | strict-origin-when-cross-origin |
permissions-policy | accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=() |
strict-transport-security | max-age=31536000; includeSubDomains, over https only and never under pnpm dev |
The values come from
server/utils/security-headers.ts(Licensees only). The
policy is built once when the Worker starts, since everything it reads is
build-time config.
The policy
With the shipped config the policy is:
default-src 'self'
script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://challenges.cloudflare.com
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com
img-src 'self' data: blob: https://imagedelivery.net
font-src 'self' data: https://fonts.gstatic.com
connect-src 'self'
frame-ancestors 'none'
base-uri 'self'
form-action 'self'
object-src 'none'
frame-src 'self' https://challenges.cloudflare.com
challenges.cloudflare.comis Turnstile. It is added only whileauth.enabledis on.imagedelivery.netis Cloudflare Images, which serves the blog covers. Avatars are R2 objects the app serves itself, under'self'.data:inimg-srcis for Nuxt Icon, which draws icons asdata:SVG masks.blob:is for the previews Nuxt UI'sUFileUploadshows.'wasm-unsafe-eval'lets@nuxt/contentrun its WebAssembly queries.connect-srcstays'self'because analytics and marketing events are sent from the server. Afetchfrom the browser to any other origin is blocked.
Allow another origin
Add sources per directive in starter.config.ts(Licensees only):
security: {
headers: true,
cspExtend: {
'img-src': ['https://cdn.example.com'],
'connect-src': ['https://api.example.com'],
},
},
Each list is appended to the kit's sources for that directive, and repeats are
dropped. A directive the kit doesn't set, such as media-src, is added as
written; until you add it, it falls back to default-src 'self'. The policy
is read at build, so a deploy needs a rebuild.
A blocked resource doesn't show up on the server. The page answers 200, the
browser refuses the image or request, and the console logs a "Refused to load"
line. Blog posts are checked for you:
test/unit/content-image-origins.test.ts(Licensees only)
reads every src and avatar in the frontmatter under content/ and fails
when img-src would block one. A GitHub profile picture or a Gravatar URL as a
post's author.avatar fails it.
Keep tokens out of the Referer header
Pages that receive a token in their URL (/reset-password, /magic-link,
/verify-email, /restore-account and /invite) set no-referrer and are
never cached. A page of yours that reads a token from its query string needs
the same two things. In the page:
useHead({ meta: [{ name: 'referrer', content: 'no-referrer' }] })
And in the perLocale({ ... }) block of routeRules in
nuxt.config.ts(Licensees only), so every locale prefix gets it:
'/claim': {
headers: { 'cache-control': 'no-store' },
robots: false,
},
Accept a cross-site post
server/middleware/cross-site-writes.ts(Licensees only)
answers 403 to any write under /api/ (anything but GET, HEAD and
OPTIONS) that the browser labels Sec-Fetch-Site: cross-site. A request
without that header, from a server or an old browser, passes, so provider
webhooks and server-to-server calls are unaffected. Payment webhooks live under
/webhooks/, outside the check anyway.
Two prefixes are allowed through: /api/auth/oauth/, where the OAuth state
protects the callback, and /api/email/unsubscribe, which mail clients post to
for one-click unsubscribe. An endpoint that must accept a form posted from
another site's page goes in CROSS_SITE_WRITES_ALLOWED in that file:
const CROSS_SITE_WRITES_ALLOWED = [
'/api/auth/oauth/',
'/api/email/unsubscribe',
'/api/sso/callback',
]
The session cookie is SameSite=Lax and never rides a cross-site post, so that
endpoint has to authenticate the request itself, with a signature or a token.
This check ignores security.headers.
Turn the headers off
Set security.headers: false and the middleware sets none of them, HSTS
included. Do this only when something in front of the Worker, such as a
Cloudflare Transform Rule, already sets them. A browser that receives two
policies enforces both.
Configuration
| Key | Default | What it does |
|---|---|---|
security.headers | true | Sets the CSP and the hardening headers on every response. |
security.cspExtend | {} | Extra sources per CSP directive, merged into the kit's policy. |
There are no environment variables.
Good to know
- HSTS carries
includeSubDomains, so once a browser has seen it, every subdomain of the app's host has to serve https for a year. - HSTS is never sent under
pnpm dev. Sent fromlocalhost, it would force https on every project you run onlocalhostin that browser. 'unsafe-inline'stays inscript-src. Nuxt writes its hydration payload as an inline script, and/blogand/legalare served from the swr cache, where one HTML body answers many requests, so a per-request nonce can't work.- Nitro runs
server/middleware/in file-name order. A middleware of yours that answers a request ends the chain, so name it to sort aftersecurity-headers.tsor its response goes out without the headers.test/unit/middleware-order.test.ts(Licensees only) holds the kit's own to this.
A link with a lock opens the kit’s private repository, which every licensee can read.