@arraypress/security-headers
v2.0.0
Published
Security response headers for static hosts — CSP, HSTS, X-Frame-Options, Referrer-Policy, Permissions-Policy. Generates a Cloudflare/Netlify _headers file, with an Astro integration. Zero dependencies.
Downloads
361
Maintainers
Readme
@arraypress/security-headers
Security response headers for static hosts — CSP, HSTS,
X-Frame-Options,Referrer-Policy,Permissions-Policy. Generates a Cloudflare/Netlify_headersfile, with an Astro integration. Zero dependencies.
Install
npm install @arraypress/security-headersAstro
// astro.config.mjs
import { defineConfig } from 'astro/config';
import headers from '@arraypress/security-headers/astro';
export default defineConfig({
security: { csp: true }, // Astro owns CSP — see below
integrations: [headers()], // everything else, written to dist/_headers
});The integration writes _headers on astro:build:done, so there's no separate
build script to remember. It logs what it wrote:
[@arraypress/security-headers] wrote _headers — 7 headers on /*Why a file and not middleware
On Cloudflare, a static-assets deploy with no server script serves requests for
free. Adding middleware to set headers adds a script and makes every request
billable. _headers is applied at the edge for nothing.
Why CSP defaults to off here
Astro has its own security.csp, and it can hash the inline <script> and
<style> blocks Astro itself emits — the theme flash-guard, scoped component
styles. A static _headers file can't hash them, so expressing the same policy
there means 'unsafe-inline' on both directives, which is most of what CSP was
protecting you from.
So Astro owns CSP, and this owns what Astro doesn't do: HSTS,
X-Frame-Options, Referrer-Policy, Permissions-Policy,
X-Content-Type-Options, Cross-Origin-Opener-Policy and
X-Permitted-Cross-Domain-Policies.
Opt back in — for a host where Astro's CSP isn't in play:
integrations: [headers({ csp: { defaultSrc: ["'self'"] } })]Options
headers(config?, options?)
config— aSecurityHeadersConfig(below).cspdefaults tofalsehere.options.path— path pattern the headers apply to. Default'/*'.options.filename— output name. Default'_headers'.
Cloudflare caps a _headers file at 100 rules; one path costs one rule however
many headers it carries.
Anywhere else
The generators are plain functions with no framework attached — use them from a build script, a Worker, or a test.
import { headersFile, buildHeaders, buildCSP, buildHSTS } from '@arraypress/security-headers';
// A _headers file, as a string.
writeFileSync('dist/_headers', headersFile({ csp: { scriptSrc: ["'self'"] } }));
// The same values as a plain object — for a Response you build yourself.
return new Response(body, { headers: buildHeaders() });
// Or one header at a time.
buildCSP({ defaultSrc: ["'self'"] });
buildHSTS({ maxAge: 31536000, includeSubDomains: true });headersFile() renders the Cloudflare/Netlify format — a path line, then each
header indented two spaces:
/*
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Cross-Origin-Opener-Policy: same-origin
X-Permitted-Cross-Domain-Policies: none
Content-Security-Policy: default-src 'self'; script-src 'self'; …
Strict-Transport-Security: max-age=31536000; includeSubDomainsUnder the Astro integration the CSP line is absent, since Astro owns it.
Configuration
| Option | Default | Notes |
|---|---|---|
| csp | strict defaults | CSPConfig or false. Defaults to false in the Astro integration. |
| cspReportOnly | false | A stricter policy sent as …-Report-Only, to trial before enforcing. |
| hsts | true | HSTSConfig, true for defaults, or false to skip. |
| xContentTypeOptions | true | Emits nosniff. |
| xFrameOptions | 'SAMEORIGIN' | 'DENY', 'SAMEORIGIN' or false. |
| referrerPolicy | 'strict-origin-when-cross-origin' | Any policy string, or false. |
| permissionsPolicy | camera=(), microphone=(), geolocation=() | Any policy string, or false. |
| crossOriginOpenerPolicy | 'same-origin' | Use 'same-origin-allow-popups' for OAuth popups. |
| crossOriginEmbedderPolicy | false | 'require-corp' / 'credentialless'. See isolation below. |
| crossOriginResourcePolicy | false | 'same-origin' / 'same-site' / 'cross-origin'. |
| permittedCrossDomainPolicies | 'none' | Legacy Flash/Acrobat crossdomain.xml opt-out. |
| originAgentCluster | false | Emits Origin-Agent-Cluster: ?1. |
| reportingEndpoints | null | { csp: 'https://…' } → Reporting-Endpoints. |
Every header is independently togglable — pass false to skip it.
Cross-origin isolation
Cross-Origin-Opener-Policy is on by default: it severs window.opener across
origins and needs nothing from the resources you load, so it costs you nothing.
The one gotcha is OAuth popups that talk back via window.opener — those want
'same-origin-allow-popups'.
Cross-Origin-Embedder-Policy and Cross-Origin-Resource-Policy are off by
default, deliberately. COEP blocks every cross-origin resource that hasn't opted
in, and CORP stops other sites embedding your images and fonts. Turn them on
together, with COOP 'same-origin', when you actually need crossOriginIsolated
— that is, SharedArrayBuffer or wasm threads:
headers({
crossOriginOpenerPolicy: 'same-origin',
crossOriginEmbedderPolicy: 'require-corp',
crossOriginResourcePolicy: 'same-origin',
})An app using AudioWorklets or Workers does not need this on its own — only shared memory does.
Rolling out a CSP
Send a strict policy as report-only alongside a permissive enforced one, watch the reports, then promote it:
headers({
csp: { scriptSrc: ["'self'", "'unsafe-inline'"] }, // enforced today
cspReportOnly: { scriptSrc: ["'self'"] }, // the goal
reportingEndpoints: { csp: 'https://example.com/csp-report' },
})CSP directives are camelCase and become kebab-case on the wire:
defaultSrc, scriptSrc, styleSrc, imgSrc, fontSrc, connectSrc,
frameSrc, and the rest.
buildHeaders({
csp: { scriptSrc: ["'self'", 'https://challenges.cloudflare.com'] },
xFrameOptions: 'DENY',
hsts: { maxAge: 63072000, includeSubDomains: true, preload: true },
permissionsPolicy: false,
});Gotchas
Two defaults are strict on purpose and will bite if your site is the exception. Both fail silently in the browser, so they're worth knowing before you deploy.
The microphone and camera are off
Permissions-Policy defaults to camera=(), microphone=(), geolocation=(),
which disables getUserMedia() outright — the call rejects, and nothing in your
own code looks wrong. Right for a marketing or directory site; wrong for an app
that records audio.
// An app with a record-from-mic button:
headers({ permissionsPolicy: 'camera=(), microphone=(self), geolocation=()' })The default CSP blocks Google Fonts
font-src defaults to 'self' and style-src to 'self' 'unsafe-inline', so
a <link> to fonts.googleapis.com and the files it pulls from
fonts.gstatic.com are both blocked:
headers({
csp: {
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
},
})The same applies to any third-party origin — analytics, embeds, a CDN. The
default assumes a site that serves everything itself; add origins as you add
dependencies rather than loosening default-src.
This one doesn't arise under the Astro integration, where CSP is off and Astro owns the policy.
Security notes
X-Frame-Options is superseded by CSP's frame-ancestors but is still emitted
for older browsers — they don't cost each other anything.
The Permissions-Policy default is a tight baseline suited to admin surfaces.
If your site legitimately uses the camera, microphone or geolocation, extend it
rather than dropping the header.
HSTS only takes effect over HTTPS, and preload is a one-way door — browsers
cache it for a long time, so don't enable it until you're certain every
subdomain can serve HTTPS.
License
MIT
