Securityintermediate

Security Headers & Best Practices — The Complete Guide

Learn how to configure HTTP security headers to protect your web application. Covers Content-Security-Policy, X-Frame-Options, HSTS, X-Content-Type-Options, Referrer-Policy, and Permissions-Policy.

16 min read·Published Apr 23, 2026
securityheaderscspbest-practices

Why Security Headers Matter

HTTP security headers instruct the browser to enable (or disable) specific security behaviors. They are your first line of defense against common web attacks — XSS, clickjacking, MIME sniffing, data leaks, and protocol downgrades.

The best part: they require no code changes to your application logic. They are configured at the server or reverse proxy level and apply to every response.

 Server Response
 ---------------
 HTTP/1.1 200 OK
 Content-Security-Policy: default-src 'self'       <-- Blocks XSS
 X-Frame-Options: DENY                              <-- Blocks clickjacking
 X-Content-Type-Options: nosniff                    <-- Blocks MIME sniffing
 Strict-Transport-Security: max-age=63072000        <-- Forces HTTPS
 Referrer-Policy: strict-origin-when-cross-origin   <-- Controls referrer
 Permissions-Policy: camera=(), microphone=()       <-- Restricts APIs
 Content-Type: text/html; charset=utf-8

 <html>...

Without security headers, browsers use permissive defaults that attackers exploit. Adding headers is one of the highest impact, lowest effort security improvements you can make.

Content-Security-Policy (CSP)

CSP is the most powerful security header. It tells the browser which sources of content (scripts, styles, images, fonts, frames, etc.) are allowed. If a source is not in the policy, the browser blocks it.

CSP is your strongest defense against XSS. Even if an attacker injects a script tag, CSP prevents it from executing if the source is not allowed.

CSP Directives

+---------------------+----------------------------------------------+
| Directive           | Controls                                     |
+---------------------+----------------------------------------------+
| default-src         | Fallback for all other directives            |
| script-src          | JavaScript sources                           |
| style-src           | CSS sources                                  |
| img-src             | Image sources                                |
| font-src            | Font file sources                            |
| connect-src         | XHR, fetch, WebSocket, EventSource targets   |
| frame-src           | iframe sources                               |
| frame-ancestors     | Who can embed this page in an iframe         |
| object-src          | <object>, <embed>, <applet> sources          |
| media-src           | <audio> and <video> sources                  |
| base-uri            | Restricts <base> element URLs                |
| form-action         | Where forms can submit to                    |
| upgrade-insecure-   | Auto-upgrade HTTP to HTTPS                   |
|   requests          |                                              |
| report-uri          | URL to send violation reports to             |
| report-to           | Reporting API endpoint (newer)               |
+---------------------+----------------------------------------------+

CSP Source Values

+---------------------+----------------------------------------------+
| Value               | Meaning                                      |
+---------------------+----------------------------------------------+
| 'self'              | Same origin only                             |
| 'none'              | Block all sources for this type              |
| 'unsafe-inline'     | Allow inline scripts/styles (weakens CSP)    |
| 'unsafe-eval'       | Allow eval() and similar (weakens CSP)       |
| 'nonce-<base64>'    | Allow specific inline script/style by nonce  |
| 'strict-dynamic'    | Trust scripts loaded by already-trusted scripts|
| https:              | Any HTTPS source                             |
| data:               | Data URIs (e.g., data:image/png)             |
| *.example.com       | Any subdomain of example.com                 |
| https://cdn.com     | Specific origin                              |
+---------------------+----------------------------------------------+

Building a CSP: From Permissive to Strict

Start with a report-only policy, then tighten based on reports:

// Step 1: Report-only mode — learn what your app loads
const reportOnlyCSP = [
  "default-src 'self'",
  "script-src 'self'",
  "style-src 'self'",
  "img-src 'self'",
  "font-src 'self'",
  "connect-src 'self'",
  "frame-src 'none'",
  "object-src 'none'",
  "report-uri /api/csp-report",
].join('; ');

app.use((req, res, next) => {
  res.setHeader('Content-Security-Policy-Report-Only', reportOnlyCSP);
  next();
});

