Security headers and CSP
The complete response-header inventory for the platform: what the _headers file declares, which of those declarations survive to the browser, what the worker sets itself, and what is not set at all.
Last updated 2026-08-06
Summary#
This is the exhaustive inventory of response headers the platform emits, split into three groups: what _headers declares, what actually reaches the browser, and what the worker adds on its own responses. It is a reference, so it is complete rather than representative, and it records absences as explicitly as presences.
Two facts do most of the work on this page. _headers contains cache directives only; there is no security header in it. And most of those directives never reach the client, because HTML responses are rebuilt by the worker before they are returned.
Overview#
The platform is a Cloudflare Pages project in advanced mode. A single _worker.js answers every request that _routes.json does not exclude. Static assets are fetched through env.ASSETS.fetch(request) and then, for anything with a text/html content type, a brand-new Response is constructed carrying only Content-Type so the chat widget can be injected. Every other header on the asset response is discarded at that point, including the Cache-Control, Pragma and Expires values _headers set for that path.
That behavior is the single most important thing to know before you change anything here. Adding a header to _headers for an HTML route has no effect. Adding it to the response the worker builds does.
Non-HTML assets are returned unmodified, so their _headers entries do survive. /login and /login.html are unaffected for a different reason: the worker intercepts them earlier and returns the inlined LOGIN_HTML constant with its own explicit no-cache headers, so the file is never consulted.
The _headers file, in full#
Seven rules, all cache directives. Nothing else is in the file.
| Path | Headers declared | Reaches the browser? |
|---|---|---|
/login.html | Cache-Control: no-cache, no-store, must-revalidate, Pragma: no-cache, Expires: 0 | Not from this file. The worker serves the inlined login page with equivalent headers of its own |
/login | Same three | Same |
/dashboard.html | Same three | No. The HTML response is rebuilt with Content-Type only |
/dashboard | Same three | No, same reason |
/sw.js | Cache-Control: no-cache, no-store, must-revalidate | Yes. Non-HTML assets pass through untouched |
/index.html | Cache-Control: no-cache, no-store, must-revalidate | No, same reason as /dashboard |
/ | Cache-Control: no-cache, no-store, must-revalidate | No, same reason |
Important: if you need a header on /dashboard, /index.html or /, add it where the worker builds the HTML response. _headers will not deliver it.
Security headers the platform sets#
This is the complete list of security-relevant headers emitted by first-party responses.
| Header | Value | Where it is set |
|---|---|---|
Referrer-Policy | no-referrer | One branch of the responsive-preview proxy only |
Cache-Control | no-store | Diagnostic 403 responses, the inlined login page, article and blog HTML, and every JSON response built through the shared no-cache helper |
Cache-Control | no-store | The API key creation response, so a generated key is never held by an intermediary |
That is the entire list.
Security headers the platform does not set#
| Header | Status on main | Consequence |
|---|---|---|
Content-Security-Policy | Not set on any first-party response | No browser-side containment of injected script. This is what makes the share-page surface below matter |
Strict-Transport-Security | Not set by the worker | HTTPS is terminated at the edge; whether the zone adds HSTS is a Cloudflare zone setting and is not visible from the repository |
X-Content-Type-Options | Not set | Content-type sniffing is not suppressed |
X-Frame-Options | Not set | The app and marketing pages can be framed |
Permissions-Policy | Not set | No feature-policy restrictions are declared |
Cross-Origin-Opener-Policy / Cross-Origin-Resource-Policy | Not set | No cross-origin isolation |
Two things follow that are worth stating plainly rather than discovering later.
The published Security page at metricvaultai.com states that Content Security Policy headers are in use. They are not, on main. Treat closing that gap as a documentation-and-code task with both halves outstanding, not as a wording problem.
The product ships an HTTP Security Headers tool that grades third-party sites on exactly this header set. See Security Headers. Running it against metricvaultai.com is a fair test and will report what this table reports.
The shared-report page#
GET /share/<slug> is intentionally public: no session, no token, and a view_count increment on each view. The stored report body is inserted into the page unescaped, because escaping would destroy the legitimate HTML of a captured report. The response carries only Content-Type: text/html; charset=utf-8.
The consequence is a stored-script surface on the main origin. The fix that exists — on the unmerged security/deferred-hardening branch, not on main — is a per-request nonce CSP rather than escaping:
Content-Security-Policy: default-src 'self'; script-src 'nonce-<n>';
style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:;
font-src 'self' data:; connect-src 'self';
object-src 'none'; base-uri 'none'; form-action 'none'
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrerFour properties of that design are worth carrying forward if you touch it:
- The nonce is minted per view request, after the body was stored, so it cannot be known at the time content is injected.
- Every script the page owns carries the nonce, including the external chart library tag. A nonce authorises an external script regardless of its host.
- A public multi-tenant CDN host must never be allow-listed in
script-src. An earlier revision allow-listed one and thereby defeated the nonce entirely, because such hosts serve arbitrary third-party code. Nonce the tag instead. - The policy blocks the inline
on*handlers baked into captured reports. A nonced compatibility script restores the two behaviors that matter, drill-down reveal and broken-image hiding; inline hover effects stay inert.
The 404 and 410 responses on that route carry the same headers in that design.
CORS#
| Helper | Headers |
|---|---|
| Standard JSON responses | Content-Type: application/json, Access-Control-Allow-Origin: * |
| Chrome-extension responses | The above plus Access-Control-Allow-Methods: GET, POST, OPTIONS and Access-Control-Allow-Headers: Content-Type, X-Extension-Version |
| No-cache JSON responses | The standard set plus Cache-Control: no-store, no-cache, must-revalidate, max-age=0, Pragma: no-cache, CDN-Cache-Control: no-store, Cloudflare-CDN-Cache-Control: no-store |
Global preflight, every OPTIONS on any path | Access-Control-Allow-Origin: *, Access-Control-Allow-Methods: GET, POST, OPTIONS, Access-Control-Allow-Headers: Content-Type, Authorization |
Access-Control-Allow-Credentials is never set anywhere, and no cookie carries authentication. The session token travels in an Authorization header attached by client JavaScript.
The wildcard origin is a deliberate decision, not an oversight. The Chrome extension runs from a chrome-extension:// origin and the public API is called from arbitrary servers; both need *. Because no credentials ride along, a cross-origin request gains an attacker nothing that a direct request would not also give them. What makes a cross-origin request useful today is the body-trusted identity class described in Authorization model, not the CORS policy. Tightening CORS would break the extension and the public API for no security benefit; closing the identity gap is the fix.
Other response headers the platform sets#
Not security headers, but part of the same inventory and frequently asked about.
| Header | Value | Where |
|---|---|---|
X-MV-Cache | hit | A shared-cache hit on a premium tool response |
X-MV-Cache-Age-Days | Whole days since the cached payload was fetched | Same responses |
X-MV-Saved-Id | The Library row id for the run | Tool responses that were saved |
Access-Control-Expose-Headers | X-MV-Cache, X-MV-Cache-Age-Days, X-MV-Saved-Id | Same responses, so the browser can read them |
X-Cache | HIT or MISS | The extension quickview endpoint |
X-Cache-Debug | A small JSON blob | Same endpoint |
Cache-Control | public, max-age=31536000, immutable | Blog media served from object storage |
Access-Control-Allow-Origin | * | Blog media, and the responsive-preview asset proxy |
Timing-Allow-Origin | * | The responsive-preview asset proxy |
Cache-Control | public, max-age=300 | The responsive-preview asset proxy, and pages served through the edge cache from the GitHub raw fallback |
Cache-Control | public, max-age=3600 | Brand logo images served through the same fallback |
The responsive-preview proxy additionally strips in-document <meta http-equiv="content-security-policy"> tags from the pages it fetches. That is the point of the proxy: a page that forbids framing cannot otherwise be previewed. The preview iframe is sandboxed without allow-same-origin, specifically so a framed site cannot read the dashboard's session, which is why the proxy injects shims for storage, matchMedia, history and document.cookie.
If you are adding headers#
- Decide whether the response is built by the worker or passed through from
env.ASSETS. Only the first can carry new headers today. - For HTML routes, add them where the injected-chatbot response is constructed, not in
_headers. - Introduce a CSP in report-only mode first. The dashboard and the marketing pages carry a large amount of inline script and inline
styleattributes, and an enforcing policy will break them until each is nonced or moved. - Never allow-list a public multi-tenant CDN host in
script-src. - Re-run Security Headers against the deployed origin afterwards. It is the fastest end-to-end check that the header actually shipped.
See also
Was this article helpful?
Thanks — feedback noted for the docs team.