---
title: 'Content Security Policy'
source: 'https://academia.sh/en/courses/frontend-quality/content-security-policy'
course: 'Frontend Quality'
language: en
updated: '2026-08-17T18:11:06+00:00'
license: 'CC BY-SA 4.0'
---

# Content Security Policy

Writing the policy that tells the browser which origins it may load from; directives and source expressions, nonce and hash-based allowance, report-only mode and violation reports, and the areas the policy does not cover.

The previous lesson placed the defense at the output: context-appropriate escaping at
every write site. This defense's weakness is its scope. Being correct requires every path
in the application to be correct; a single raw-markup write site, a single third-party
component, a single legacy page template is enough. Assuming this scope holds continuously
in a large interface is an assumption that is hard to audit.

This lesson builds a second layer. What matters about this layer is that it does not
depend on the correctness of the application's own code. The decision is declared to the
browser, and the browser enforces it at load time no matter what the document says. The
policy set across the North Slope Measurement Station interface fixes, this way, which
scripts the page showing a measurement note may run and whether the embedded map frame is
allowed.

## What the Policy Does

**Content security policy** is a declaration carried by a response's
`Content-Security-Policy` header. The declaration lists what kind of resource the document
may load from where, and under what condition inline code may run. Before fetching a
subresource or running a script, the browser checks the policy; if there is no match in
the expression list, it does not carry out the action.

This is not a defense that replaces escaping. Escaping stops data from turning into code;
the policy narrows what code that runs anyway, past the boundary, can do. The two are
independent layers, and because they are independent, they work together.

The policy is sent as an HTTP header. It can also be declared with a `meta` element in the
document, but this route does not support some directives and only takes effect after the
document has already started parsing. Defining it as a header in server configuration is
the route with the wider reach.

## Directives and Source Expressions

The policy consists of **directives** separated by semicolons. Each directive names a
resource type and is followed by a list of **source expressions** valid for that type.

`default-src` is the fallback for the other directives: when the policy carries no
directive for a given type, the browser falls back to this list. `script-src` covers
scripts, `style-src` covers stylesheets, `img-src` covers images, `connect-src` covers
network connections opened from script, and `font-src` covers fonts. Alongside these are
directives that decide things other than loading: `frame-ancestors` decides who may frame
the document, `form-action` decides where a form submission may go, and `base-uri` decides
whether the document's base address can be changed.

Source expressions come in a few forms. `'none'` allows nothing. `'self'` denotes the
document's own origin. A scheme expression (`https:`, `data:`) covers every address from
that scheme. A host expression names a domain, and, with a leading asterisk, also covers
its subdomains. `'nonce-…'` and `'sha256-…'` allow inline code one at a time.
`'unsafe-inline'` allows all inline code, and the warning in its name is warranted: this
expression largely cancels out the protection the policy provides against script
execution.

## Turning the Policy into a Decision

The most direct way to see how a policy is read is to write the code that reads it. The
resolver below splits a policy string into directives, tests a given request against the
relevant list, and applies the fallback-to-default behavior.