// Endpoint to collect CSP violation reports
app.post('/api/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => {
  const report = req.body['csp-report'];
  console.log('CSP Violation:', {
    blockedUri: report['blocked-uri'],
    violatedDirective: report['violated-directive'],
    documentUri: report['document-uri'],
    sourceFile: report['source-file'],
    lineNumber: report['line-number'],
  });
  res.status(204).end();
});
// Step 2: After analyzing reports, build production CSP
const productionCSP = [
  "default-src 'self'",
  "script-src 'self' https://cdn.example.com",
  "style-src 'self' 'unsafe-inline'",  // Often needed for CSS-in-JS
  "img-src 'self' data: https://images.example.com",
  "font-src 'self' https://fonts.gstatic.com",
  "connect-src 'self' https://api.example.com",
  "frame-src 'none'",
  "frame-ancestors 'none'",
  "object-src 'none'",
  "base-uri 'self'",
  "form-action 'self'",
  "upgrade-insecure-requests",
].join('; ');

app.use((req, res, next) => {
  res.setHeader('Content-Security-Policy', productionCSP);
  next();
});

Nonces allow specific inline scripts while blocking all others. Each request gets a unique nonce.

const crypto = require('crypto');

// Middleware: generate nonce per request
app.use((req, res, next) => {
  const nonce = crypto.randomBytes(16).toString('base64');
  res.locals.nonce = nonce;

  res.setHeader('Content-Security-Policy', [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}'`,
    "style-src 'self' 'unsafe-inline'",
    "img-src 'self' data:",
    "object-src 'none'",
    "base-uri 'self'",
  ].join('; '));

  next();
});

// In your template (EJS example):
// <script nonce="<%= nonce %>">
//   // This runs because the nonce matches
//   console.log('Allowed inline script');
// </script>
//
// <script>
//   // This is BLOCKED — no nonce
//   console.log('Blocked inline script');
// </script>

CSP in Next.js

// next.config.js
const crypto = require('crypto');

module.exports = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: [
              "default-src 'self'",
              "script-src 'self' 'unsafe-eval' 'unsafe-inline'",
              // Next.js requires unsafe-eval in development
              // and unsafe-inline for its runtime
              // Use nonce-based approach for stricter production CSP
              "style-src 'self' 'unsafe-inline'",
              "img-src 'self' data: https:",
              "font-src 'self'",
              "connect-src 'self'",
              "frame-src 'none'",
              "frame-ancestors 'none'",
              "object-src 'none'",
              "base-uri 'self'",
              "form-action 'self'",
            ].join('; '),
          },
        ],
      },
    ];
  },
};

For strict nonce-based CSP with Next.js, use the nonce prop in next/script:

// app/layout.tsx (App Router)
import { headers } from 'next/headers';
import Script from 'next/script';

export default function RootLayout({ children }) {
  const nonce = headers().get('x-nonce') || '';

  return (
    <html>
      <body>
        {children}
        <Script nonce={nonce} strategy="afterInteractive">
          {`console.log('This script has a nonce');`}
        </Script>
      </body>
    </html>
  );
}

X-Frame-Options (Clickjacking Prevention)

X-Frame-Options controls whether your page can be embedded in an iframe. Clickjacking attacks overlay a transparent iframe of your page on top of a malicious page — the user thinks they are clicking on the malicious page but actually clicks on your hidden page.

 Clickjacking Attack:

 User sees:                 Actually happening:
 +---------------------+    +---------------------+
 | Malicious page      |    | Your app (iframe)   |
 |                     |    | (transparent)        |
 | "Win a prize!"      |    |                     |
 | [Click Here]  <---------> [Delete Account]     |
 |                     |    |                     |
 +---------------------+    +---------------------+
+---------------------+----------------------------------------------+
| Value               | Effect                                       |
+---------------------+----------------------------------------------+
| DENY                | Page cannot be embedded in any iframe         |
| SAMEORIGIN          | Page can only be embedded by same origin      |
| ALLOW-FROM uri      | Page can be embedded by specified origin      |
|                     | (deprecated — use CSP frame-ancestors)       |
+---------------------+----------------------------------------------+
// Express
app.use((req, res, next) => {
  res.setHeader('X-Frame-Options', 'DENY');
  next();
});

// Or with helmet
const helmet = require('helmet');
app.use(helmet.frameguard({ action: 'deny' }));

Modern alternative: Use CSP frame-ancestors directive, which is more flexible:

Content-Security-Policy: frame-ancestors 'none';           /* Same as DENY */
Content-Security-Policy: frame-ancestors 'self';           /* Same as SAMEORIGIN */
Content-Security-Policy: frame-ancestors https://parent.com; /* Specific origin */

