
Dec 23, 2025
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.
Before diving into the configuration, let's understand the threats these headers defend against:
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.
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).
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.
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.
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.
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.
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
}
"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 visitReal-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.
"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.
"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 domainALLOW-FROM https://trusted-site.com: Allow iframes only from a specific trusted domainReal-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.
"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."
"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:
example.com/page1)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 domainstrict-origin-when-cross-origin: What we recommend (good balance of privacy and functionality)"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 microphonecamera=(): No website can access your cameraReal-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 locationcamera=(self): Allow only your own domain to access cameramicrophone=(*): Allow any domain (usually not recommended)Caddy stores its configuration in a file called Caddyfile. Depending on how you installed Caddy, this file is usually located at:
/etc/caddy/Caddyfile/etc/caddy/CaddyfileOpen 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
}
Before reloading, verify your syntax is correct:
caddy validate
caddy reload
This reloads the configuration without stopping your web server (zero downtime).
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.
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:
# ❌ 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
}
# ❌ WRONG - Exposing Node.js app directly
# No reverse proxy, app handles HTTP directly
Why this is dangerous:
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:
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:
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)
After applying the headers, verify they're actually being sent:
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=()
Visit these websites and enter your domain:
These tools show you exactly which headers you're missing and which ones need improvement.
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 defaultscript-src 'self' https://cdn.example.com: Allow scripts only from your domain and a trusted CDNstyle-src 'self' 'unsafe-inline': Allow styles from your domain and inline stylesimg-src 'self' data: https:: Allow images from your domain, data URLs, and HTTPS sourcesThis prevents any JavaScript from external sources from running on your page unless you explicitly allow it.
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
}
Security headers are critical: They provide an essential layer of protection against common web attacks.
Caddy makes it simple: Unlike other servers, Caddy makes adding security headers straightforward and centralized.
Use reusable snippets: Don't repeat yourself. Define security headers once and import them everywhere.
Always proxy through Caddy: Never expose Node.js directly to the internet. Always use a reverse proxy like Caddy.
Use a process manager: Keep your Node.js app running reliably with PM2.
Test your headers: Use securityheaders.com or Mozilla Observatory to verify everything is working.
This is production-ready: The configuration in this guide is suitable for production websites serving real users.

25 Dec 2025
Top 5 Animated UI Component Libraries for Frontend Developers

24 Dec 2025
Why Most Modern Apps use Kafka

24 Dec 2025
Top 10 AI Tools Every Developer Should Know