logo
How to Configure Caddy Security Headers to Protect Your Web Application

How to Configure Caddy Security Headers to Protect Your Web Application

Dec 23, 2025

Introduction: Why Security Headers Matter

When you build a web application and deploy it on the internet, you're exposed to various attacks. Hackers use different techniques to steal data, hijack sessions, or redirect users to malicious sites. While firewalls and HTTPS provide basic protection, security headers are an additional layer of defense that run at the browser level.

Think of security headers as instructions you send to every visitor's browser, telling it: "Don't trust scripts from unknown sources," "Don't display this page inside someone else's website," and "Always talk to me securely." These are simple text messages included in every HTTP response, but they're incredibly powerful.

Caddy, a modern web server, makes adding these headers effortless. Unlike older servers like Apache or Nginx where you need to manually write complex configurations, Caddy provides a clean, simple syntax for implementing security headers consistently across your entire application.


Common Web Vulnerabilities Security Headers Protect Against

Before diving into the configuration, let's understand the threats these headers defend against:

1. Cross-Site Scripting (XSS)

An attacker injects malicious JavaScript code into your web page. When users visit, the script runs in their browsers, stealing login cookies or sensitive information.

Example: A comment form that doesn't validate input allows someone to post <script>alert('hacked')</script>, which then runs for everyone who views that comment.

2. Clickjacking

An attacker embeds your website inside a hidden frame on their own malicious site. Users think they're clicking a button on your site, but they're actually clicking something invisible on the attacker's page.

Example: You see "Claim Your Prize!" on a site, but clicking it actually approves a bank transfer on your real bank's website (which is hidden underneath).

3. MIME Type Sniffing

Older browsers would guess the file type based on content, not just the filename. An attacker could upload a malicious script with a .jpg extension, and the browser might execute it as JavaScript anyway.

Example: Upload a file named photo.jpg containing JavaScript code. Some browsers might run it as a script instead of displaying it as an image.

4. Man-in-the-Middle (MITM) Attacks

An attacker intercepts your connection and reads or modifies data in transit. This happens when someone connects to unsecured HTTP instead of HTTPS.

Example: Using public WiFi at a café, an attacker intercepts your login credentials when you visit an HTTP website.

5. Information Leakage via Referrer

When you click a link to another site, that site can see which page you came from (the referrer). This can leak sensitive information.

Example: If you click a link from a private healthcare website to a search engine, that search engine knows you visited a healthcare site.

6. Unauthorized Browser Feature Access

Websites might request access to your camera, microphone, or location without your explicit consent.

Example: A malicious website silently accesses your webcam to spy on you.


Understanding the Security Headers Configuration

Here's the complete Caddy configuration with detailed explanations:

example.com {
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
        X-XSS-Protection "0"
        Referrer-Policy "strict-origin-when-cross-origin"
        Permissions-Policy "geolocation=(), microphone=(), camera=()"
    }

    reverse_proxy localhost:3000
}

What Each Header Does

1. Strict-Transport-Security (HSTS)

"max-age=31536000; includeSubDomains; preload"

What it does: Forces all communication with your website to use HTTPS (secure connection), never HTTP (unsecure).

Why it matters: Prevents attackers from intercepting data by forcing users to upgrade to HTTPS automatically, even if they accidentally type http:// instead of https://.

Breaking down the values:

  • max-age=31536000: Remember this rule for 365 days (31,536,000 seconds)
  • includeSubDomains: Apply this rule to all subdomains (api.example.com, blog.example.com, etc.)
  • preload: Add your site to browsers' HSTS preload list, so this rule is built into the browser before users even visit

Real-world example: Without this header, if someone types http://example.com, they might land on an unsecured connection momentarily, giving attackers a window. With HSTS, the browser says "I know example.com requires HTTPS" and automatically upgrades the connection.


2. X-Content-Type-Options: nosniff

"nosniff"

What it does: Tells the browser "Trust the file type I tell you, don't guess."

Why it matters: Prevents browsers from misinterpreting files. If you serve a file as an image, it won't be executed as a script.

Technical detail: Older browsers had "MIME sniffing" where they'd inspect file content to guess the actual type, overriding what the server declared. This header disables that behavior.

Real-world example: Without this header, an attacker could upload a malicious JavaScript file named vacation.jpg. Some browsers might execute it as a script anyway. With this header, the browser trusts your declaration that it's an image.


3. X-Frame-Options: DENY

"DENY"

What it does: Prevents your website from being displayed inside an iframe (a frame within another website).

Why it matters: Protects against clickjacking attacks where attackers hide your site in a frame and trick users into clicking things unknowingly.

Alternative values you might see:

  • DENY: Never allow this site in an iframe (most secure)
  • SAMEORIGIN: Allow iframes only from your own domain
  • ALLOW-FROM https://trusted-site.com: Allow iframes only from a specific trusted domain