Use both headers for maximum compatibility (older browsers support X-Frame-Options but not CSP frame-ancestors).

X-Content-Type-Options (MIME Sniffing Prevention)

Browsers sometimes "sniff" the content type of a response, ignoring the Content-Type header. This can lead to security issues — an attacker uploads a file that looks like HTML/JavaScript, and the browser executes it despite the server labeling it as text/plain.

 Without nosniff:
   Server returns: Content-Type: text/plain
   Body contains: <script>alert('xss')</script>
   Browser sniffs: "This looks like HTML" --> Executes the script

 With nosniff:
   Server returns: Content-Type: text/plain
   X-Content-Type-Options: nosniff
   Browser: "Server said text/plain, I will treat it as text/plain"

This header has exactly one valid value:

X-Content-Type-Options: nosniff
// Express
app.use((req, res, next) => {
  res.setHeader('X-Content-Type-Options', 'nosniff');
  next();
});

// With helmet (enabled by default)
app.use(helmet());

Always set the correct Content-Type on your responses. This header is a safety net, not a replacement for proper content typing.

Strict-Transport-Security (HSTS)

HSTS forces browsers to use HTTPS for your domain. Once set, the browser will never make an HTTP request to your domain — it converts all HTTP URLs to HTTPS automatically.

Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
+---------------------+----------------------------------------------+
| Directive           | Purpose                                      |
+---------------------+----------------------------------------------+
| max-age             | Duration (seconds) to remember HTTPS-only    |
|                     | 63072000 = 2 years                           |
| includeSubDomains   | Apply to all subdomains                      |
| preload             | Eligible for browser preload list            |
+---------------------+----------------------------------------------+
// Express
app.use((req, res, next) => {
  res.setHeader(
    'Strict-Transport-Security',
    'max-age=63072000; includeSubDomains; preload'
  );
  next();
});

// With helmet
app.use(helmet.hsts({
  maxAge: 63072000,
  includeSubDomains: true,
  preload: true,
}));

Important considerations:

  • Only set HSTS after confirming HTTPS works on ALL pages and subdomains
  • Start with a short max-age (e.g., 300 = 5 minutes) and increase gradually
  • includeSubDomains means ALL subdomains must support HTTPS
  • preload is practically permanent — your domain is hardcoded into browsers
// Gradual HSTS rollout
// Week 1: Test with 5 minutes
'Strict-Transport-Security: max-age=300'

// Week 2: Increase to 1 day
'Strict-Transport-Security: max-age=86400'

// Week 3: Increase to 1 week
'Strict-Transport-Security: max-age=604800'

// Week 4: Add includeSubDomains
'Strict-Transport-Security: max-age=604800; includeSubDomains'

// Month 2: Full production value
'Strict-Transport-Security: max-age=63072000; includeSubDomains; preload'

Referrer-Policy

The Referer header (yes, the misspelling is intentional — it is in the HTTP spec) tells the destination site where the user came from. This can leak sensitive URLs (with tokens, query parameters, or private paths).

 User on: https://app.com/dashboard?token=secret123
 Clicks link to: https://external.com

 Without Referrer-Policy:
   Referer: https://app.com/dashboard?token=secret123
   (External site sees the token!)

 With strict-origin-when-cross-origin:
   Referer: https://app.com
   (Only the origin, no path or query)

Referrer-Policy Values

