Apache Security Headers

Chapter 6 — Apache Security Headers

Every HTTP response your server sends contains headers — metadata that tells the browser how to handle the response. Most of these headers are functional (Content-Type, Content-Length, Cache-Control). Security headers are a distinct category: they tell the browser what the server permits, so the browser can refuse anything outside those boundaries. They cost nothing to add and protect against a wide range of real attacks — from clickjacking to cross-site scripting.

What this chapter covers: How security headers work and why they matter. Hiding server version information (ServerTokens, ServerSignature, X-Powered-By). X-Frame-Options against clickjacking. X-Content-Type-Options against MIME sniffing. Referrer-Policy for URL privacy. Permissions-Policy to lock down browser APIs. Content Security Policy — the most powerful header, with a safe roll-out approach using Report-Only mode. Assembling all headers into the VirtualHost config. Testing with curl and online tools. How Cloudflare interacts with these headers. Common mistakes (duplicate headers, CSP breakage).

How Security Headers Work

Without security headers — the browser makes its own decisions: Server ──▶ 200 OK ← response Content-Type: text/html (no further instructions) Browser: can I run this script? → sure, why not Browser: can I show this in an iframe? → sure Browser: should I follow this redirect to http://? → yes With security headers — the server sets the rules: Server ──▶ 200 OK Content-Type: text/html X-Frame-Options: DENY ← browser refuses to render in iframes X-Content-Type-Options: nosniff ← browser trusts the declared Content-Type Content-Security-Policy: default-src 'self' ← only scripts from same origin run Referrer-Policy: no-referrer ← no URL leakage on navigation Browser: request to iframe this page → blocked (X-Frame-Options) Browser: script from cdn.evil.com → blocked (CSP) Browser: MIME-sniff this upload → blocked (nosniff)

Security headers are client-side enforcement enforced by the browser — they don't stop a determined attacker from making raw HTTP requests, but they protect your actual users' browsers from being used against them. They're most effective against injected content, data leakage, and social-engineering attacks.

mod_headers must be enabled. If you followed Chapter 2 (HSTS), mod_headers is already active. Confirm: apache2ctl -M | grep headers — you should see headers_module. If not: sudo a2enmod headers && sudo systemctl restart apache2.

Hiding Server Information

By default, Apache tells the world exactly what version it's running. That version information is directly useful to attackers — they can look up which CVEs apply to that exact version and craft targeted exploits.

Default — information leakage
Server: Apache/2.4.57 (Ubuntu) X-Powered-By: PHP/8.2.10 ← exact version + OS + PHP version ← attacker searches for CVEs targeting Apache 2.4.57 specifically
Hardened — minimal disclosure
Server: Apache ← generic name, no version ← X-Powered-By removed entirely ← attacker knows it's Apache (unavoidable) but not which version
# /etc/apache2/conf-available/security.conf (or directly in apache2.conf) # These are server-level settings — not per-VirtualHost # Edit the existing security.conf (already present on Ubuntu): $ sudo nano /etc/apache2/conf-available/security.conf # Find and set these two lines: ServerTokens Prod ServerSignature Off # ServerTokens Prod — "Server: Apache" only, no version or OS # ServerTokens Full — the default: "Apache/2.4.57 (Ubuntu)" — too much info # ServerSignature Off — no server version appended to error pages (404, 500 etc.) # Enable the security.conf if not already enabled: $ sudo a2enconf security $ sudo systemctl reload apache2
# Remove the X-Powered-By header (PHP adds this automatically) # Add to your VirtualHost config or a global conf file: Header always unset X-Powered-By Header always unset X-AspNet-Version # "unset" removes the header if it exists — safe even if PHP isn't installed

The Security Headers — One by One

X-Frame-Options: SAMEORIGIN
Protects against: Clickjacking — an attacker embeds your site in a transparent iframe over their own page, then tricks your logged-in users into clicking their buttons (submitting your forms, deleting accounts, etc.).

