[ Identity & security ]
Security headers and CSP
One typed policy for CSP, HSTS and Permissions-Policy, nonces for the scripts you render, violation reports, and a security.txt.
Last updated 2026-09-29
A browser enforces what your response headers tell it: where scripts may load from, whether the
page may be framed, which device APIs it may ask for, whether the site is HTTPS-only. This guide
writes all of it as one typed policy, reviewed in one file, and applies it to every response of
a Remix v3 app. @sdxc/security-headers models the headers and ships
the middleware, @sdxc/crypto hashes a fixed inline script, and
@sdxc/well-known publishes the security.txt that tells a researcher where
to report what the headers missed.
npm add @sdxc/security-headers @sdxc/crypto @sdxc/well-known @sdxc/result
Write the policy once
The CSP model writes keywords unquoted and quotes them on output, so a missing quote cannot
happen. "nonce" is a placeholder the middleware replaces with a fresh nonce per response.
import type { SecurityHeaders } from "@sdxc/security-headers";
export const BOOT_SCRIPT = `document.documentElement.classList.add("js");`;
export const SECURITY_POLICY: SecurityHeaders.Policy = {
contentSecurityPolicyReportOnly: {
defaultSrc: ["self"],
scriptSrc: [
"self",
"nonce",
"sha256-WZRJfWvsnNCPcxzZwvyhovnZGqhZaC+8gPGPRbx6wTk=",
"https://challenges.cloudflare.com",
],
styleSrc: ["self", "unsafe-inline"],
imgSrc: ["self", "data:"],
frameSrc: ["https://challenges.cloudflare.com"],
formAction: ["self", "https://auth.example.com"],
frameAncestors: ["none"],
objectSrc: ["none"],
baseUri: ["none"],
reportTo: "csp",
},
reportingEndpoints: { csp: "/reports/csp" },
strictTransportSecurity: { maxAge: 31536000, includeSubDomains: true },
referrerPolicy: "strict-origin-when-cross-origin",
crossOriginOpenerPolicy: "same-origin",
permissionsPolicy: { camera: [], microphone: [], geolocation: [], payment: [] },
};
A few choices here are load-bearing. styleSrc keeps "unsafe-inline" and never takes
"nonce": remix/ui writes one <style> per css() mixin without a nonce, and a nonce in
style-src makes browsers ignore 'unsafe-inline' and block every one of them. formAction
names your identity provider, because a login form that posts to it — the one in
Sign in with OpenID Connect — is a form action
too. The Turnstile origin appears in scriptSrc and frameSrc because the challenge from
Protect forms loads a script that renders a frame.
frameAncestors: ["none"] also derives X-Frame-Options: DENY, so the two can never disagree,
and X-Content-Type-Options: nosniff is written unless you turn it off. The sha256-… source
allows BOOT_SCRIPT inline, as the section on hashing a fixed script below explains.
The CSP starts as contentSecurityPolicyReportOnly: the browser reports what it would have
blocked without blocking it. You move it to contentSecurityPolicy once the reports go quiet.
Install it before the renderer
securityHeaders(policy) publishes ctx.securityHeaders and writes the headers onto whatever
response the chain returns, keeping any header the response set itself. Place it right before
renderWith, so it decorates the rendered page and the renderer can read the nonce.
import { securityHeaders } from "@sdxc/security-headers/middleware";
import { renderWith } from "remix/middleware/render";
import { createRouter } from "remix/router";
import { SECURITY_POLICY } from "~/app/http/security-policy";
import { APP_MIDDLEWARE } from "~/bootstrap/middleware";
import { createHtmlRenderer } from "~/bootstrap/renderer";
export const router = createRouter({
middleware: [
...APP_MIDDLEWARE,
securityHeaders(SECURITY_POLICY),
renderWith(createHtmlRenderer),
],
});
APP_MIDDLEWARE stands for whatever your app already runs ahead of it, and
createHtmlRenderer for the renderer from
Wire the router; the order among the rest does not
matter to the headers.
HSTS is written only on https: requests, so localhost over HTTP is never pinned to HTTPS by
accident, and preload, when you set it, is written only if the preload lists would accept the
rest of the header.
Nonces for the scripts you render
A handler that renders an inline script reads ctx.securityHeaders.nonce and puts it on the
tag. The nonce is generated on first read, and a response that never reads it gets every
"nonce" source removed, so an unused nonce is never advertised.
import type { Handle, RemixNode } from "remix/ui";
import { ImportMap } from "remix/ui/server";
import { BOOT_SCRIPT } from "~/app/http/security-policy";
type DocumentProps = { nonce: string; children?: RemixNode };
export function Document(handle: Handle<DocumentProps>) {
return () => (
<html lang="en">
<head>
<ImportMap value={{ imports: {} }} nonce={handle.props.nonce} />
<script>{BOOT_SCRIPT}</script>
</head>
<body>{handle.props.children}</body>
</html>
);
}
The handler reads the nonce and passes it in, as
ctx.render(<Document nonce={ctx.securityHeaders.nonce}>…</Document>). The boot script needs
no nonce, because its hash is in the policy.
A page with client entries — the passkey button from Add passkeys,
say — needs this. remix/ui keeps the attributes of <ImportMap> on the import map it writes
and copies its nonce onto every import map it appends later; without it, a nonce-based
script-src blocks the client entries. TurnstileWidget and the other CAPTCHA widgets take a
nonce prop for their loader script the same way.
Read the nonce in the handler, while the chain is still running: the headers are written when it resolves, ahead of a streamed body.
Hash a fixed inline script instead
A nonce is per response, so an HTML response stored in a shared cache replays one nonce to every
visitor. For a script whose text never changes, list its hash instead. The policy module above
exports the script's text as BOOT_SCRIPT and lists its sha256-… source as a literal, and a
test fails when the two drift apart:
import { Base64, sha256 } from "@sdxc/crypto";
import { unwrap } from "@sdxc/result";
import { expect, test } from "vitest";
import { SECURITY_POLICY, BOOT_SCRIPT } from "./security-policy";
test("the policy allows the boot script exactly as written", async () => {
let digest = Base64.encode(unwrap(await sha256(BOOT_SCRIPT)));
let scriptSrc = SECURITY_POLICY.contentSecurityPolicyReportOnly?.scriptSrc;
expect(scriptSrc).toContain(`sha256-${digest}`);
});
CSP hashes are standard, padded base64, which is what Base64.encode writes. Hashing in a test
keeps the work out of the Worker's global scope and off every cold start.
Collect reports before enforcing
reportTo: "csp" names the endpoint reportingEndpoints points at. parseReports reads either
format a browser sends — the Reporting API's application/reports+json batches and the legacy
application/csp-report document — into one violation shape.
import { isFailure } from "@sdxc/result";
import { parseReports } from "@sdxc/security-headers/reports";
import { createAction } from "remix/router";
import routes from "~/routes/web";
export default createAction(routes.cspReports, async (ctx) => {
let parsed = await parseReports(ctx.request);
if (isFailure(parsed)) return new Response(null, { status: 400 });
for (let violation of parsed.data.slice(0, 20)) {
ctx.log.warn("csp.violation", {
document_url: violation.documentURL,
blocked_url: violation.blockedURL,
directive: violation.effectiveDirective,
});
}
return new Response(null, { status: 204 });
});
Declare the route as cspReports: post("/reports/csp") in routes/web.ts. The slice caps how much one crafted batch can write
to your logs. When the only violations left are browser extensions, rename
contentSecurityPolicyReportOnly to contentSecurityPolicy and the policy is enforced.
Loosen one route, not the policy
A route that has to differ — an embeddable widget that other sites frame — patches its own response instead of weakening the policy for every page:
import { securityHeadersOverride } from "@sdxc/security-headers/middleware";
import { createAction } from "remix/router";
import { EmbedCard } from "~/resources/views/embed-card";
import routes from "~/routes/web";
export default createAction(routes.embed, {
middleware: [
securityHeadersOverride({ contentSecurityPolicy: { frameAncestors: ["*"] } }),
],
handler: (ctx) => ctx.render(<EmbedCard />),
});
A CSP patch merges directive by directive, and the derived X-Frame-Options disappears with the
'none' it came from. A handler can do the same with ctx.securityHeaders.override(patch).
Publish security.txt
RFC 9116's /.well-known/security.txt is where a researcher looks for your security contact.
wellKnown() answers it with the right media type, an ETag and caching, and hands every other
path to the next middleware.
import type { SecurityTxt } from "@sdxc/well-known/security-txt";
export const SECURITY_TXT: SecurityTxt = {
contact: [new URL("mailto:security@example.com")],
expires: new Date("2027-09-30T00:00:00Z"),
encryption: [],
acknowledgments: [],
preferredLanguages: ["en"],
canonical: [new URL("https://example.com/.well-known/security.txt")],
policy: [],
hiring: [],
extensions: {},
};
Register wellKnown({ "security.txt": serve(securityTxt, () => SECURITY_TXT) }) — wellKnown
and serve from @sdxc/well-known/middleware, securityTxt from
@sdxc/well-known/security-txt — near the top of the chain, before session work, since the
document is the same for every visitor. Keep expires a literal date, and add a test that fails
30 days before it with isExpired(SECURITY_TXT, thirtyDaysFromNow): a date computed from the
clock keeps the file looking fresh while the inbox behind it goes stale.
Where to go next
Protect forms from bots and abuse — the challenge whose origin this policy allows.
SEO, sitemaps and robots.txt — the other documents crawlers look for at fixed paths.
Logs, traces and timings — querying the
csp.violationevents.@sdxc/security-headers— every directive,override, and reading a policy back withparse.