+------------------------------------------+----------------------------------------------+
| Policy                                   | What Is Sent                                 |
+------------------------------------------+----------------------------------------------+
| no-referrer                              | Nothing (safest, may break analytics)        |
| no-referrer-when-downgrade               | Full URL for HTTPS->HTTPS, nothing for       |
|                                          | HTTPS->HTTP                                  |
| origin                                   | Only origin (https://app.com)                |
| origin-when-cross-origin                 | Full URL for same-origin, origin for cross   |
| same-origin                              | Full URL for same-origin, nothing for cross  |
| strict-origin                            | Origin for HTTPS->HTTPS, nothing for downgrade|
| strict-origin-when-cross-origin          | Full URL same-origin, origin cross-origin,   |
|                                          | nothing for downgrade (RECOMMENDED)          |
| unsafe-url                               | Full URL always (never use)                  |
+------------------------------------------+----------------------------------------------+
// Express — recommended policy
app.use((req, res, next) => {
  res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
  next();
});

// With helmet
app.use(helmet.referrerPolicy({
  policy: 'strict-origin-when-cross-origin',
}));

You can also set it per-element in HTML:

<!-- Override for specific links -->
<a href="https://external.com" referrerpolicy="no-referrer">External Link</a>

<!-- Override for specific images -->
<img src="https://analytics.com/pixel.gif" referrerpolicy="no-referrer" />

Permissions-Policy (formerly Feature-Policy)

Permissions-Policy controls which browser features and APIs your page can use. It can disable features entirely or restrict them to specific origins.

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

This disables camera, microphone, geolocation, and payment APIs on your page. Even if XSS occurs, the attacker cannot use these APIs.

Common Directives

+---------------------+----------------------------------------------+
| Feature             | Controls                                     |
+---------------------+----------------------------------------------+
| camera              | Camera access (getUserMedia video)           |
| microphone          | Microphone access (getUserMedia audio)       |
| geolocation         | Location access                              |
| payment             | Payment Request API                          |
| usb                 | WebUSB API                                   |
| accelerometer       | Accelerometer sensor                         |
| gyroscope           | Gyroscope sensor                             |
| magnetometer        | Magnetometer sensor                          |
| fullscreen          | Fullscreen API                               |
| autoplay            | Media autoplay                               |
| picture-in-picture  | Picture-in-Picture API                       |
| display-capture     | Screen capture (getDisplayMedia)             |
+---------------------+----------------------------------------------+

Permissions-Policy Values

+---------------------+----------------------------------------------+
| Value               | Meaning                                      |
+---------------------+----------------------------------------------+
| ()                  | Disabled for all contexts                    |
| (self)              | Allowed for this origin only                 |
| (self "https://x")  | Allowed for this origin and x                |
| *                   | Allowed for all origins                      |
+---------------------+----------------------------------------------+
// Express
app.use((req, res, next) => {
  res.setHeader('Permissions-Policy', [
    'camera=()',
    'microphone=()',
    'geolocation=()',
    'payment=()',
    'usb=()',
    'accelerometer=()',
    'gyroscope=()',
    'magnetometer=()',
    'display-capture=()',
  ].join(', '));
  next();
});

If your app needs some of these features, allow only for your origin:

res.setHeader('Permissions-Policy', [
  'camera=(self)',           // Only your origin can use camera
  'microphone=(self)',       // Only your origin can use microphone
  'geolocation=()',          // Nobody can use geolocation
  'payment=(self "https://pay.example.com")',  // Your origin + payment provider
].join(', '));

Implementing All Headers Together

Express with Helmet

const express = require('express');
const helmet = require('helmet');

const app = express();

// Helmet sets many security headers by default
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", 'data:', 'https:'],
      fontSrc: ["'self'", 'https://fonts.gstatic.com'],
      connectSrc: ["'self'", 'https://api.example.com'],
      frameSrc: ["'none'"],
      objectSrc: ["'none'"],
      baseUri: ["'self'"],
      formAction: ["'self'"],
      frameAncestors: ["'none'"],
      upgradeInsecureRequests: [],
    },
  },
  crossOriginEmbedderPolicy: true,
  crossOriginOpenerPolicy: true,
  crossOriginResourcePolicy: { policy: 'same-origin' },
  hsts: {
    maxAge: 63072000,
    includeSubDomains: true,
    preload: true,
  },
  referrerPolicy: {
    policy: 'strict-origin-when-cross-origin',
  },
}));

// Permissions-Policy (helmet doesn't set this by default)
app.use((req, res, next) => {
  res.setHeader('Permissions-Policy', [
    'camera=()',
    'microphone=()',
    'geolocation=()',
    'payment=()',
  ].join(', '));
  next();
});

Next.js Configuration

// next.config.js
const securityHeaders = [
  {
    key: 'Content-Security-Policy',
    value: [
      "default-src 'self'",
      "script-src 'self' 'unsafe-eval' 'unsafe-inline'",
      "style-src 'self' 'unsafe-inline'",
      "img-src 'self' data: https:",
      "font-src 'self'",
      "connect-src 'self'",
      "frame-src 'none'",
      "frame-ancestors 'none'",
      "object-src 'none'",
      "base-uri 'self'",
      "form-action 'self'",
      "upgrade-insecure-requests",
    ].join('; '),
  },
  {
    key: 'X-Frame-Options',
    value: 'DENY',
  },
  {
    key: 'X-Content-Type-Options',
    value: 'nosniff',
  },
  {
    key: 'Strict-Transport-Security',
    value: 'max-age=63072000; includeSubDomains; preload',
  },
  {
    key: 'Referrer-Policy',
    value: 'strict-origin-when-cross-origin',
  },
  {
    key: 'Permissions-Policy',
    value: 'camera=(), microphone=(), geolocation=(), payment=()',
  },
  {
    key: 'X-DNS-Prefetch-Control',
    value: 'on',
  },
];

