@arraypress/security-headers
v1.0.0
Published
Security response headers for Hono — CSP, HSTS, X-Frame-Options, Referrer-Policy, Permissions-Policy. Strict by default.
Maintainers
Readme
@arraypress/security-headers
Strict-by-default security response headers for Hono on edge runtimes (Cloudflare Workers, Deno, Bun, Node.js). Single middleware covers:
- Content-Security-Policy — builder with safe defaults tuned for Tailwind v4 + shadcn/ui admin SPAs
- Strict-Transport-Security — HSTS with 1-year max-age +
includeSubDomainsout of the box - X-Frame-Options — legacy clickjacking defence (supersets CSP
frame-ancestors) - X-Content-Type-Options —
nosniffto block MIME-type guessing - Referrer-Policy —
strict-origin-when-cross-originby default - Permissions-Policy — restrictive browser-feature gate (camera/mic/geolocation off)
Plus standalone buildCSP() and buildHSTS() helpers when you want the header string without the middleware (e.g. static-file serves, reverse-proxy config).
Zero runtime dependencies. Peer: hono ^4.0.0.
Why
Configuring CSP by hand is how apps end up with 'unsafe-eval' in production or a forgotten HSTS directive. This package bakes in the pattern that's been tuned across multiple @arraypress apps — every header togglable, every directive overridable, but always with a sensible baseline.
Install
npm install @arraypress/security-headersQuick start
import { Hono } from 'hono';
import { securityHeaders } from '@arraypress/security-headers';
const app = new Hono();
app.use('*', securityHeaders({
// Add Turnstile to the CAPTCHA-using script + frame allowlists.
csp: {
scriptSrc: ["'self'", 'https://challenges.cloudflare.com'],
frameSrc: ["'self'", 'https://challenges.cloudflare.com'],
},
// All other headers use strict defaults.
}));Default configuration
If you pass no config at all, here's what your responses will carry:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-src 'self'; form-action 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; upgrade-insecure-requests
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()Why these specific defaults
'unsafe-inline'onstyle-src— Tailwind v4 emits inline styles for arbitrary-value utilities ([--foo:12]) and Radix / shadcn primitives set inline positioning styles. Without this, the admin SPA visibly breaks. If your app doesn't use either, tighten withstyleSrc: ["'self'"].img-src 'self' data: https:—data:allows inline SVG / embedded base64 images;https:allows any HTTPS image source. Tighten with a specific allowlist if you host all images yourself.object-src 'none'— kills Flash / Java plugin vectors. Keep this restrictive unless you have a hard requirement.frame-ancestors 'self'— clickjacking defence. SupersedesX-Frame-Optionson modern browsers, but we set both for belt-and-braces.upgrade-insecure-requests— auto-rewrites anyhttp://ref tohttps://so a stray asset URL doesn't mixed-content-warn.- HSTS 1yr + includeSubDomains, no preload —
preloadis hard to undo (inclusion requires manual submission to hstspreload.org). Opt in explicitly if you actually want it.
CSP configuration
Every directive has a safe default. Pass only the ones you want to override — arrays replace the default, they don't merge. This is deliberate: CSP bugs are usually "I added X but forgot to include the baseline," and explicit replacement makes the full rule visible in one place.
securityHeaders({
csp: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", 'https://challenges.cloudflare.com'],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
fontSrc: ["'self'"],
connectSrc: ["'self'"],
frameSrc: ["'self'", 'https://challenges.cloudflare.com'],
formAction: ["'self'"],
baseUri: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'self'"],
upgradeInsecureRequests: true,
// For directives not covered above (e.g. report-uri):
custom: {
'report-uri': ['https://example.report-uri.com/a/d/g'],
'require-trusted-types-for': ["'script'"],
},
},
});Pass csp: false to skip the CSP header entirely — e.g. for an API-only Worker where the browser never renders responses.
HSTS configuration
securityHeaders({
hsts: {
maxAge: 63072000, // 2 years (preload-list requirement)
includeSubDomains: true,
preload: true, // only after submitting to hstspreload.org
},
});Pass hsts: true for the defaults (1yr, includeSubDomains, no preload), or hsts: false to skip HSTS entirely.
Standalone builders
Use these when you need the header string without the middleware:
import { buildCSP, buildHSTS } from '@arraypress/security-headers';
// E.g. for a static Response with custom headers:
const csp = buildCSP({ scriptSrc: ["'self'"] });
const hsts = buildHSTS({ maxAge: 31536000 });
return new Response(html, {
headers: {
'Content-Type': 'text/html',
'Content-Security-Policy': csp,
'Strict-Transport-Security': hsts,
},
});Toggling individual headers
Pass false for any field to skip it:
securityHeaders({
csp: false, // no CSP at all
hsts: false, // no HSTS at all
xFrameOptions: false, // no X-Frame-Options (rely on CSP frame-ancestors only)
xContentTypeOptions: true, // keep nosniff
referrerPolicy: 'no-referrer',
permissionsPolicy: false, // no Permissions-Policy
});Full configuration reference
SecurityHeadersConfig
| Field | Default | Description |
|---|---|---|
| csp | Strict defaults (see above) | CSP config, or false to skip. |
| hsts | true (1yr + includeSubDomains) | HSTS config, or true / false. |
| xContentTypeOptions | true | Emit X-Content-Type-Options: nosniff. |
| xFrameOptions | 'SAMEORIGIN' | 'DENY' / 'SAMEORIGIN' / false. |
| referrerPolicy | 'strict-origin-when-cross-origin' | Any valid Referrer-Policy, or false. |
| permissionsPolicy | 'camera=(), microphone=(), geolocation=()' | Any valid Permissions-Policy, or false. |
CSPConfig
| Directive | Default | Notes |
|---|---|---|
| defaultSrc | ["'self'"] | Fallback for anything not explicitly set. |
| scriptSrc | ["'self'"] | Add CDN origins for external scripts. |
| styleSrc | ["'self'", "'unsafe-inline'"] | Drop 'unsafe-inline' if your app doesn't need Tailwind v4 arbitrary values or Radix inline positioning. |
| imgSrc | ["'self'", 'data:', 'https:'] | Tighten with specific origins when possible. |
| fontSrc | ["'self'"] | Add CDN origins for web fonts. |
| connectSrc | ["'self'"] | Add API origins for fetch / XHR. |
| frameSrc | ["'self'"] | Allowed iframe sources (CAPTCHAs, embeds). |
| formAction | ["'self'"] | Allowed <form action> targets. |
| baseUri | ["'self'"] | Allowed <base href> origins. |
| objectSrc | ["'none'"] | Keep at 'none' unless you really need plugins. |
| frameAncestors | ["'self'"] | Who can frame you. Supersets X-Frame-Options. |
| upgradeInsecureRequests | true | Auto-rewrite http:// → https://. |
| custom | undefined | Map of raw directive name → values for anything not first-class. |
HSTSConfig
| Field | Default | Description |
|---|---|---|
| maxAge | 31536000 | Seconds. 63072000 (2yr) is required for preload-list eligibility. |
| includeSubDomains | true | Apply HSTS to all subdomains. |
| preload | false | Emit preload directive. Only set after submitting to hstspreload.org — inclusion is hard to undo. |
Patterns
Adding Cloudflare Turnstile / reCAPTCHA
Both CAPTCHA providers need their CDN on script-src and frame-src:
securityHeaders({
csp: {
scriptSrc: ["'self'", 'https://challenges.cloudflare.com'],
frameSrc: ["'self'", 'https://challenges.cloudflare.com'],
},
});Google reCAPTCHA uses https://www.google.com instead.
Allowing Stripe checkout
securityHeaders({
csp: {
scriptSrc: ["'self'", 'https://js.stripe.com'],
frameSrc: ["'self'", 'https://js.stripe.com', 'https://hooks.stripe.com'],
connectSrc: ["'self'", 'https://api.stripe.com'],
},
});Report-only mode
For testing a tightened policy without breaking the app, use custom:
// Apply the strict policy as reporting-only, keep the lenient one as enforced.
app.use('*', securityHeaders({ csp: { /* lenient live policy */ } }));
app.use('*', async (c, next) => {
await next();
c.header('Content-Security-Policy-Report-Only',
buildCSP({ /* stricter trial policy */, custom: { 'report-uri': ['/csp-report'] } })
);
});Non-admin API Worker
If your Worker serves only JSON to trusted clients, CSP + XFO don't buy you much:
app.use('*', securityHeaders({
csp: false,
xFrameOptions: false,
// Keep HSTS + nosniff — always useful.
}));Security notes
- CSP doesn't protect against CSRF — you need a separate token or
X-Requested-Withcheck. See@arraypress/admin-authfor one. - HSTS only takes effect after the first TLS response — a user's first insecure HTTP request to the domain is still vulnerable to TLS-stripping attacks. That's why
preloadexists (gets it into the browser's built-in list). frame-ancestorsis the modern clickjacking defence —X-Frame-Optionsis legacy but both are set for older browsers.'unsafe-inline'onstyle-srcis NOT equivalent to'unsafe-inline'onscript-src— inline styles are much lower risk than inline scripts. Don't let CSP-purity arguments push you into breaking Tailwind.report-urihas largely been replaced byreport-to— if you're collecting violations, check your reporting endpoint's requirements.
License
MIT
