Docket

SELF-HOSTING

The security headers a public feedback page needs

Content-Security-Policy, HSTS, nosniff, Referrer-Policy, Permissions-Policy and frame-ancestors, with a sensible starting value for each.

7 MIN READ Last updated 23 August 2026

A public feedback page needs seven HTTP headers set with care: Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, frame-ancestors and a cross-origin policy. Each stops a distinct attack, and each has a specific way of quietly breaking the page if set without testing it first.

Why do headers matter more on a page that accepts public submissions?

A page that takes input from anyone, logged in or not, is where user-supplied content is most likely to end up rendered back to other visitors: a title, a comment, a name. Every header below limits what the browser does with content on that page, so if something unwanted gets past moderation, the browser refuses to run it, frame it, or leak information from it.

None of these headers replace input sanitisation, output encoding or moderation. They are a second layer that limits the damage when something upstream fails.

What does Content-Security-Policy actually stop?

CSP tells the browser which sources it may load scripts, styles, images and fonts from. It is the most consequential header here, because it stops injected script from running at all, not just limiting what already-trusted script can do.

A sensible starting point for a mostly static support site with a form:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self';
  img-src 'self' data:;
  font-src 'self';
  connect-src 'self';
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none'

That is written across several lines here for readability; the real header is a single line, semicolon-separated. default-src 'self' is the fallback for any directive not set explicitly, object-src 'none' blocks legacy plugin content, and base-uri 'self' stops an injected <base> tag redirecting every relative link.

unsafe-inline in a script-src defeats much of the point of CSP. It tells the browser to run any inline script regardless of origin, exactly the behaviour CSP exists to remove, since the most common way injected content becomes executed script is by ending up inline. The correct alternative is a nonce, a random value generated fresh per page load and included both in the header and as a nonce attribute on that script tag, or a hash of the exact script content listed in the policy. Either lets a known script run while refusing anything injected.

The careless mistake with CSP is usually writing it too strictly against resources the page needs, an analytics script or a font host served from a domain the default 'self' excludes. The page does not error visibly, the resource just never loads.

What does Strict-Transport-Security do, and why is it hard to undo?

HSTS tells the browser to only ever contact your domain over HTTPS, for a length of time you specify, even if a link points at http:// by mistake. The header itself is defined in RFC 6797.

Strict-Transport-Security: max-age=31536000; includeSubDomains

max-age is in seconds, so 31536000 is one year: once a browser has seen the header, it refuses unencrypted requests for that period. includeSubDomains extends the rule to every subdomain.

The genuinely hard mistake to undo is preload, which submits your domain to a list baked into browsers themselves, so even a first-ever request refuses plain HTTP before your header is ever seen. Removal from that list is a slow, manual process, so only add it once every subdomain can serve HTTPS correctly, permanently.

Why does X-Content-Type-Options matter for a form?

X-Content-Type-Options: nosniff

Browsers sometimes guess a file's actual type by inspecting its content rather than trusting the Content-Type header, called MIME sniffing. This header turns that guessing off, so a file labelled plain text cannot be reinterpreted as executable script because its content happened to look like one. No legitimate downside, no case for leaving it off.

What do Referrer-Policy and Permissions-Policy each stop?

Referrer-Policy controls how much of your page's URL is sent to another site when a visitor clicks away, in the Referer header. A safe, explicit setting:

Referrer-Policy: strict-origin-when-cross-origin

That sends the full URL to your own origin, only the origin to a different one, and nothing when downgrading from HTTPS to HTTP.

Permissions-Policy controls which browser features the page, and anything embedded in it, may use: camera, microphone, geolocation. A feedback page needs none, so switch them off:

Permissions-Policy: geolocation=(), camera=(),
  microphone=(), payment=()

An empty parenthesis means no origin, not even your own, may use that feature. Embed a third-party widget later, and its required permission must be explicitly allowed here or the browser silently refuses it.

Your own support centre, in your own repository

One payment, no subscription, unlimited products.

How do frame-ancestors and X-Frame-Options stop clickjacking?

