How to Harden Web Servers: Security Headers, CSP and HSTS on RHEL 7

HTTP security headers are one of the most cost-effective controls available to web server administrators. They instruct the browser to enforce strict content handling rules, preventing common attacks such as cross-site scripting (XSS), clickjacking, MIME-type confusion, and protocol downgrade attacks — without requiring any changes to application code. This tutorial covers adding a comprehensive set of security headers to both Apache and Nginx on RHEL 7, configuring a Content Security Policy, enabling HTTP Strict Transport Security with preload support, and validating the result using the same methodology employed by tools like securityheaders.com.

Prerequisites

  • RHEL 7 server with Apache (httpd) or Nginx installed
  • TLS/SSL certificate already configured — HSTS requires HTTPS
  • Root or sudo access
  • Basic understanding of Apache or Nginx virtual host configuration
  • mod_headers enabled for Apache (included in httpd package on RHEL 7)

Step 1: Install and Enable Apache or Nginx

Install Apache if not already present:

sudo yum install -y httpd mod_ssl
sudo systemctl enable httpd
sudo systemctl start httpd

For Nginx, enable the EPEL repository first:

sudo yum install -y epel-release
sudo yum install -y nginx
sudo systemctl enable nginx
sudo systemctl start nginx

Verify mod_headers is loaded in Apache:

httpd -M 2>/dev/null | grep headers
# Expected: headers_module (shared)

Step 2: Configure Security Headers in Apache

Edit your virtual host configuration file. Security headers for HTTPS virtual hosts should be placed inside the <VirtualHost *:443> block. The Header always set directive ensures headers are added even for error responses (4xx, 5xx):

sudo vi /etc/httpd/conf.d/ssl.conf

Add the following block inside the SSL virtual host:

<VirtualHost *:443>
    ServerName example.com
    DocumentRoot /var/www/html

    SSLEngine on
    SSLCertificateFile    /etc/pki/tls/certs/example.com.crt
    SSLCertificateKeyFile /etc/pki/tls/private/example.com.key

    # HTTP Strict Transport Security — 1 year, include subdomains, preload
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"

    # Prevent clickjacking
    Header always set X-Frame-Options "SAMEORIGIN"

    # Prevent MIME-type sniffing
    Header always set X-Content-Type-Options "nosniff"

    # Control referrer information sent with requests
    Header always set Referrer-Policy "strict-origin-when-cross-origin"

    # Disable legacy XSS auditor (modern recommendation: rely on CSP)
    Header always set X-XSS-Protection "0"

    # Content Security Policy (start with report-only during testing)
    Header always set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"

    # Permissions Policy (formerly Feature-Policy)
    Header always set Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()"
</VirtualHost>

Redirect all HTTP traffic to HTTPS in the port 80 virtual host:

<VirtualHost *:80>
    ServerName example.com
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
    Redirect permanent / https://example.com/
</VirtualHost>

Test the Apache configuration and reload:

sudo apachectl configtest
sudo systemctl reload httpd

Step 3: Configure Security Headers in Nginx

For Nginx, security headers are added with the add_header directive. Edit your server block configuration:

sudo vi /etc/nginx/conf.d/example.conf

Configure the HTTPS server block:

server {
    listen 443 ssl http2;
    server_name example.com;
    root /usr/share/nginx/html;

    ssl_certificate     /etc/pki/tls/certs/example.com.crt;
    ssl_certificate_key /etc/pki/tls/private/example.com.key;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         HIGH:!aNULL:!MD5;

    # HSTS
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

    # Clickjacking protection
    add_header X-Frame-Options "SAMEORIGIN" always;

    # MIME sniffing protection
    add_header X-Content-Type-Options "nosniff" always;

    # Referrer Policy
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # Disable legacy XSS auditor
    add_header X-XSS-Protection "0" always;

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

    # Permissions Policy
    add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always;
}

server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
}

Test and reload Nginx:

sudo nginx -t
sudo systemctl reload nginx

Step 4: Understanding Content-Security-Policy Directives

