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

# Security Headers

Enforcing transport security, restricting framing, narrowing referrer information, turning off MIME sniffing, and cross-origin isolation; a configuration checker that finds what is missing.

The previous three lessons built specific layers against specific attack classes. This
lesson takes up a group that is not tied to a single class but reduces the impact of all
of them: constraints declared through response headers and enforced by the browser.

What these headers have in common is that they live in server configuration, not
application code. It is easy for them to go unnoticed during a release, because they never
break any functionality; the only observable consequence of their absence appears when
something else goes wrong. The checker at the end of this lesson removes that invisibility.

## Enforcing Transport Security

The Role of HTTPS lesson in the How the Internet Works course established that encrypted
transport provides confidentiality, integrity, and authentication. Even when an
application is served entirely over an encrypted connection, one gap remains: when a user
types the address without a scheme, the first request can go out unencrypted and be
intercepted before the redirect happens.

The `Strict-Transport-Security` header closes this gap. It tells the browser to connect
to this host only over an encrypted connection for the declared duration; an address typed
without a scheme is converted before it ever hits the network. It has three components.
`max-age` is the duration in seconds, and it is kept long for the protection to be
meaningful. The `includeSubDomains` rule spreads it across subdomains; without it, a
subdomain can stay unencrypted and affect the application through cookie scope. The third
component is a preload declaration that registers the host in a list built into browsers;
it covers even the first request, but reversing it is slow, and it permanently commits
every subdomain to being served encrypted.

Sending the header on an unencrypted response has no effect, and should not: otherwise
someone intercepting the connection could get a record written that makes the host
unreachable.

## Restricting Framing

The North Slope Measurement Station interface embeds a map frame showing the station's
location. That is one direction of framing. The other direction is the application itself
being framed by another page, and that is the direction that needs defending.

A page placed inside a frame that has been made invisible sits underneath another
interface the user sees. The user thinks they are clicking a button they see, but actually
clicks a control belonging to the page inside the frame; because the session belongs to
them, the action goes through. This class is called **clickjacking**, and its defense is
declaring who is allowed to frame the document.

There are two mechanisms. The `X-Frame-Options` header takes the values `DENY` or
`SAMEORIGIN` and declares a single decision. The content security policy's
`frame-ancestors` directive accepts a list of origins and is more detailed: framing can be
allowed for specific origins only. In an implementation that understands the directive,
the directive wins; writing both together provides protection in implementations that
understand only one of them.

## Narrowing Referrer Information

When a link is clicked or a subresource is loaded, the browser adds a header telling the
destination which page the request came from. The default behavior can go as far as
sending the full address. On the measurement station, this means the path and query
components of an address in the form `.../station/north-slope?measurement=4417` leak to a
third party.

The `Referrer-Policy` header narrows this behavior. Its values sit on an axis, from an end
that sends nothing to an end that sends the full address. The balanced choice is a value
that sends the full address to the same origin, sends only the origin string to other
origins, and sends nothing on a transition from encrypted to unencrypted. If analysis
needs this information, it is drawn from the application's own measurement, not from the
header.

## Turning Off MIME Sniffing

If a response's content type is missing or wrong, the browser can look at the body and
guess the type. In an application where users upload files, this guess turns into a
vulnerability: an upload meant to be served as plain text, if interpreted as markup, runs
as a document within the same origin.

The `X-Content-Type-Options: nosniff` header turns off the guess and forces the browser to
honor the declared type. The header alone is not enough; the declared type being correct,
user uploads being served from a separate origin, and being marked for download are all
part of the same defense.

## Cross-Origin Isolation and Permission Policy

The `Cross-Origin-Resource-Policy` header declares whether a resource may be embedded by
other origins. When its value is `same-origin`, the resource can only be used in documents
from its own origin. This is a constraint that limits data being pulled into another
document's memory space.

Two more headers belong to the same family: one that cuts browsing-context sharing with
windows the document opens and windows that open it, and one that requires embedded
resources to declare explicit permission. Together the two establish a state called
**cross-origin isolation**. This state is a precondition for some capabilities and is not
required in an application that does not use those capabilities; turning it on
unnecessarily breaks legitimate embedding.

**Permission policy**, introduced in the Web Fundamentals and HTML course, is a separate
axis: it lists which device capabilities the document and the frames it embeds may use. If
the map frame has no need for the location capability, that capability is not granted to
the frame.

## Auditing the Configuration

Every one of these headers can be written correctly one at a time, and one can drop a
release later. Turning the audit into a rule set makes a dropped header visible.