module.exports = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: securityHeaders,
      },
    ];
  },
  // Also remove X-Powered-By
  poweredByHeader: false,
};

Nginx Configuration

server {
    listen 443 ssl http2;
    server_name example.com;

    # TLS
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # Security Headers
    add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-src 'none'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests;" always;

    add_header X-Frame-Options "DENY" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;

    # Hide server version
    server_tokens off;

    location / {
        proxy_pass http://localhost:3000;
        proxy_hide_header X-Powered-By;
    }
}

Testing Security Headers

Using curl

# Check all response headers
curl -I https://example.com

# Check specific header
curl -s -D- https://example.com | grep -i "content-security-policy"
curl -s -D- https://example.com | grep -i "strict-transport-security"
curl -s -D- https://example.com | grep -i "x-frame-options"

Programmatic Header Check

async function auditSecurityHeaders(url) {
  const response = await fetch(url, { method: 'HEAD' });
  const headers = response.headers;

  const checks = [
    {
      name: 'Content-Security-Policy',
      header: headers.get('content-security-policy'),
      required: true,
    },
    {
      name: 'X-Frame-Options',
      header: headers.get('x-frame-options'),
      required: true,
      expected: ['DENY', 'SAMEORIGIN'],
    },
    {
      name: 'X-Content-Type-Options',
      header: headers.get('x-content-type-options'),
      required: true,
      expected: ['nosniff'],
    },
    {
      name: 'Strict-Transport-Security',
      header: headers.get('strict-transport-security'),
      required: true,
    },
    {
      name: 'Referrer-Policy',
      header: headers.get('referrer-policy'),
      required: true,
    },
    {
      name: 'Permissions-Policy',
      header: headers.get('permissions-policy'),
      required: true,
    },
    {
      name: 'X-Powered-By',
      header: headers.get('x-powered-by'),
      required: false,
      shouldBeAbsent: true,
    },
    {
      name: 'Server',
      header: headers.get('server'),
      required: false,
      warn: 'Consider hiding server version',
    },
  ];

  const results = checks.map(check => {
    if (check.shouldBeAbsent) {
      return {
        name: check.name,
        status: check.header ? 'FAIL' : 'PASS',
        detail: check.header ? `Present: ${check.header} (should be removed)` : 'Absent (good)',
      };
    }

    if (check.required && !check.header) {
      return {
        name: check.name,
        status: 'FAIL',
        detail: 'Missing',
      };
    }

    if (check.expected && check.header) {
      const valid = check.expected.includes(check.header);
      return {
        name: check.name,
        status: valid ? 'PASS' : 'WARN',
        detail: check.header,
      };
    }

    return {
      name: check.name,
      status: check.header ? 'PASS' : 'SKIP',
      detail: check.header || check.warn || 'Not set',
    };
  });

  return results;
}

// Usage
auditSecurityHeaders('https://example.com').then(results => {
  results.forEach(r => {
    const icon = r.status === 'PASS' ? '[OK]' : r.status === 'FAIL' ? '[!!]' : '[??]';
    console.log(`${icon} ${r.name}: ${r.detail}`);
  });
});

Integration Test

// tests/security-headers.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Security Headers', () => {
  test('all required security headers are present', async ({ request }) => {
    const response = await request.get('/');

    // CSP
    expect(response.headers()['content-security-policy']).toBeDefined();
    expect(response.headers()['content-security-policy']).toContain("default-src");

    // Clickjacking
    expect(response.headers()['x-frame-options']).toBe('DENY');

    // MIME sniffing
    expect(response.headers()['x-content-type-options']).toBe('nosniff');

    // HSTS
    const hsts = response.headers()['strict-transport-security'];
    expect(hsts).toBeDefined();
    expect(hsts).toContain('max-age=');

    // Referrer
    expect(response.headers()['referrer-policy']).toBeDefined();

    // Permissions
    expect(response.headers()['permissions-policy']).toBeDefined();

    // No server info leak
    expect(response.headers()['x-powered-by']).toBeUndefined();
  });

  test('CSP blocks inline scripts', async ({ page }) => {
    let cspViolation = false;
    page.on('console', msg => {
      if (msg.text().includes('CSP')) cspViolation = true;
    });

    await page.goto('/');

    // Try to inject an inline script
    await page.evaluate(() => {
      const script = document.createElement('script');
      script.textContent = 'window.__xss = true';
      document.body.appendChild(script);
    });

    // If CSP is working, the script should not execute
    const xssResult = await page.evaluate(() => (window as any).__xss);
    expect(xssResult).toBeUndefined();
  });
});