Real-world example: Without this header, an attacker could create a page with your banking site loaded in a hidden iframe, then overlay fake buttons. Users think they're clicking your site but are actually interacting with the attacker's page. With DENY, this becomes impossible.


4. X-XSS-Protection: 0

"0"

What it does: Disables the browser's built-in XSS protection feature.

Why it's set to 0 (counterintuitive): Modern browsers (Chrome, Firefox, Safari, Edge) have moved away from this header because their built-in XSS protection can be bypassed and actually causes more problems than it solves. Setting it to "0" explicitly disables this feature and tells browsers to rely on other protections instead (like Content-Security-Policy, which we'll cover later).

Modern alternative: Instead of relying on X-XSS-Protection, modern sites use the Content-Security-Policy (CSP) header, which provides stronger XSS protection:

Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"

This tells the browser: "Only load scripts from my own domain ('self'), nothing else."


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

"strict-origin-when-cross-origin"

What it does: Controls how much information your site shares about where users came from when they navigate to other websites.

Why it matters: Prevents leaking sensitive information through the referrer URL. For example, if your referrer URL contains a user ID or session token, other sites could see it.

How it works:

When you click a link, your browser sends a "Referrer" header to the destination site, saying "I came from example.com/private-health-records." This can leak sensitive information.

The strict-origin-when-cross-origin value means:

  • Within your own domain: Send full referrer URL (e.g., example.com/page1)
  • To other domains: Send only the domain name, not the full path (e.g., just example.com, not example.com/private-data)

Real-world example: Without this header, if you visit example.com/medical-records/patient-123 and click a link to Google, Google can see that full URL in the referrer. With this header, Google only sees example.com, not the sensitive path.

Other options:

  • no-referrer: Don't send any referrer information (most private, but breaks some analytics)
  • same-origin: Only send referrer within your own domain
  • strict-origin-when-cross-origin: What we recommend (good balance of privacy and functionality)

6. Permissions-Policy (formerly Feature-Policy)

"geolocation=(), microphone=(), camera=()"

What it does: Restricts which browser features websites can access.

Why it matters: Prevents malicious websites (or compromised third-party scripts) from silently accessing your camera, microphone, location, or other sensitive hardware.

How to read this:

  • geolocation=(): No website can access your location (empty parentheses mean nobody)
  • microphone=(): No website can access your microphone
  • camera=(): No website can access your camera

Real-world example: Even if a website's JavaScript is compromised, it can't request access to your camera without your permission. And if a third-party ad script tries to be sneaky, this policy blocks it entirely.

More granular options:

If you want to allow specific features:

Permissions-Policy "geolocation=(self), camera=(self), microphone=(*)"
  • geolocation=(self): Allow only your own domain to access location
  • camera=(self): Allow only your own domain to access camera
  • microphone=(*): Allow any domain (usually not recommended)

How to Apply This Configuration

Step 1: Access Your Caddy Configuration File

Caddy stores its configuration in a file called Caddyfile. Depending on how you installed Caddy, this file is usually located at:

  • Linux: /etc/caddy/Caddyfile
  • Docker: Inside the container at /etc/caddy/Caddyfile
  • Manual installation: Wherever you specified it to be

Step 2: Add the Security Headers Block

Open your Caddyfile and find your domain configuration. If you don't have one yet, create it:

example.com {
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
        X-XSS-Protection "0"
        Referrer-Policy "strict-origin-when-cross-origin"
        Permissions-Policy "geolocation=(), microphone=(), camera=()"
    }

    reverse_proxy localhost:3000
}

Step 3: Test the Configuration

Before reloading, verify your syntax is correct:

caddy validate

Step 4: Reload Caddy

caddy reload

This reloads the configuration without stopping your web server (zero downtime).


Applying Headers Across Multiple Sites (DRY Principle)

If you're hosting multiple websites or have many subdomains, you don't want to repeat the same security headers for each one. Caddy provides a way to define reusable configuration blocks called snippets.

Using Snippets for Code Reuse

Create a snippet at the top of your Caddyfile:

(security_headers) {
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
        X-XSS-Protection "0"
        Referrer-Policy "strict-origin-when-cross-origin"
        Permissions-Policy "geolocation=(), microphone=(), camera=()"
    }
}

example.com {
    import security_headers
    reverse_proxy localhost:3000
}

api.example.com {
    import security_headers
    reverse_proxy localhost:3001
}

blog.example.com {
    import security_headers
    reverse_proxy localhost:3002
}

Benefits:

  • Write the security headers once
  • Import them everywhere you need them
  • Update once, and all sites get the new headers
  • Reduces mistakes from copy-pasting

Common Mistakes to Avoid

Mistake 1: Using an IP Address Instead of a Domain

