Documentation
Security defaults
What is on before you configure anything, and why it cannot be off by accident.
Every security-relevant default is safe with an empty config object. Weakening one requires naming it explicitly; there is no path to an insecure setup by omission.
Escaping
Every interpolated value passes through Bun.escapeHTML() on the way out. Not by convention and not by lint rule — by the renderer, with no global switch to turn it off.
<p>{userInput}</p> // always escaped
<p>{raw("<em>ok</em>")}</p> // the only way throughraw() is deliberately more effort than the safe path, and greppable during review. dangerouslySetInnerHTML works too, on both the server and the client, and is named the way it is for the same reason — both hand the browser markup you vouched for.
Three things the renderer refuses outright, because escaping cannot make them safe: interpolating dynamic values into a script or style body, attribute names that could break out of a tag, and a javascript: or vbscript: URL in an attribute the browser follows.
<a href={userSupplied}> // refused if the scheme executes
<div onclick={fromASpread}> // dropped, in any casingThe server and the client share one module deciding this. They used to decide separately and drifted once — the handler check was tightened in the renderer and left alone in the client, so an island was guarded on first paint and unguarded on every update after it. A shared policy makes that class of bug impossible rather than merely unlikely.
Content-Security-Policy
A restrictive policy ships by default: script-src 'self', no unsafe-inline, no unsafe-eval. Stoneware never emits inline executable script, so no nonce plumbing is needed to satisfy it.
This documentation site runs under that default policy, unmodified. The dev server serves its live-reload client as a real file rather than an inline script, so development and production run the same policy.
One consequence worth knowing: style-src 'self' blocks inline style attributes too. Islands that need to drive a value at runtime write a CSS custom property through the CSSOM, which CSP does not govern. The scroll gauge on this page works that way.
Since 0.1.4 the renderer says so rather than leaving you to find it. The attribute is still emitted — a warning must not change output, and the project may be about to widen its policy — but development prints a line naming the element, and stays quiet if the policy allows unsafe-inline or sets no style-src at all.
[stoneware] <p style="..."> will be ignored by the browser.
The Content-Security-Policy sets style-src without 'unsafe-inline', which
blocks style attributes as well as <style> blocks. The markup renders, the
declaration is in the HTML, and it simply never applies.
Use a class and a .css file beside the component - the build collects it.Adding a third party: analytics, Stripe, Sentry
The default policy allows nothing but your own origin, which means a third-party script is blocked until you say otherwise. Say it by naming the origins you need — everything you do not mention stays exactly as the framework set it.
import { defineConfig } from "stoneware";
export default defineConfig({
csp: {
scriptSrc: ["https://www.googletagmanager.com"],
connectSrc: ["https://www.google-analytics.com"],
imgSrc: ["https://www.google-analytics.com"],
},
});default-src 'self' script-src 'self' https://www.googletagmanager.com style-src 'self' img-src 'self' data: https://www.google-analytics.com font-src 'self' connect-src 'self' https://www.google-analytics.com object-src 'none' <- untouched base-uri 'self' <- untouched form-action 'self' <- untouched frame-ancestors 'none' <- untouched
Each list is added to the default rather than replacing it, so 'self' survives, img-src keeps its data:, and the directives you never mentioned are byte-identical to the ones you would have got with no configuration at all. That is the entire point of the object form: a policy retyped by hand to add one origin is a policy with object-src 'none' missing from it, and nothing anywhere reports the omission.
- scriptSrc, styleSrc, imgSrc, fontSrc, connectSrc, frameSrc, workerSrc, mediaSrc, objectSrc, baseUri, formAction, frameAncestors, defaultSrc.
- A directive the default policy does not list — frameSrc, workerSrc — is created seeded with 'self', because that is what it was inheriting from default-src. Without that, allowing Stripe's frame would block your own.
- A source has to be one token. A value containing a semicolon, a comma or whitespace is refused rather than concatenated, because a semicolon ends the directive and starts another — that is how an origin read from an environment variable would append script-src 'unsafe-inline' to a policy that never asked for it.
Inline snippets, without unsafe-inline
Most vendors hand you a bootstrap snippet to paste inline. Under a policy without 'unsafe-inline' the browser refuses it, and the fix is not to add 'unsafe-inline' — that would allow every inline script on the site, including one an injection put there, which is the single thing script-src 'self' is protecting you from.
<script>gtag('config','G-XXX')</script>
blocked — and allowing it means allowing all inline script
public/analytics.js + <script src="/analytics.js" />
allowed by 'self' already, no policy change at allwindow.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag("js", new Date());
gtag("config", "G-XXXXXXX");Move the snippet into a file under public/ and load it with a script tag. It is served from your own origin, so 'self' covers it and the policy does not change. The only entry the vendor genuinely needs is the one for their own domain.
Stoneware never emits inline executable script itself — hydration payloads are JSON in a script type="application/json" tag, not code — which is why script-src 'self' is livable as a default and why no nonce plumbing exists to work around.
Which directive blocks what
Observed in a browser against the default policy, not inferred from the spec. Each row is a real violation event and the directive the browser attributed it to.
script-src https://www.googletagmanager.com/gtag/js script-src the inline bootstrap snippet connect-src https://www.google-analytics.com/g/collect img-src https://www.google-analytics.com/collect (beacon fallback) style-src https://fonts.googleapis.com/css2 font-src https://fonts.gstatic.com frame-src https://td.doubleclick.net worker-src blob: (Sentry replay, etc.)
frame-src and worker-src are not in the default policy — they inherit from default-src 'self' — so a violation is reported against them even though nothing names them. Naming either in the config creates it with 'self' already present.
Not every third-party failure is CSP. Check the browser console for a line beginning "Refused to" — that names the directive. A request that fails without one is a CORS problem, an ad blocker, or the vendor being down, and widening the policy will not fix any of those.
Where the policy applies
stoneware start Content-Security-Policy response header
stoneware export <meta http-equiv> in every page
plus _headers, for hosts that read oneThe object is resolved to a policy string once, when the config loads, so all three carry the same thing and a static export cannot drift from a served one. The meta tag drops frame-ancestors, report-uri and sandbox because browsers ignore those in a meta tag — the export names them rather than pretending to enforce them.
The string form still works and still replaces the policy outright, and csp: false still removes the header. Both remain the explicit, greppable way to take the whole thing over; the object is for the ordinary case of allowing one vendor.
The policy after a static export
A CSP is a response header, so a directory of files cannot carry one. Until 0.1.4 that made the claim above false for stoneware export: the pages went out with no policy at all unless the host was configured to send one, and nothing indicated the loss.
An export now writes both a _headers file — read by Netlify and Cloudflare Pages, carrying the full policy and the other security headers — and a meta http-equiv tag in every page, which works on any host including GitHub Pages. Neither covers everything alone.
frame-ancestors, report-uri and sandbox are ignored by browsers when they arrive in a meta tag. They are stripped from it rather than emitted, because a policy that lists frame-ancestors without enforcing it advertises clickjacking protection the page does not have. The export names them at the end of the run, so what a header-less host gives up is stated rather than assumed.
Rotating the CSRF secret
STONEWARE_CSRF_SECRET signs every token. Replacing it invalidates all of them at once, which is what you want if it has leaked — or on a schedule, if you rotate secrets as a matter of course.
# Generate a new one
bun -e 'console.log(crypto.randomUUID() + crypto.randomUUID())'
# Set it wherever the app reads its environment, then restart.
# Render, Railway, Fly: the dashboard. Docker: the compose file. Local: .envThe cost is one round of failed submissions: any form already rendered in a visitor's browser carries a token signed with the old secret and will be rejected. They see the CSRF error and succeed on a reload. There is no rolling window that accepts both, deliberately — accepting an old secret after a rotation is the one thing a rotation is supposed to stop.
- Rotate on leak, on staff changes, or on a schedule you set. Nothing expires it automatically.
- A production build refuses to start without one rather than falling back to something that appears to work.
- Tokens carry their own expiry too — 24 hours by default, adjustable with csrf.expiresIn.
Tokens are signed with this secret and bound to nothing else. Same-origin policy is what stops an attacker reading one out of your pages; the token proves the request came from a page your server rendered, not that it came from a particular visitor.
Everything else
- Hydration payloads are JSON in a non-executable block, with <, >, & and U+2028/9 escaped.
- X-Content-Type-Options, X-Frame-Options and Referrer-Policy on every response.
- Static file serving refuses path traversal.
- A production build refuses to start without a CSRF secret.
Every response leaves through a single function that applies these headers, so a new route cannot forget them.
Something wrong in the framework itself rather than the page? Open an issue on GitHub.