```js
// headers.mjs — audits response headers against a rule set
const ONE_YEAR = 31536000;

const RULES = [
  {
    name: 'transport security',
    check: (h) => {
      const value = h['strict-transport-security'];
      if (!value) return 'Strict-Transport-Security missing';
      const duration = Number(/max-age=(\d+)/.exec(value)?.[1] ?? 0);
      if (duration < ONE_YEAR) return `max-age ${duration} seconds, shorter than a year`;
      if (!/includeSubDomains/i.test(value)) return 'includeSubDomains missing, subdomains excluded';
      return null;
    },
  },
  {
    name: 'content security policy',
    check: (h) => {
      const value = h['content-security-policy'];
      if (!value) return 'Content-Security-Policy missing';
      if (/script-src[^;]*'unsafe-inline'/.test(value)) return "script-src contains 'unsafe-inline'";
      if (!/frame-ancestors/.test(value)) return 'frame-ancestors directive missing';
      return null;
    },
  },
  {
    name: 'framing',
    check: (h) =>
      h['x-frame-options'] || /frame-ancestors/.test(h['content-security-policy'] ?? '')
        ? null
        : 'framing not restricted',
  },
  {
    name: 'MIME sniffing',
    check: (h) => (h['x-content-type-options'] === 'nosniff' ? null : 'X-Content-Type-Options: nosniff missing'),
  },
  {
    name: 'referrer policy',
    check: (h) => {
      const value = h['referrer-policy'];
      if (!value) return 'Referrer-Policy missing';
      const loose = ['unsafe-url', 'no-referrer-when-downgrade', 'origin-when-cross-origin'];
      return loose.includes(value) ? `loose value: ${value}` : null;
    },
  },
  {
    name: 'cross-origin resource policy',
    check: (h) => (h['cross-origin-resource-policy'] ? null : 'Cross-Origin-Resource-Policy missing'),
  },
  {
    name: 'session cookie',
    check: (h) => {
      const value = h['set-cookie'];
      if (!value) return null;
      const missing = ['HttpOnly', 'Secure', 'SameSite'].filter((n) => !new RegExp(n, 'i').test(value));
      return missing.length ? `cookie attributes missing: ${missing.join(', ')}` : null;
    },
  },
];

function audit(name, headers) {
  const findings = RULES.map((r) => [r.name, r.check(headers)]).filter(([, f]) => f !== null);
  console.log(`\n${name}: ${findings.length} findings`);
  for (const [rule, finding] of findings) console.log('  -', `${rule}:`, finding);
  if (findings.length === 0) console.log('  - no findings');
}

const initialRelease = {
  'content-type': 'text/html; charset=utf-8',
  'strict-transport-security': 'max-age=86400',
  'content-security-policy': "default-src 'self'; script-src 'self' 'unsafe-inline'",
  'referrer-policy': 'unsafe-url',
  'set-cookie': 'measurement_session=abc; Path=/',
};

const fixed = {
  'content-type': 'text/html; charset=utf-8',
  'strict-transport-security': 'max-age=31536000; includeSubDomains',
  'content-security-policy': "default-src 'self'; script-src 'self' 'nonce-r4k2'; frame-ancestors 'none'",
  'x-frame-options': 'DENY',
  'x-content-type-options': 'nosniff',
  'referrer-policy': 'strict-origin-when-cross-origin',
  'cross-origin-resource-policy': 'same-origin',
  'set-cookie': 'measurement_session=abc; Path=/; HttpOnly; Secure; SameSite=Lax',
};

audit('initial release', initialRelease);
audit('fixed configuration', fixed);
```

```
$ node headers.mjs

initial release: 7 findings
  - transport security: max-age 86400 seconds, shorter than a year
  - content security policy: script-src contains 'unsafe-inline'
  - framing: framing not restricted
  - MIME sniffing: X-Content-Type-Options: nosniff missing
  - referrer policy: loose value: unsafe-url
  - cross-origin resource policy: Cross-Origin-Resource-Policy missing
  - session cookie: cookie attributes missing: HttpOnly, Secure, SameSite

fixed configuration: 0 findings
  - no findings
```

Each defect in the first configuration is small on its own and none of them breaks the
application: the transport rule's duration is short, the policy allows inline code,
framing is not restricted, the session cookie carries none of its three attributes. The
findings list makes all of this visible in a single run.

The value of this audit is in running it continuously. Run as a pre-release step, a
configuration change that drops a header shows up the same day, not at the next security
review. The rule set also changes over time; when a new rule is added, the findings list
getting longer is the expected behavior.

## Summary

- Security headers live in server configuration and go unnoticed because their absence
  breaks no function silently.
- `Strict-Transport-Security` closes the first-request gap; without `includeSubDomains`,
  subdomains remain unprotected.
- Framing is restricted with `X-Frame-Options` or the `frame-ancestors` directive; the
  latter is more detailed because it accepts a list of origins.
- `Referrer-Policy` narrows path and query information leaking out; `X-Content-Type-Options: nosniff`
  turns off MIME sniffing.
- A rule resolver that audits the header set makes a dropped header visible the same day;
  the audit is run as a step in the release pipeline.

## Next Step

The defenses built so far rested on the application's own code and its own server. The
measurement interface also runs code it did not write: the map frame, the script that
draws the measurement chart, the hundreds of packages in the dependency tree. Every line of
this code runs with the authority of the application's origin. The next lesson takes up the
risk third-party code creates, where it arises across the supply chain, and how subresource
integrity narrows it.