DENY — never allow framing (recommended if you don't embed your own site in iframes).
SAMEORIGIN — only allow framing from the same domain (useful if you use iframes internally).

Being superseded by CSP's frame-ancestors directive, but still valuable for older browser compatibility.
X-Content-Type-Options: nosniff
Protects against: MIME-type confusion attacks. Without this, a browser may "sniff" the content of a response and decide it looks like JavaScript, even if the server declared it as text/plain.

Attack scenario: A user uploads an image file that contains JavaScript code. The server stores it and serves it as image/jpeg. Without nosniff, some browsers execute the JS. With nosniff, the browser trusts the declared Content-Type and treats it as an image.

This is a single fixed value — there are no other valid options for this header.
Referrer-Policy: strict-origin-when-cross-origin
Protects against: URL leakage. When a user clicks a link from your site to another, the browser sends a Referer header containing the full URL of the page they came from — including any query parameters, tokens, or user IDs in the URL.

strict-origin-when-cross-origin — sends the full URL only to same-origin destinations; sends just the origin (domain name, no path) to cross-origin HTTPS destinations; sends nothing to HTTP destinations. A sensible default for most sites.

Alternatives: no-referrer (nothing sent, most private), same-origin (nothing sent to other domains).
Permissions-Policy: camera=(), microphone=(), geolocation=()
Protects against: Injected content requesting browser permissions. If an XSS attack injects a script that requests camera access, this header tells the browser the site doesn't have permission to do that — the request is denied before the user even sees a prompt.

camera=() — deny camera access entirely (empty parentheses = no allowed origins).
microphone=() — deny microphone access.
geolocation=() — deny geolocation access.

For a web hosting/tutorial site like osztromok.com, none of these features are needed — deny them all. Add only what your site actually requires.
Strict-Transport-Security: max-age=86400
Covered in detail in Chapter 2. Brief reminder: this header tells browsers to always use HTTPS for your domain, even if the user types http://. Don't add it again if you already set it in the VirtualHost — duplicate headers cause both values to be sent.

How Clickjacking Works

Attack Explained — Clickjacking
An attacker creates evil.com/prize.html — a page that appears to offer a free prize with a large "Claim Now" button. Hidden behind that button (via CSS opacity: 0) is an iframe loading osztromok.com/account/delete. The iframe is perfectly positioned so the attacker's button overlaps your site's "Confirm Delete" button.

A logged-in visitor to evil.com clicks what looks like "Claim Now" — they actually click "Confirm Delete" on your site. The request goes through with their session cookie because the browser is genuinely loading your site, just invisibly.

X-Frame-Options: DENY prevents your site from loading inside any iframe at all — the attack fails at the first step.
Fix: Header always set X-Frame-Options "SAMEORIGIN" (or DENY if you never use iframes)

Content Security Policy (CSP)

CSP is the most powerful security header — and the most complex. It defines a whitelist of trusted sources for every type of resource your page can load: scripts, stylesheets, images, fonts, API calls. The browser blocks anything that doesn't match the policy. A properly configured CSP makes XSS (cross-site scripting) attacks far harder to exploit — even if an attacker manages to inject HTML, the injected scripts won't run.

A wrong CSP will break your site. Too restrictive and your own CSS or JavaScript stops loading. A blank page with no errors is a typical symptom. Always deploy CSP in Report-Only mode first — the browser enforces nothing but logs every violation, letting you see what would break before it does.

CSP Directives

default-src
The fallback for all resource types not explicitly listed. Set this first; override specific types below. Most restrictive setting: 'self'
script-src
Where JavaScript can be loaded from. The highest-value directive — controls XSS. Avoid 'unsafe-inline' — it defeats much of CSP's XSS protection.
style-src
Where CSS can be loaded from. Inline <style> tags require 'unsafe-inline' or a hash/nonce. Google Fonts requires adding https://fonts.googleapis.com.
img-src
Where images can be loaded from. data: allows base64-embedded images. Add CDN domains if you load images from third-party services.
font-src
Where web fonts can be loaded from. If using Google Fonts: add https://fonts.gstatic.com.
connect-src
Which URLs JavaScript can connect to (fetch, XHR, WebSocket). Relevant if your site makes API calls to external services.
frame-ancestors
The CSP replacement for X-Frame-Options. frame-ancestors 'none' = DENY. More powerful — X-Frame-Options only controls one level of nesting.
form-action
Where forms can submit to. 'self' prevents injected forms from submitting to attacker-controlled servers (data exfiltration via form).
base-uri
Restricts the <base> element, which can redirect all relative URLs. 'self' or 'none' prevents base tag injection attacks.

CSP Source Values

'self' 'none' https://trusted.com https: 'unsafe-inline' 'unsafe-eval' data: * http:

Green = safe and recommended. Amber = use only where necessary (weaken security). Red = avoid — these open significant holes.

Deploying CSP Safely — Report-Only First

Step 1 — Deploy in Report-Only mode
Use Content-Security-Policy-Report-Only instead of Content-Security-Policy. The browser evaluates the policy and logs violations to the browser console (and optionally to a report-uri) but does not block anything. Your site works exactly as before.
Step 2 — Browse your site and watch the console
Open DevTools → Console. Any violation appears as: "Refused to load the script 'https://cdn.example.com/lib.js' because it violates the following Content Security Policy directive: script-src 'self'". Note every domain mentioned — these are sources you need to whitelist.
Step 3 — Expand the policy to cover legitimate sources
Add each violation's source to the appropriate directive. For example, if jQuery loads from cdnjs.cloudflare.com, add it to script-src. If Google Analytics appears, add www.google-analytics.com and www.googletagmanager.com to script-src and connect-src.
Step 4 — Switch from Report-Only to enforcing
Once the console is clean (no violations), change Content-Security-Policy-Report-Only to Content-Security-Policy in Apache. Reload Apache. Test every page of the site — especially any with forms, embedded maps, videos, or third-party widgets.
Step 5 — Keep refining over time
When you add new third-party embeds (YouTube video, Disqus comments, analytics), they'll generate CSP violations. That's the policy doing its job — you need to explicitly allow each new source. This makes you conscious of every external dependency.

Putting It All Together — Complete VirtualHost Config

All headers go in the *:443 VirtualHost (HTTPS only). The HTTP VirtualHost only handles redirecting to HTTPS — no need for security headers on the redirect response.

# /etc/apache2/sites-available/osztromok.com.conf # ── HTTP VirtualHost — redirect only ──────────────────────────── <VirtualHost *:80> ServerName osztromok.com ServerAlias www.osztromok.com Redirect permanent / https://osztromok.com/ </VirtualHost> # ── HTTPS VirtualHost — all security headers go here ──────────── <VirtualHost *:443> ServerName osztromok.com ServerAlias www.osztromok.com DocumentRoot /var/www/osztromok.com/public_html <Directory /var/www/osztromok.com/public_html> Options FollowSymLinks AllowOverride All Require all granted </Directory> SSLEngine on SSLCertificateFile /etc/letsencrypt/live/osztromok.com/fullchain.pem SSLCertificateKeyFile /etc/letsencrypt/live/osztromok.com/privkey.pem # ── Security headers ───────────────────────────────────────── # Remove server information headers Header always unset X-Powered-By Header always unset X-AspNet-Version # Clickjacking protection Header always set X-Frame-Options "SAMEORIGIN" # MIME sniffing protection Header always set X-Content-Type-Options "nosniff" # Referrer privacy Header always set Referrer-Policy "strict-origin-when-cross-origin" # Lock down browser APIs Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" # HSTS — already set in Chapter 2 (don't duplicate if already present) Header always set Strict-Transport-Security "max-age=86400" # CSP — start in Report-Only mode, switch to enforcing after testing Header always set Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'self'" # ───────────────────────────────────────────────────────────── ErrorLog ${APACHE_LOG_DIR}/osztromok_error.log CustomLog ${APACHE_LOG_DIR}/osztromok_access.log combined </VirtualHost>
# Validate the config, then reload $ sudo apache2ctl configtest Syntax OK $ sudo systemctl reload apache2
The CSP above is intentionally permissive as a starting point'unsafe-inline' is included for script-src and style-src because osztromok.com's lesson pages use a lot of inline CSS and JavaScript. Once you've identified all your legitimate sources via Report-Only violations, you can tighten this by removing 'unsafe-inline' and replacing inline scripts with nonces or hashes.

Switching CSP to Enforcing Mode

# After testing with Report-Only and fixing all violations: # Change the header name from Report-Only to enforcing # Before: Header always set Content-Security-Policy-Report-Only "default-src 'self'; ..." # After: Header always set Content-Security-Policy "default-src 'self'; ..." # An example of a tighter policy (after identifying all sources): Header always set Content-Security-Policy "default-src 'self'; script-src 'self' https://cdnjs.cloudflare.com; style-src 'self' https://fonts.googleapis.com 'unsafe-inline'; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; form-action 'self'; base-uri 'self'; frame-ancestors 'none'"

Testing Your Headers

Verification · Check Headers Are Being Sent
Three methods to confirm your headers are active.
1
curl — quick server-side check.
$ curl -I https://osztromok.com HTTP/2 200 x-frame-options: SAMEORIGIN x-content-type-options: nosniff referrer-policy: strict-origin-when-cross-origin permissions-policy: camera=(), microphone=(), geolocation=(), payment=() content-security-policy-report-only: default-src 'self'; ... strict-transport-security: max-age=86400 server: Apache ← good — no version number # If a header is missing: check Apache error log and verify the conf loaded $ sudo grep -i "header" /var/log/apache2/error.log | tail -10
2
Browser DevTools — check CSP violations while you browse.
Open Chrome or Firefox → F12 → Console tab. Browse every page of your site. CSP violations appear in red:
Refused to load script 'https://cdn.jsdelivr.net/npm/...' because it violates the following Content Security Policy directive: "script-src 'self'".
Each violation tells you exactly which domain you need to add to the policy.
3
Online security header scanners — get a scored report.
Two reliable options (no account needed):

securityheaders.com — grades A+ through F, flags missing or misconfigured headers, shows what each one does.
observatory.mozilla.org — Mozilla's scanner, checks headers, cookies, redirect chains, and HSTS.

Enter osztromok.com and aim for B+ or above. An A+ requires a strict CSP with no 'unsafe-inline'.
Run the online scanners after every change. A score regression usually means a header was accidentally removed or a duplicate was introduced (two conflicting values for the same header cancel out or cause unexpected behaviour).

How Cloudflare Interacts With These Headers

# Headers Cloudflare adds on top of Apache's response (you'll see these in curl): cf-ray: 8a4b3c2d1e0f-LHR # Cloudflare request ID — ignore cf-cache-status: DYNAMIC # Cloudflare cache state — ignore server: cloudflare # ← Cloudflare replaces the Server header! # The Server: header — note Cloudflare replaces "Apache" with "cloudflare" # in the curl output. This is fine — it's even better than ServerTokens Prod # since it hides that you're running Apache at all.
  • Your custom security headers pass through unchanged — X-Frame-Options, CSP, X-Content-Type-Options, etc. all arrive at the browser exactly as Apache sent them.
  • The Server: header is replaced by Cloudflare — visitors see Server: cloudflare, not Server: Apache. This is actually better; ServerTokens Prod still matters for direct connections and internal tooling.
  • Cloudflare can also set security headers via Transform Rules (in the Cloudflare dashboard). If Cloudflare sets a header that Apache also sets, the visitor receives the header twice. This can cause unexpected behaviour — check with curl -I and look for duplicate header names.
  • Cloudflare's Bot Fight Mode and WAF add their own layer of protection before requests reach Apache, complementing your headers rather than replacing them.
Check for duplicate headers after enabling Cloudflare Transform Rules. If you set X-Frame-Options in both Apache and a Cloudflare Transform Rule, the browser receives both values. RFC behaviour for duplicate response headers varies by browser — some take the first, some take the last, some treat it as an error. Use curl -I https://osztromok.com | grep -i "x-frame" — you should see the header exactly once.

Troubleshooting

Security headers are not appearing in curl -I output
In order of likelihood: (1) Apache config not reloaded after changes — run sudo systemctl reload apache2. (2) Syntax error prevented the config from loading — run sudo apache2ctl configtest; fix any errors. (3) Headers are in the wrong VirtualHost — if you put them in the HTTP (*:80) block but are testing HTTPS, they won't appear. Move them to the *:443 block. (4) mod_headers not enabled — check: apache2ctl -M | grep headers; enable with sudo a2enmod headers.
CSS / JavaScript / fonts stopped loading after enabling CSP
Your CSP is blocking a legitimate resource. Open DevTools Console — violation messages show exactly which source is blocked. Add that source to the appropriate directive in the policy (e.g. if fonts.googleapis.com is blocked, add it to style-src). If many things break at once, temporarily add 'unsafe-inline' to script-src and style-src to get the site working, then identify and whitelist specific sources before removing 'unsafe-inline' again. If you're in a hurry: switch back to Content-Security-Policy-Report-Only to stop blocking while you refine the policy.
Same header appears twice in curl output
Duplicate header — being set in two places. Common causes: (1) Set in both the VirtualHost config and in an .htaccess file — remove from one. (2) Set in both Apache and a Cloudflare Transform Rule — remove one. (3) Set in both the server-level security.conf and the VirtualHost — pick one location and remove the other. Use sudo grep -r "X-Frame-Options" /etc/apache2/ to find every place a header is set.
ServerTokens Prod doesn't seem to change the Server: header
Via Cloudflare Tunnel, the Server: header is overwritten by Cloudflare — you'll always see Server: cloudflare when curling through the tunnel. ServerTokens Prod still works, but its effect isn't visible via Cloudflare. To verify it's working, test from localhost: curl -I http://localhost — the Server header should show Apache (not the version). Also check: is security.conf actually enabled? apache2ctl -t -D DUMP_INCLUDES shows which conf files are loaded.
Inline styles stop working with CSP enabled
<style> tags and style="" attributes are blocked unless 'unsafe-inline' is in style-src. For a site with many lesson pages using inline styles (like osztromok.com), the practical choice is to keep 'unsafe-inline' in style-src — it's less ideal from a CSP perspective but doesn't enable script injection. The security benefit of removing inline style is smaller than removing inline script. Focus your efforts on keeping script-src as tight as possible.

Quick Reference — Chapter 6

Setting / HeaderRecommended valueProtects against
ServerTokensProdVersion disclosure — only "Apache", no version number
ServerSignatureOffVersion in error page footers
X-Powered-ByunsetPHP / framework version disclosure
X-Frame-OptionsSAMEORIGINClickjacking via malicious iframes
X-Content-Type-OptionsnosniffMIME confusion / uploaded file execution
Referrer-Policystrict-origin-when-cross-originURL leakage to third-party sites
Permissions-Policycamera=(), microphone=(), geolocation=()Browser API abuse via injected scripts
Content-Security-Policydefault-src 'self'; ...XSS, data injection, resource hijacking
CommandPurpose
curl -I https://osztromok.comCheck all response headers — verify headers are present and not duplicated
sudo apache2ctl configtestValidate Apache config syntax before reload
apache2ctl -M | grep headersConfirm mod_headers is loaded
sudo grep -r "Header" /etc/apache2/Find all places a header directive is set — hunt for duplicates
Browser DevTools → ConsoleSee CSP violation messages while browsing (Report-Only or enforcing)