```js
// csp.mjs — resolves a policy string and decides whether a resource is allowed
function resolvePolicy(policy) {
  const directives = new Map();
  for (const part of policy.split(';')) {
    const words = part.trim().split(/\s+/).filter(Boolean);
    if (words.length === 0) continue;
    directives.set(words[0].toLowerCase(), words.slice(1));
  }
  return directives;
}

function hostMatches(expression, url) {
  const [schemePart, rest] = expression.includes('://') ? expression.split('://') : [null, expression];
  const [host, ...path] = rest.split('/');
  if (schemePart && `${schemePart}:` !== url.protocol) return false;
  if (host.startsWith('*.')) {
    if (!url.hostname.endsWith(host.slice(1))) return false;
  } else if (host !== url.host && host !== url.hostname) {
    return false;
  }
  if (path.length > 0 && path[0] !== '') return url.pathname.startsWith('/' + path.join('/'));
  return true;
}

function expressionMatches(expression, request, documentOrigin) {
  if (expression === "'none'") return false;
  if (expression === "'self'") return request.url !== null && new URL(request.url).origin === documentOrigin;
  if (expression === "'unsafe-inline'") return request.inline === true && !request.nonce;
  if (expression.startsWith("'nonce-")) return request.nonce === expression.slice(7, -1);
  if (expression.startsWith("'sha256-")) return request.hash === expression.slice(1, -1);
  if (expression.endsWith(':')) return request.url !== null && new URL(request.url).protocol === expression;
  return request.url !== null && hostMatches(expression, new URL(request.url));
}

function decide(policy, request, documentOrigin) {
  const directives = resolvePolicy(policy);
  const name = directives.has(request.directive) ? request.directive : 'default-src';
  const list = directives.get(name);
  if (!list) return { allowed: true, directive: null };
  const matched = list.find((expression) => expressionMatches(expression, request, documentOrigin));
  return { allowed: Boolean(matched), directive: name, matched: matched ?? null };
}

function reportEntry(request, result, policy, disposition) {
  return {
    'document-uri': 'https://measurements.example.test/station/north-slope',
    'violated-directive': result.directive,
    'blocked-uri': request.url ?? 'inline',
    'disposition': disposition,
    'original-policy': policy,
  };
}

const POLICY =
  "default-src 'none'; script-src 'self' 'nonce-r4k2' https://measurements-cdn.example.test; " +
  "img-src 'self' data:; connect-src 'self' https://*.data.example.test; frame-ancestors 'none'";
const ORIGIN = 'https://measurements.example.test';

const requests = [
  { name: 'own script', directive: 'script-src', url: 'https://measurements.example.test/js/measurements.js' },
  { name: 'distribution script', directive: 'script-src', url: 'https://measurements-cdn.example.test/chart.js' },
  { name: 'foreign script', directive: 'script-src', url: 'https://other.example.test/tracking.js' },
  { name: 'inline with nonce', directive: 'script-src', url: null, inline: true, nonce: 'r4k2' },
  { name: 'inline without nonce', directive: 'script-src', url: null, inline: true },
  { name: 'embedded data image', directive: 'img-src', url: 'data:image/png;base64,iVBOR' },
  { name: 'subdomain call', directive: 'connect-src', url: 'https://measurements1.data.example.test/record' },
  { name: 'font', directive: 'font-src', url: 'https://measurements.example.test/f/a.woff2' },
];

for (const request of requests) {
  const result = decide(POLICY, request, ORIGIN);
  const line = [
    (result.allowed ? 'allow' : 'block').padEnd(6),
    request.name.padEnd(22),
    (result.directive ?? '-').padEnd(14),
    result.matched ?? '',
  ].join(' ');
  console.log(line.trimEnd());
}

console.log('\nreport entry:');
const blocked = requests[2];
console.log(JSON.stringify(reportEntry(blocked, decide(POLICY, blocked, ORIGIN), POLICY, 'enforce'), null, 2));
```

```
$ node csp.mjs
allow  own script             script-src     'self'
allow  distribution script    script-src     https://measurements-cdn.example.test
block  foreign script         script-src
allow  inline with nonce      script-src     'nonce-r4k2'
block  inline without nonce   script-src
allow  embedded data image    img-src        data:
allow  subdomain call         connect-src    https://*.data.example.test
block  font                   default-src

report entry:
{
  "document-uri": "https://measurements.example.test/station/north-slope",
  "violated-directive": "script-src",
  "blocked-uri": "https://other.example.test/tracking.js",
  "disposition": "enforce",
  "original-policy": "default-src 'none'; script-src 'self' 'nonce-r4k2' https://measurements-cdn.example.test; img-src 'self' data:; connect-src 'self' https://*.data.example.test; frame-ancestors 'none'"
}
```