Content Security Policy is a declarative whitelist that tells the browser which sources are trusted for each content type. A well-crafted CSP is the single most effective defence against XSS attacks. Key directives explained:

  • default-src 'self' — fallback for all content types; only allow same-origin resources
  • script-src 'self' — only execute scripts served from your own domain; blocks inline scripts and eval()
  • style-src 'self' 'unsafe-inline' — allow same-origin stylesheets; 'unsafe-inline' is needed if inline styles are used (work to remove this over time)
  • img-src 'self' data: — images from same origin and data URIs (used by CSS background images)
  • font-src 'self' — web fonts only from your own server
  • connect-src 'self' — XMLHttpRequest, WebSocket, and fetch() calls restricted to same origin
  • frame-ancestors 'none' — the CSP equivalent of X-Frame-Options DENY; no page may embed yours in a frame
  • base-uri 'self' — prevents base tag injection attacks that redirect relative URLs
  • form-action 'self' — form submissions only to same-origin endpoints

When adding external CDN resources, add them explicitly rather than opening the policy broadly:

script-src 'self' https://cdn.jsdelivr.net https://ajax.googleapis.com;

Use Content-Security-Policy-Report-Only mode during development to log violations without blocking:

# Apache:
Header always set Content-Security-Policy-Report-Only "default-src 'self'; report-uri /csp-report-endpoint"

# Nginx:
add_header Content-Security-Policy-Report-Only "default-src 'self'; report-uri /csp-report-endpoint" always;

Step 5: HTTP Strict Transport Security Deep Dive

HSTS instructs the browser to always use HTTPS for your domain, even if a user types http:// or clicks an HTTP link. The browser caches this instruction for the duration specified by max-age:

  • max-age=31536000 — 1 year (minimum required for HSTS preload submission)
  • includeSubDomains — applies the policy to all subdomains; only add this when all subdomains serve HTTPS
  • preload — indicates willingness to be included in browser preload lists; requires prior submission to hstspreload.org

Important: Only add preload when you are absolutely certain all current and future subdomains will support HTTPS. Preloading is very difficult to reverse. Start with a shorter max-age during testing:

# Testing phase (5 minutes):
Header always set Strict-Transport-Security "max-age=300"

# Production phase (1 year + subdomains):
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"

Step 6: X-Frame-Options and Permissions-Policy

X-Frame-Options: SAMEORIGIN prevents your pages from being embedded in iframes on other domains, mitigating clickjacking attacks. For stricter control, use DENY to block all framing including from your own domain.

The Permissions-Policy header (formerly Feature-Policy) controls browser API access. Disable APIs your site does not use:

# Comprehensive Permissions-Policy example:
Header always set Permissions-Policy "accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), display-capture=(), document-domain=(), encrypted-media=(), fullscreen=(self), geolocation=(), gyroscope=(), interest-cohort=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), xr-spatial-tracking=()"

Step 7: Validate Security Headers

Test your headers using curl from the command line:

curl -s -D - https://example.com -o /dev/null | grep -i "strict|x-frame|x-content|referrer|content-security|permissions"

Expected output:

strict-transport-security: max-age=31536000; includeSubDomains; preload
x-frame-options: SAMEORIGIN
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin
content-security-policy: default-src 'self'; script-src 'self'; ...
permissions-policy: geolocation=(), microphone=(), camera=(), payment=()

For a comprehensive grade using the securityheaders.com methodology, replicate their checks by verifying the presence and correct values of all six critical headers: Content-Security-Policy, Strict-Transport-Security, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and Permissions-Policy. Missing any of these results in a grade reduction. Use Mozilla Observatory for an automated online scan:

# Check via Mozilla Observatory API:
curl "https://http-observatory.security.mozilla.org/api/v1/analyze?host=example.com" 
  -X POST -d "hidden=true"

Conclusion

Implementing HTTP security headers on RHEL 7 is one of the highest-value, lowest-effort security improvements available for web servers. The headers covered in this guide collectively defend against XSS, clickjacking, MIME confusion, protocol downgrade, and unwanted browser API access. Start by deploying in report-only mode for CSP to observe violations without breaking functionality, progressively tighten the policy, then switch to enforcement mode. For existing applications that use inline scripts or third-party resources, iteratively refine the script-src and connect-src directives until all legitimate functionality is explicitly whitelisted and the policy enforces strict defaults everywhere else.