Put shared security headers in the template headers.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.jsonyou 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.jsonand edits it. It then owns every header, not just the one it changed. - Do not rely on a template's
site.jsonto 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.jsonis 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.