Clickjacking loads your page invisibly inside a frame on another site, tricking a visitor into clicking something on your page while believing they are clicking the attacker's. frame-ancestors, inside CSP, and the older X-Frame-Options header both say who may frame your page.

X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'

frame-ancestors 'none' is more flexible, since CSP can allow specific origins with frame-ancestors 'self' https://example.com, where X-Frame-Options cannot. Set both: frame-ancestors for any browser that reads CSP, X-Frame-Options as a fallback. If your page is ever meant to be embedded, this is the header to loosen deliberately.

What do the cross-origin policy headers actually add?

Cross-Origin-Opener-Policy, Cross-Origin-Embedder-Policy and Cross-Origin-Resource-Policy isolate your page's browsing context from other origins, on top of CSP and frame-ancestors.

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin

Cross-Origin-Opener-Policy: same-origin stops another site that opens your page in a new tab from retaining a live script reference into it. Cross-Origin-Resource-Policy: same-origin stops other sites loading your resources directly into theirs. Both are safe defaults for a page not meant to embed or be embedded; Cross-Origin-Embedder-Policy is the strictest, worth adding only once every resource loaded explicitly opts in, since otherwise it silently blocks them.

HeaderWhat it stopsCareless-setting risk
Content-Security-PolicyInjected scripts and unapproved resource loadsBlocks your own analytics or fonts silently
Strict-Transport-SecurityDowngrade to unencrypted HTTPpreload is very hard to reverse
X-Content-Type-OptionsMIME-sniffing turning data into scriptNone; safe to always set
Referrer-PolicyLeaking full URLs to other originsToo strict breaks referral tracking you rely on
Permissions-PolicyUnwanted use of camera, mic, location, and moreBlocks a legitimate embedded widget silently
frame-ancestors, X-Frame-OptionsClickjacking via hidden framesBlocks your own intentional embed if set too strictly
Cross-Origin-Opener/Resource-PolicyCross-window and cross-site resource leakageCOEP can silently block third-party resources

How do you actually set these, since it depends on the host?

Headers are set differently depending on what serves the page. A static site behind Vercel declares them in vercel.json rather than in application code:

{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "X-Content-Type-Options",
          "value": "nosniff" },
        { "key": "Referrer-Policy",
          "value": "strict-origin-when-cross-origin" }
      ]
    }
  ]
}

Netlify and Cloudflare Pages use their own _headers file format, Nginx sets them with add_header, and Apache uses Header set in .htaccess. Check your platform's current documentation, and confirm the headers landed by requesting the live page rather than assuming the config file was picked up.

For the setup this all sits inside, see self-hosting a feature request board. If the same page also sends notification email, deliverability is covered separately in SMTP for self-hosted tools.

Frequently asked questions

Does setting these headers make a feedback page secure?

No single header, or all together, makes a page secure. Each closes off a specific browser behaviour an attacker could abuse; none replaces sanitising input or moderating what gets published.

What is the safest way to start with Content-Security-Policy without breaking the page?

Start from a strict default like default-src 'self' and widen only the directive a known resource needs, one at a time, checking the browser console for blocked-resource warnings. Content-Security-Policy-Report-Only reports what a policy would have blocked without blocking it, so you can see the effect first.

Why use a nonce or a hash instead of unsafe-inline?

unsafe-inline allows any inline script to run, including one an attacker injects, removing most of CSP's protection. A nonce is a fresh random value attached to one script tag per page load; a hash is a fingerprint of one script's content. Both let a known script run while refusing anything injected.

Is X-Frame-Options still worth setting if I already have frame-ancestors in my CSP?

Yes, as a fallback. frame-ancestors is more capable, since CSP can list several allowed origins where X-Frame-Options cannot, but a small minority of browsers do not honour CSP's framing control.

What is the one HSTS setting worth being cautious about?

preload. It submits your domain to a list built into browsers themselves, so even a first-ever request refuses plain HTTP before your header is ever seen. That is far harder to reverse than max-age alone, so only add it once every subdomain can serve HTTPS correctly, permanently.