Skip to content
Codeloom
Web

Web Security Headers: A Complete Guide

Every security header explained with examples: CSP, HSTS, X-Frame-Options, Permissions-Policy, CORS headers, and how to deploy them safely without breaking your site.

·7 min read · By Codeloom
Intermediate 13 min read

What you'll learn

  • What each security header does and what attacks it prevents
  • How to configure CSP with nonces for inline scripts
  • How to enable HSTS without locking yourself out
  • How to set Permissions-Policy to restrict browser APIs
  • How to deploy headers incrementally with report-only mode

Prerequisites

  • Basic HTTP and web server concepts
  • Understanding of XSS and CSRF

Why Headers Matter

Security headers are HTTP response headers that tell the browser to restrict what a page can do. They are your second line of defense: even if an attacker injects code into your page, the browser refuses to execute it because the headers say no.

Without security headers, a browser trusts everything the page does. With them, you restrict scripts, frames, connections, and browser APIs to only what your application actually needs.

The Headers, One by One

Content-Security-Policy (CSP)

CSP tells the browser which sources of content are allowed. If a script tag loads from an unlisted origin, the browser blocks it.

Basic policy:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-abc123';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https://cdn.example.com;
  font-src 'self' https://fonts.gstatic.com;
  connect-src 'self' https://api.example.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';

Key directives:

DirectiveControlsExample
default-srcFallback for all resource types'self'
script-srcJavaScript sources'self' 'nonce-...'
style-srcCSS sources'self' 'unsafe-inline'
img-srcImage sources'self' data: https://cdn.example.com
connect-srcFetch, XHR, WebSocket destinations'self' https://api.example.com
frame-ancestorsWho can embed this page in an iframe'none'
base-uriRestricts <base> element'self'
form-actionWhere forms can submit to'self'

Using nonces for inline scripts:

A nonce is a random value generated per request. The server puts it in the CSP header and in the script tag. The browser only runs inline scripts whose nonce matches.

// Server-side: generate nonce per request
import crypto from 'node:crypto';

const nonce = crypto.randomBytes(16).toString('base64');

// Set the CSP header
response.headers.set(
  'Content-Security-Policy',
  `script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; default-src 'self'`,
);
<!-- In the HTML -->
<script nonce="generated-nonce-here">
  console.log('This runs because the nonce matches');
</script>

<!-- This does NOT run because there is no matching nonce -->
<script>
  console.log('Blocked by CSP');
</script>

Report-only mode lets you test a policy without blocking anything:

Content-Security-Policy-Report-Only:
  default-src 'self';
  report-uri /api/csp-report;
  report-to csp-endpoint;
// Collect CSP violation reports
export const POST: APIRoute = async ({ request }) => {
  const report = await request.json();
  console.log('CSP Violation:', {
    blockedUri: report['csp-report']?.['blocked-uri'],
    violatedDirective: report['csp-report']?.['violated-directive'],
    documentUri: report['csp-report']?.['document-uri'],
  });
  return new Response(null, { status: 204 });
};

Strict-Transport-Security (HSTS)

HSTS tells the browser to always use HTTPS for your domain. After the first visit, the browser will never make an HTTP request — it upgrades to HTTPS before the request leaves the machine.

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
  • max-age=31536000: Remember for 1 year (in seconds)
  • includeSubDomains: Apply to all subdomains
  • preload: Submit to the HSTS preload list so browsers enforce HTTPS on the first visit

Deployment strategy:

# Start with a short max-age to test
Strict-Transport-Security: max-age=300

# After a week, increase
Strict-Transport-Security: max-age=86400

# After a month, go full
Strict-Transport-Security: max-age=31536000; includeSubDomains

# Finally, add preload
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

Warning: once you enable HSTS with includeSubDomains, every subdomain must support HTTPS. If internal.example.com does not have a certificate, it becomes unreachable. Start without includeSubDomains and add it only when all subdomains are ready.

X-Content-Type-Options

Prevents MIME type sniffing. Without this, a browser might interpret a malicious HTML file served as text/plain as executable HTML.

X-Content-Type-Options: nosniff

Always set this. There is no reason not to.

X-Frame-Options

Controls whether your site can be loaded in an iframe. Prevents clickjacking attacks.

X-Frame-Options: DENY

Options:

  • DENY: Cannot be framed by anyone
  • SAMEORIGIN: Can only be framed by the same origin