The last line shows the policy's most instructive behavior. The font request falls into
the `default-src 'none'` list, because there is no `font-src` directive, and gets blocked.
This is the recommended way to write a strict policy: start with `default-src 'none'` and
add a directive explicitly for each type the application actually uses. A missing
directive turns into a visible failure instead of silently allowing anything through.

## Inline Code with Nonces and Hashes

If inline script is genuinely needed, there are two routes, and neither uses
`'unsafe-inline'`.

A **nonce** is an unpredictable value the server generates for each response. It is
declared in the policy as `'nonce-r4k2'`, and the same value is written on the script
element as the `nonce` attribute. The browser runs only the matching element. Three rules
are binding: the value is generated with cryptographic-quality randomness, it is renewed
on every response, and it is not reused with a cached page. A fixed nonce reduces the
policy to the level of `'unsafe-inline'`.

**Hash-based allowance** suits inline scripts whose content does not change. A digest of
the script's body is taken and written into the policy as `'sha256-…'`. It requires no
per-response generation, but the policy must be updated the moment one character of the
script text changes, which means the hash has to be computed at build time.

As host lists grow, a policy's protective value drops, because every domain on the list
becomes a trusted load point. The `'strict-dynamic'` expression, defined to reduce this
problem, delegates trust to whatever a nonce-run script loads, making the host list
unnecessary. For an implementation that does not understand this expression to keep
working, it is written alongside the host list: old behavior reads the list, new behavior
ignores it.

## Report-Only Mode and Rollout

Applying a strict policy directly to a working application breaks functioning features.
This is why the `Content-Security-Policy-Report-Only` header exists: the browser evaluates
the policy and reports violations, but blocks nothing. In the report entry above, the
`disposition` field carries this distinction — `enforce` under enforce mode, `report`
under report-only mode.

Violation reports are sent to a reporting endpoint declared in the policy. The entry's
fields say which document the violation occurred in, which directive was violated, and
what was blocked. For inline code, the `blocked-uri` field carries the value `inline`
instead of an address.

The rollout order is this: start with report-only mode, collect incoming reports for a
while, add legitimate load points to the policy, and fix remaining violations in code —
only then move to enforce mode once the flow of reports has settled. The reporting
endpoint itself must be resilient to noise: browser extensions that inject code into the
page on the user side produce violations unrelated to the application, and if these
reports are not filtered out, genuine findings become invisible.

## What the Policy Does Not Cover

The policy governs loading and execution decisions; it does not govern what legitimate
code running inside the document does. The DOM-based class introduced in the previous
lesson falls into this gap: if the application's own script writes a value from its own
origin as raw markup, the policy does not count that as an external load.

The mechanism defined to narrow this gap makes insertion points that accept raw markup
writable only through an audited type, turning a plain string assignment into an error. It
attaches to the policy through a directive and is measurable in report-only mode. Because
support is not available in every environment, it is tested with feature detection, and
where it is absent the application falls back on the escaping layer built in the previous
lesson.

## Summary

- Content security policy tells the browser which origin may load and which code may run,
  and makes the decision independent of the application's own code.
- The policy consists of directives; `default-src` is the fallback for the rest, so
  starting with `default-src 'none'` and adding each needed type one at a time makes gaps
  visible.
- For inline code, a nonce renewed per response or a hash computed at build time replaces
  `'unsafe-inline'`.
- Report-only mode evaluates the policy without blocking; rollout starts in report-only
  mode and moves to enforce mode once the flow of reports has settled.
- The policy governs external load decisions; the document's own code writing raw markup
  is constrained by a separate mechanism.

## Next Step

The policy says where a script may load from. Once the script has loaded and sends a
request to another origin to read data, a different set of rules takes over: the
same-origin policy does not allow the response to that request to be read by default. For
the measurement interface to read from a data endpoint at a separate domain, the server
has to declare explicit permission. The next lesson builds this permission mechanism, the
constraints the browser applies, and the correct server configuration.