# ❌ WRONG
192.168.1.100 {
    header {
        Strict-Transport-Security "max-age=31536000"
    }
}

Why this fails: TLS certificates (HTTPS) require a valid domain name, not an IP address. You can't get an SSL certificate for 192.168.1.100.

Solution: Always use a domain name. If you're testing locally, use a domain like localhost or set up a local domain in your /etc/hosts file:

# Add to /etc/hosts
127.0.0.1 myapp.local

Then in Caddyfile:

myapp.local {
    header {
        Strict-Transport-Security "max-age=31536000"
    }
    reverse_proxy localhost:3000
}

Mistake 2: Running Node.js Directly to the Internet

# ❌ WRONG - Exposing Node.js app directly
# No reverse proxy, app handles HTTP directly

Why this is dangerous:

  • Your Node.js app handles raw HTTP requests (slower)
  • No centralized place to add headers
  • Security headers aren't applied
  • Performance suffers
  • If Node.js crashes, your site is down immediately
  • Each Node.js instance needs its own port, making scaling complicated

Correct approach: Always put Caddy in front:

# ✅ CORRECT
example.com {
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
    }
    
    reverse_proxy localhost:3000
}

Benefits of this setup:

  • Caddy handles HTTPS and certificates
  • Caddy applies headers consistently
  • Node.js focuses on business logic
  • Easy to restart Node.js without downtime
  • Load balance across multiple Node.js instances

Mistake 3: Running Node.js Without a Process Manager

Problem: If your Node.js app crashes, your entire website goes down.

# ❌ WRONG - No process manager
node app.js

Solution: Use PM2 (Process Manager 2) to keep your Node.js app running:

# Install PM2 globally
npm install -g pm2

# Start your app with PM2
pm2 start app.js --name "myapp"

# Make PM2 start on system reboot
pm2 startup
pm2 save

# Monitor your app
pm2 status

What PM2 does:

  • Automatically restarts your app if it crashes
  • Manages multiple instances of your app for load balancing
  • Provides monitoring and logs
  • Starts your app automatically when the server reboots

Complete architecture:

Internet
   ↓
[Caddy - Port 443/80]
   ↓ (reverse proxy)
[PM2 managing Node.js instances]
   ├── Instance 1 (localhost:3000)
   ├── Instance 2 (localhost:3001)
   └── Instance 3 (localhost:3002)

Testing Your Security Headers

After applying the headers, verify they're actually being sent:

Using curl (Command Line)

curl -I https://example.com

Output should show your headers:

HTTP/2 200
...
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 0
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=()

Using Online Tools

Visit these websites and enter your domain:

These tools show you exactly which headers you're missing and which ones need improvement.


Advanced: Adding Content-Security-Policy (CSP)

For stronger XSS protection, add a Content-Security-Policy header:

example.com {
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
        X-XSS-Protection "0"
        Referrer-Policy "strict-origin-when-cross-origin"
        Permissions-Policy "geolocation=(), microphone=(), camera=()"
        Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:"
    }

    reverse_proxy localhost:3000
}

What CSP does:

  • default-src 'self': Load all resources from your own domain by default
  • script-src 'self' https://cdn.example.com: Allow scripts only from your domain and a trusted CDN
  • style-src 'self' 'unsafe-inline': Allow styles from your domain and inline styles
  • img-src 'self' data: https:: Allow images from your domain, data URLs, and HTTPS sources

This prevents any JavaScript from external sources from running on your page unless you explicitly allow it.


Complete Production-Ready Caddyfile Example

Here's a complete example with best practices:

# Define reusable security headers
(security_headers) {
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
        X-XSS-Protection "0"
        Referrer-Policy "strict-origin-when-cross-origin"
        Permissions-Policy "geolocation=(), microphone=(), camera=()"
        Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:"
    }
}

# Main application
example.com {
    import security_headers
    
    # Enable GZIP compression
    encode gzip
    
    # Reverse proxy to Node.js app
    reverse_proxy localhost:3000
}

# API subdomain
api.example.com {
    import security_headers
    
    reverse_proxy localhost:3001
}

# Blog subdomain
blog.example.com {
    import security_headers
    
    reverse_proxy localhost:3002
}

Key Takeaways

  1. Security headers are critical: They provide an essential layer of protection against common web attacks.

  2. Caddy makes it simple: Unlike other servers, Caddy makes adding security headers straightforward and centralized.

  3. Use reusable snippets: Don't repeat yourself. Define security headers once and import them everywhere.

  4. Always proxy through Caddy: Never expose Node.js directly to the internet. Always use a reverse proxy like Caddy.

  5. Use a process manager: Keep your Node.js app running reliably with PM2.

  6. Test your headers: Use securityheaders.com or Mozilla Observatory to verify everything is working.

  7. This is production-ready: The configuration in this guide is suitable for production websites serving real users.


Further Reading