Note: frame-ancestors in CSP is more flexible and supersedes X-Frame-Options, but set both for browser compatibility.

Referrer-Policy

Controls how much referrer information is sent when navigating away from your site.

Referrer-Policy: strict-origin-when-cross-origin

Options ranked from most to least restrictive:

  • no-referrer: Never send referrer
  • strict-origin-when-cross-origin: Full URL for same-origin, origin only for cross-origin over HTTPS, nothing for HTTPS-to-HTTP
  • origin-when-cross-origin: Full URL for same-origin, origin only for cross-origin
  • unsafe-url: Always send the full URL (do not use this)

Permissions-Policy

Restricts which browser APIs your site can use. Prevents embedded iframes from using your camera, microphone, etc.

Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()

Common permissions:

Permissions-Policy:
  camera=(),
  microphone=(),
  geolocation=(self),
  payment=(self "https://pay.example.com"),
  fullscreen=(self),
  display-capture=(),
  interest-cohort=()
  • (): Disabled entirely
  • (self): Allowed only for this origin
  • (self "https://example.com"): Allowed for this origin and the listed origin

Cross-Origin Headers

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: same-origin

These three headers work together to enable SharedArrayBuffer and high-resolution timers (needed for WebAssembly threads) while preventing cross-origin attacks:

  • COOP: Isolates your window from cross-origin popups
  • COEP: Requires all cross-origin resources to explicitly opt in
  • CORP: Declares whether a resource can be loaded cross-origin

Only enable these if you need SharedArrayBuffer. They break legitimate cross-origin resource loading.

Implementation: All Headers in One Place

Astro Middleware

// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
import crypto from 'node:crypto';

export const onRequest = defineMiddleware(async (context, next) => {
  const nonce = crypto.randomBytes(16).toString('base64');
  context.locals.nonce = nonce;

  const response = await next();

  // Security headers
  response.headers.set(
    'Content-Security-Policy',
    [
      `default-src 'self'`,
      `script-src 'self' 'nonce-${nonce}'`,
      `style-src 'self' 'unsafe-inline'`,
      `img-src 'self' data: https:`,
      `font-src 'self'`,
      `connect-src 'self'`,
      `frame-ancestors 'none'`,
      `base-uri 'self'`,
      `form-action 'self'`,
    ].join('; '),
  );

  response.headers.set(
    'Strict-Transport-Security',
    'max-age=31536000; includeSubDomains',
  );
  response.headers.set('X-Content-Type-Options', 'nosniff');
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
  response.headers.set(
    'Permissions-Policy',
    'camera=(), microphone=(), geolocation=(), payment=()',
  );

  return response;
});

Nginx

# /etc/nginx/conf.d/security-headers.conf
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; frame-ancestors 'none'" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;

Vercel (vercel.json)

{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "X-Content-Type-Options", "value": "nosniff" },
        { "key": "X-Frame-Options", "value": "DENY" },
        { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
        { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" },
        { "key": "Strict-Transport-Security", "value": "max-age=31536000; includeSubDomains" }
      ]
    }
  ]
}

Netlify (_headers)

/*
  X-Content-Type-Options: nosniff
  X-Frame-Options: DENY
  Referrer-Policy: strict-origin-when-cross-origin
  Strict-Transport-Security: max-age=31536000; includeSubDomains
  Permissions-Policy: camera=(), microphone=(), geolocation=()

Testing Your Headers

SecurityHeaders.com

Visit securityheaders.com and enter your URL. It grades your headers from A+ to F.

Browser DevTools

Open Network tab, click on the document request, and check the Response Headers section.

curl

curl -I https://example.com | grep -i -E 'content-security|strict-transport|x-frame|x-content-type|referrer-policy|permissions-policy'

Deployment Checklist

  1. Start with Content-Security-Policy-Report-Only to find what would break
  2. Collect reports for a week, adjust the policy
  3. Switch to enforcing Content-Security-Policy
  4. Add Strict-Transport-Security with a short max-age first
  5. Gradually increase max-age over weeks
  6. Add includeSubDomains only when all subdomains support HTTPS
  7. Set X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and Permissions-Policy immediately (they rarely break anything)

Summary

Security headers are high-impact, low-effort defenses. CSP blocks injected scripts. HSTS forces HTTPS. X-Frame-Options prevents clickjacking. Permissions-Policy restricts browser API access. Deploy them incrementally using report-only mode, test with automated scanners, and treat them as non-negotiable infrastructure for any production web application.