Put shared security headers in the template headers.json

Pitfall By design
To give every site that uses an app the same security headers (CSP and the rest), put them in headers.json in the template's root. A site's own #/headers.json replaces the template's file as a whole. A template's site.json does not work for this: each site takes its own copy on its first write, and stops following the template.
Applies to: AppsPackagesSecurity headersCSPheaders.jsonsite.json

What you'll see

You maintain an app (or a package app that sites use as their template) and want a Content-Security-Policy, or any other header, on every site built from it. The documentation says headers.json is legacy and that headers belong in the headers section of site.json, so you reach for site.json.

Or you set the policy in one site's #/headers.json to try it out, and then expect the other sites on the same template to have it too.

What's actually happening

#/headers.json and #/site.json are resolved like every other # path: the publication's own # folder first, then the app's root, then platform defaults. See Do not create a # folder inside an app.

The first match is used as a whole file. Nothing is merged key by key between the site and the template. A site with its own #/headers.json gets exactly that file, and none of the template's headers. The same holds for site.json: a local site.json that only contains images means the site has no headers from the template at all. A site's local file overriding the template is intended. What surprises people is that it overrides all of it.

Within a site, headers.json beats site.json. If the headers block in a site's site.json sets the same header as the headers.json in effect (the site's own or the template's), the headers.json value is used. site.json only fills in headers that headers.json does not set (Docly #29118). A template's CSP therefore cannot be weakened by a headers block in a site's site.json.

A site.json in a template is copied into each site the first time the site writes it. Writes to #/… always land in the publication's own # folder. When the file exists only in the template, the site gets its own copy, with the template's content as the starting point, and the write goes into that copy (Docly #29094). The platform writes site.json itself, during ordinary page requests: it records discovered image sizes there. So a site that inherits site.json soon has its own snapshot, and later changes to the template's site.json no longer reach it. headers.json is not written by the platform, so a template's headers.json stays a live default for every site that does not have its own.

The header cache is keyed on the site and compares against the newest of the site's and the template's modification times, so a change to the template's headers.json reaches every site without republishing.

What to do

Put headers that every site should have in headers.json in the template's root: ./headers.json, not ./#/headers.json. Here the "legacy" label does not apply; it is the only header file that reliably works as a template default.

{
  "Content-Security-Policy": "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'"
}
  • Give each template its own policy. Two templates published from the same site folder (an admin app and a public app, say) are two templates. Each needs its own file, and only the public one should allow what an embedded widget or Turnstile needs.
  • Do not leave a test copy in a site. A #/headers.json you added to one site to try a policy shadows the template's file for good. Delete it once the template has the policy, or that site will never get later changes.
  • Override per site only on purpose, and as a whole. A site that needs a different policy copies the template's file into its own #/headers.json and edits it. It then owns every header, not just the one it changed.
  • Do not rely on a template's site.json to share headers, minification or image settings. Each site takes a snapshot of it on its first write, and from then on follows its own copy, not the template.
  • A JSON error in headers.json is reported on every request and the platform falls back to its defaults. In a template that affects every site at once, so validate the JSON before you save it.

Start a CSP as Content-Security-Policy-Report-Only and switch the key to Content-Security-Policy once no page reports anything. A strict script-src 'self' means no inline <script> blocks and no onclick= attributes. Move the code into files, and pass server values as data- attributes encoded with docly.htmlAttributeEncode() (see Hash expressions do not escape HTML). The browser reports violations only in its developer tools. A securitypolicyviolation listener loaded first in <head> lets you collect them yourself.