Security Headers Quick Reference

+-----------------------------+-----------------------------------------+----------+
| Header                      | Recommended Value                       | Priority |
+-----------------------------+-----------------------------------------+----------+
| Content-Security-Policy     | default-src 'self'; script-src 'self';  | Critical |
|                             | object-src 'none'; base-uri 'self'      |          |
+-----------------------------+-----------------------------------------+----------+
| Strict-Transport-Security   | max-age=63072000; includeSubDomains;    | Critical |
|                             | preload                                 |          |
+-----------------------------+-----------------------------------------+----------+
| X-Content-Type-Options      | nosniff                                 | Critical |
+-----------------------------+-----------------------------------------+----------+
| X-Frame-Options             | DENY                                    | High     |
+-----------------------------+-----------------------------------------+----------+
| Referrer-Policy             | strict-origin-when-cross-origin         | High     |
+-----------------------------+-----------------------------------------+----------+
| Permissions-Policy          | camera=(), microphone=(),               | Medium   |
|                             | geolocation=()                          |          |
+-----------------------------+-----------------------------------------+----------+
| X-Powered-By                | (remove this header)                    | Medium   |
+-----------------------------+-----------------------------------------+----------+
| Cross-Origin-Opener-Policy  | same-origin                             | Medium   |
+-----------------------------+-----------------------------------------+----------+
| Cross-Origin-Embedder-      | require-corp                            | Medium   |
|   Policy                    |                                         |          |
+-----------------------------+-----------------------------------------+----------+
| Cross-Origin-Resource-      | same-origin                             | Medium   |
|   Policy                    |                                         |          |
+-----------------------------+-----------------------------------------+----------+

Security Headers Checklist

+------+----------------------------------------------+----------+
| #    | Check                                        | Status   |
+------+----------------------------------------------+----------+
| 1    | CSP deployed (at minimum: default-src 'self') | [ ]     |
| 2    | CSP report-uri configured for monitoring      | [ ]      |
| 3    | X-Frame-Options: DENY (or SAMEORIGIN)         | [ ]      |
| 4    | CSP frame-ancestors set (modern replacement)   | [ ]      |
| 5    | X-Content-Type-Options: nosniff               | [ ]      |
| 6    | HSTS enabled with long max-age                | [ ]      |
| 7    | HSTS includeSubDomains set                    | [ ]      |
| 8    | Referrer-Policy set                           | [ ]      |
| 9    | Permissions-Policy disables unused APIs        | [ ]      |
| 10   | X-Powered-By header removed                   | [ ]      |
| 11   | Server header minimized or removed            | [ ]      |
| 12   | Headers tested with securityheaders.com       | [ ]      |
| 13   | Headers tested with automated tests           | [ ]      |
| 14   | Headers applied to all responses (not just HTML)| [ ]     |
+------+----------------------------------------------+----------+

Summary

Security headers are low-effort, high-impact defenses that instruct browsers to protect your users. Every web application should implement them.

Priority order:

  1. Content-Security-Policy — Strongest defense against XSS. Start with report-only, then enforce.
  2. Strict-Transport-Security — Prevents HTTPS downgrade attacks. Non-negotiable.
  3. X-Content-Type-Options — Prevents MIME sniffing. One line, zero downside.
  4. X-Frame-Options / frame-ancestors — Prevents clickjacking. Use both for compatibility.
  5. Referrer-Policy — Prevents URL leaks to third parties.
  6. Permissions-Policy — Disables browser APIs you do not use.

Implementation strategy:

  • Start with report-only CSP, deploy other headers immediately
  • Analyze CSP reports, refine policy
  • Enforce CSP when ready
  • Test headers in CI pipeline
  • Monitor for violations continuously

Security headers are not a replacement for secure code — they are an additional layer. Combine them with input validation, output encoding, authentication, and all other security practices in this series.

Found this helpful?

Support devsofus — help us keep creating free dev guides.

Related Articles