Skip to content

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:

HeaderValue
content-security-policyThe policy below, plus security.cspExtend
x-frame-optionsDENY
x-content-type-optionsnosniff
referrer-policystrict-origin-when-cross-origin
permissions-policyaccelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()
strict-transport-securitymax-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.com is Turnstile. It is added only while auth.enabled is on.
  • imagedelivery.net is Cloudflare Images, which serves the blog covers. Avatars are R2 objects the app serves itself, under 'self'.
  • data: in img-src is for Nuxt Icon, which draws icons as data: SVG masks. blob: is for the previews Nuxt UI's UFileUpload shows.
  • 'wasm-unsafe-eval' lets @nuxt/content run its WebAssembly queries.
  • connect-src stays 'self' because analytics and marketing events are sent from the server. A fetch from 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

KeyDefaultWhat it does
security.headerstrueSets 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 from localhost, it would force https on every project you run on localhost in that browser.
  • 'unsafe-inline' stays in script-src. Nuxt writes its hydration payload as an inline script, and /blog and /legal are 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 after security-headers.ts or 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.