Skip to main content

HTTPS Security Headers: TLS, CSP, and HSTS Guide

Published: December 12, 2025 Updated: May 24, 2026 Larry Qu 14 min read

Securing web traffic is a foundational responsibility for web developers and DevOps engineers. HTTPS (TLS) protects data in transit while security headers such as Content Security Policy (CSP) and HTTP Strict Transport Security (HSTS) harden browser behavior and reduce attack surface. This guide explains the protocols, shows practical header configurations, and gives deployment advice you can apply today.


Overview

  • SSL vs TLS: TLS is the modern protocol; “SSL” is legacy naming
  • TLS handshake basics: key exchange, authentication, and symmetric encryption
  • CSP: reduce XSS risk by restricting sources for scripts, styles, frames
  • HSTS: force browsers to use HTTPS only
  • Practical deployment: certificate issuance/renewal, secure server configs, testing

SSL/TLS Essentials

What TLS Does

Transport Layer Security (TLS) encrypts HTTP traffic, preventing eavesdropping and tampering. A TLS connection provides:

  • Confidentiality (encryption)
  • Integrity (detect tampering)
  • Authentication (server identity via certificate)

Note: People still say “SSL/TLS” but TLS 1.2 and TLS 1.3 are the recommended protocols—disable SSLv3/TLS < 1.2.

How the TLS Handshake Works (high level)

  1. Client Hello: the client sends supported protocol versions, cipher suites, and a random value.
  2. Server Hello: server picks protocol version and cipher suite and sends its certificate chain.
  3. Key Exchange: using ECDHE or similar, both parties derive a shared secret.
  4. Verification and Finished messages: both sides verify the handshake and begin encrypted communication.

TLS 1.3 simplifies and speeds up this process (fewer roundtrips, mandatory forward secrecy).

Certificates and Trust

A TLS certificate includes a public key and identity info (subject). Browsers trust certificates issued by recognized Certificate Authorities (CAs). Common management steps:

  • Generate a private key and CSR (certificate signing request)
  • Have a CA issue the certificate (Let’s Encrypt, commercial CAs)
  • Install cert and key on your server
  • Renew before expiry (Let’s Encrypt: 90 days)

Inspect a certificate with openssl:

openssl s_client -connect example.com:443 -servername example.com \
  | openssl x509 -noout -text

Key Concepts and Best Practices

  • Use TLS 1.3 where possible; fall back to TLS 1.2 only if necessary
  • Prefer ECDHE key exchange for Perfect Forward Secrecy (PFS)
  • Use 2048+ RSA keys or ECDSA P-256 keys
  • Enable OCSP stapling to speed up revocation checks
  • Disable old ciphers (RC4, DES, 3DES, export ciphers)
  • Regularly test configs with SSL Labs: https://www.ssllabs.com/ssltest/

Example nginx TLS snippet (minimal, secure):

ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off; # TLS1.3 chooses secure ciphers
ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_stapling on;
ssl_stapling_verify on;
resolver 8.8.8.8 8.8.4.4 valid=300s;
resolver_timeout 5s;

Content Security Policy (CSP)

CSP is a browser mechanism that restricts what resources a page may load and execute, greatly reducing the impact of cross-site scripting (XSS) vulnerabilities.

CSP Basics

Set CSP with the Content-Security-Policy header. The policy lists directives that control resource types, e.g. default-src, script-src, style-src, img-src, frame-ancestors.

A conservative example:

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none';
  • default-src 'self' allows resources only from the same origin
  • script-src allows scripts from the site’s origin and a trusted CDN
  • object-src 'none' disables plugins
  • frame-ancestors 'none' prevents framing (clickjacking)

Inline scripts/styles are frequent causes of weakness. Instead of unsafe-inline, use nonces or hashes.

Server-side: generate a random nonce per response and include it in script tags and the CSP header.

Content-Security-Policy: script-src 'self' 'nonce-<RANDOM_NONCE>' https://cdn.example.com;

HTML:

<script nonce="<RANDOM_NONCE>">console.log('allowed inline script');</script>

Hashes are useful for static inline scripts (sha256-abc…). Nonces are better for dynamic pages.

Reporting and Iteration

Use Content-Security-Policy-Report-Only during rollout to collect violations without blocking. Provide a report-uri or report-to endpoint to gather reports.

Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report-endpoint

Common Pitfalls

  • unsafe-inline and unsafe-eval negate much of CSP’s protection—avoid them
  • Overly broad host wildcards (e.g., *.example.com) may include attacker-controlled subdomains in misconfigured DNS
  • Forgetting to include frame-ancestors allows clickjacking

HTTP Strict Transport Security (HSTS)

HSTS tells browsers to only use HTTPS when contacting your site for a specified period.

Header and Options

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
  • max-age is seconds—31536000 equals one year
  • includeSubDomains applies HSTS to all subdomains
  • preload indicates intent to submit to the browser preload list (see https://hstspreload.org)

Deployment Notes

  1. Start with max-age=3600 (1 hour) and no includeSubDomains during testing
  2. Once stable, increase max-age to one year and add includeSubDomains if you control all subdomains
  3. Only add preload after careful testing—being preloaded means removal requires coordination with browsers

Nginx example

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

Secure Deployment Practices

Certificate Management

  • Use Let’s Encrypt for automation or a commercial CA for extended validation
  • Automate renewals (certbot, acme.sh, or platform-managed certs)
  • Monitor expiry and set alerts
  • Use separate keys for dev/staging and production
  • For high-value keys, consider storing private keys in an HSM or cloud KMS

Example: issue and auto-renew with Certbot (nginx):

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
# Certbot installs renewal cron; test with:
sudo certbot renew --dry-run

Server Configuration Checklist

  • Redirect all HTTP to HTTPS (301)
  • Add HSTS after testing
  • Enable strong TLS protocols and ciphers
  • Enable OCSP stapling
  • Serve cookies with Secure; HttpOnly; SameSite=Strict where appropriate
  • Disable weak TLS versions and ciphers
  • Keep server software and OpenSSL updated

HTTP redirect example (nginx):

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

Extra Security Headers

Add these headers in addition to CSP and HSTS:

add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Permissions-Policy "geolocation=(), microphone=()" always; # formerly Feature-Policy
add_header X-XSS-Protection "0" always; # deprecated but some still set it

Testing & Validation

Common Pitfalls

  • Applying HSTS before HTTPS redirects are stable (locks users out if misconfigured)
  • Using wide CSPs (default-src *) that defeat the purpose
  • Forgetting to renew certificates or relying on manual renewals
  • Exposing private keys in version control—never commit private keys

Quick Reference: Example Express.js hardening

const express = require('express');
const helmet = require('helmet');
const app = express();

// Helmet sets many headers (CSP must be configured carefully)
app.use(helmet());

// HSTS (enable after verifying HTTPS)
app.use(helmet.hsts({
  maxAge: 31536000,
  includeSubDomains: true,
  preload: true
}));

// CSP with nonce (example middleware sets res.locals.cspNonce)
app.use((req, res, next) => {
  const nonce = crypto.randomBytes(16).toString('base64');
  res.locals.cspNonce = nonce;
  res.setHeader("Content-Security-Policy",
    `default-src 'self'; script-src 'self' 'nonce-${nonce}' https://cdn.example.com;`);
  next();
});

app.get('/', (req, res) => {
  res.send(`<script nonce="${res.locals.cspNonce}">console.log('hello')</script>`);
});

TLS 1.3 Handshake in Detail

TLS 1.3, standardized in RFC 8446, redesigned the handshake to complete in one round trip (1-RTT) for new connections and zero round trips (0-RTT) for resumed connections.

Full TLS 1.3 Handshake (1-RTT)

Client                                                     Server
  |                                                          |
  |--- ClientHello ---------------------------------------->|
  |    Protocol: TLS 1.3                                     |
  |    Key share: X25519 public key                          |
  |    Ciphers: TLS_AES_256_GCM_SHA384, ...                  |
  |    Extensions: SNI, ALPN, supported_versions              |
  |                                                          |
  |                                            ServerHello --|
  |                                            Cipher suite -|
  |                                            Key share ----|
  |                                            Certificate --|
  |                                            CertVerify ---|
  |                                            Finished -----|
  |<---------------------------------------------------------|
  |                                                          |
  |--- Finished ------------------------------------------->|
  |--- Application Data ----------------------------------->|

Step-by-step breakdown:

  1. ClientHello: Client sends supported protocol versions, a Diffie-Hellman key share (typically X25519 or P-256), cipher suites, and extensions (SNI for hostname, ALPN for protocol negotiation). Unlike TLS 1.2, the client predicts the key agreement algorithm and sends its share immediately.

  2. ServerHello: Server selects the protocol version (TLS 1.3), cipher suite, and sends its own key share. Both parties now compute the shared secret using ECDHE.

  3. Encrypted Extensions: Server sends extensions (ALPN negotiation, server name) encrypted with the handshake traffic key.

  4. Certificate: Server sends its certificate chain, encrypted. TLS 1.3 encrypts the certificate, hiding the server identity from passive observers.

  5. CertificateVerify: Server signs the handshake transcript to prove possession of the private key.

  6. Finished: Server sends an HMAC of the handshake, authenticated.

  7. Client Finished: Client authenticates the handshake similarly.

  8. Application Data: Encrypted communication begins.

TLS 1.2 vs TLS 1.3 Comparison

Feature TLS 1.2 TLS 1.3
Round trips 2 (full handshake) 1 (full handshake)
Handshake latency 2 RTT 1 RTT (0 with 0-RTT)
Cipher suites 40+ combinations 5 AEAD ciphers
Key exchange Separate negotiation Embedded in ClientHello
Forward secrecy Optional Mandatory (ECDHE)
Certificate encryption No Yes
0-RTT resumption No Yes (with anti-replay)
Supported signature algs RSA, ECDSA, DSA ECDSA, EdDSA, RSA-PSS
Compression Supported (disabled) Removed
Renegotiation Supported Removed
Static RSA key exchange Supported (insecure) Removed
Insecure feature removal Manual Built-in

0-RTT (Zero Round Trip Resumption)

TLS 1.3 supports sending application data with the ClientHello for resumed connections:

Client (with cached PSK)                                   Server
  |                                                          |
  |--- ClientHello + 0-RTT Data -------------------------->|
  |    PSK key ID                                            |
  |    Early application data                                |
  |                                                          |
  |                                            ServerHello --|
  |                                            Finished -----|
  |<---------------------------------------------------------|
  |--- End of EarlyData ---------------------------------->|

Considerations for 0-RTT:

  • Data is replayable — an attacker can forward the same 0-RTT data multiple times
  • Applications must implement idempotency guarantees for 0-RTT requests
  • Suitable for GET requests, unsafe for non-idempotent mutations
  • Maximum early data size is configurable (typically 16KB)
  • Not all clients support 0-RTT

HSTS Preload List Submission

The HSTS preload list is a hardcoded list of domains in browser source code that receive automatic HTTPS enforcement, even before the first HSTS header is received.

Submission Process

  1. Prerequisites: Your site must:

    • Have a valid TLS certificate on the root domain
    • Redirect all HTTP traffic to HTTPS (301)
    • Serve all subdomains over HTTPS
    • Serve the HSTS header with max-age of at least 31536000 (1 year)
    • Include includeSubDomains directive
    • Include preload directive in the HSTS header
  2. Test your configuration:

    # Check HSTS header
    curl -sI https://example.com | grep -i strict-transport
    # Expected: strict-transport-security: max-age=31536000; includeSubDomains; preload
    
    # Test redirect
    curl -sI http://example.com | grep -i location
    # Expected: location: https://example.com/
    
  3. Submit to hstspreload.org: Visit https://hstspreload.org and enter your domain. The site validates your configuration against preload requirements.

  4. Wait for inclusion: After successful submission, your domain appears in the preload list within weeks to months. Browser vendors (Chrome, Firefox, Safari, Edge) update their preload lists independently.

  5. Removal difficulty: Once preloaded, removing a domain requires submitting a removal request to the Chromium HSTS preload list repository and waiting for a browser release cycle (3-6 months for Chromium). Preload removal is not guaranteed — some browsers may never remove your domain.

Content Security Policy Directive Reference

Core Directives

Directive Controls Example
default-src Fallback for all resource types default-src 'self'
script-src JavaScript sources script-src 'self' https://cdn.example.com
style-src CSS sources style-src 'self' 'unsafe-inline'
img-src Image sources img-src 'self' https://images.example.com data:
connect-src XHR, fetch, WebSocket connect-src 'self' https://api.example.com wss://ws.example.com
font-src Web font sources font-src 'self' https://fonts.gstatic.com
frame-src Frame/iframe sources frame-src 'self' https://player.vimeo.com
frame-ancestors Parent frame embedding frame-ancestors 'none'
media-src Audio/video sources media-src 'self' https://cdn.example.com
object-src Plugin sources (Flash, Java) object-src 'none'
base-uri <base> tag URLs base-uri 'self'
form-action Form submission targets form-action 'self' https://api.example.com
report-uri Violation reporting endpoint report-uri /csp-violations
report-to Reporting API group report-to csp-endpoint
manifest-src Web app manifest manifest-src 'self'
worker-src Web Worker, Service Worker worker-src 'self' blob:

Source Expressions

Expression Meaning
'none' No sources allowed
'self' Same origin
'unsafe-inline' Allow inline code (weakens protection)
'unsafe-eval' Allow eval() and similar (weakens protection)
'strict-dynamic' Trust scripts loaded by trusted scripts
'nonce-<value>' Allow script with matching nonce attribute
'sha256-<hash>' Allow script with matching hash
https: All HTTPS sources
http: All HTTP sources (weakens protection)
data: Data URIs
blob: Blob URIs
https://*.example.com Specific domain pattern

CSP Bypass Techniques and Prevention

Technique How It Works Prevention
JSONP endpoints Old APIs with ?callback= parameter execute arbitrary JavaScript Use strict-dynamic instead of CDN whitelists
CDN file uploads Upload malicious script to allowlisted CDN Pin specific script hashes, avoid https://*
Base tag injection Override relative URLs via injected <base> tag Set base-uri 'none' or base-uri 'self'
Form action override Redirect form submissions to attacker endpoint Set form-action 'self'
Dangling markup injection Capture page content via injected <img src=attacker? Set img-src 'self' and strict markup encoding
Angular sandbox escape Exploit AngularJS expression evaluation Use 'strict-dynamic' and avoid Angular 1.x
Policy misconfiguration default-src 'self' 'unsafe-inline' bypasses most protection Audit with automated CSP evaluator

Example of strict-dynamic usage:

Content-Security-Policy: script-src 'self' 'strict-dynamic' 'nonce-RAND123'

This policy tells the browser to trust scripts loaded by scripts that already have the correct nonce. Third-party CDN whitelists are ignored when strict-dynamic is present in Chromium browsers.

Certificate Transparency (CT)

Certificate Transparency logs provide an audit mechanism for TLS certificate issuance. Every publicly trusted CA must submit certificates to CT logs before issuance.

How CT Works

  1. When a CA issues a certificate, it submits the certificate to multiple CT logs
  2. Each log returns a Signed Certificate Timestamp (SCT) proving submission
  3. The SCT is delivered to clients via:
    • X.509 extension: Embedded in the certificate (most reliable)
    • TLS extension: Sent during handshake as signed_certificate_timestamp
    • OCSP stapling: Included in OCSP response
  4. Browsers check SCTs against known logs and reject certificates without valid SCTs (Chrome requires at least 2 SCTs)

Checking CT Status

# Check SCTs in certificate
openssl s_client -connect example.com:443 -servername example.com \
  | openssl x509 -noout -text | grep -A 20 "CT Precertificate"

# Using ct-client tool
ct-client verify example.com

CT Monitoring

Operators should monitor CT logs for unauthorized certificate issuance:

# Monitor for new certificates issued for your domain
go run github.com/google/certificate-transparency-go/cmd/ct_monitor \
  --domains example.com --log_list ct_log_list.json

Security Scoring and Automation

Automated Security Assessment Tools

Tool Purpose URL Usage
SSL Labs Test TLS configuration grading https://www.ssllabs.com/ssltest/ Web-based, API available
Mozilla Observatory Security headers + TLS https://observatory.mozilla.org/ Web-based, CLI available
Security Headers Header configuration grading https://securityheaders.com/ Web-based, API available
CSP Evaluator CSP policy analysis https://csp-evaluator.withgoogle.com/ Web-based
HSTS Preload Check HSTS readiness check https://hstspreload.org/ Web-based
testssl.sh Comprehensive TLS testing https://testssl.sh/ CLI tool

CI/CD Header Validation

Integrate security header checks into your deployment pipeline:

# GitHub Actions: Validate security headers on deploy
security-headers-check:
  runs-on: ubuntu-latest
  steps:
  - name: Check security headers
    run: |
      URL="https://${{ vars.SITE_DOMAIN }}"
      echo "Testing: $URL"

      # Check HSTS
      HSTS=$(curl -sI "$URL" | grep -i strict-transport-security)
      if [ -z "$HSTS" ]; then
        echo "FAIL: Missing HSTS header"
        exit 1
      fi
      echo "PASS: HSTS header found"

      # Check CSP
      CSP=$(curl -sI "$URL" | grep -i content-security-policy)
      if [ -z "$CSP" ]; then
        echo "FAIL: Missing CSP header"
        exit 1
      fi
      echo "PASS: CSP header found"

      # Check TLS version
      TLS=$(echo | openssl s_client -connect "${{ vars.SITE_DOMAIN }}:443" 2>/dev/null \
        | openssl x509 -noout -text | grep -i "tls")
      echo "TLS version: $TLS"

Automated Certificate Monitoring

#!/bin/bash
# cert-monitor.sh — Check certificate expiry and send alerts

DOMAINS=("example.com" "api.example.com")
THRESHOLD_DAYS=30

for domain in "${DOMAINS[@]}"; do
  expiry_date=$(echo | openssl s_client -connect "$domain":443 \
    -servername "$domain" 2>/dev/null \
    | openssl x509 -noout -enddate \
    | cut -d= -f2)

  expiry_epoch=$(date -d "$expiry_date" +%s)
  now_epoch=$(date +%s)
  days_left=$(( (expiry_epoch - now_epoch) / 86400 ))

  if [ "$days_left" -lt "$THRESHOLD_DAYS" ]; then
    echo "ALERT: $domain certificate expires in $days_left days"
    # Send alert (email, Slack, PagerDuty)
  else
    echo "OK: $domain expires in $days_left days"
  fi
done

Real-World Configuration Examples

Apache

<VirtualHost *:443>
    ServerName example.com

    SSLEngine on
    SSLCertificateFile /etc/ssl/certs/example.com.pem
    SSLCertificateKeyFile /etc/ssl/private/example.com.key

    # TLS 1.3 and 1.2 only
    SSLProtocol -all +TLSv1.3 +TLSv1.2

    # Secure ciphers (TLS 1.3 ciphers are built-in)
    SSLCipherSuite ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384
    SSLHonorCipherOrder on

    # OCSP Stapling
    SSLUseStapling on
    SSLStaplingResponderTimeout 5
    SSLStaplingReturnResponderErrors off

    # Security headers
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
    Header always set X-Content-Type-Options "nosniff"
    Header always set X-Frame-Options "DENY"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"

    # CSP
    Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-${NONCE}'; object-src 'none'; base-uri 'self'; frame-ancestors 'none';"
</VirtualHost>

<VirtualHost *:80>
    ServerName example.com
    Redirect permanent / https://example.com/
</VirtualHost>

Cloudflare

When using Cloudflare as a reverse proxy, TLS termination happens at the edge:

Cloudflare Settings:
  SSL/TLS: Full (strict)
  Minimum TLS Version: 1.2
  Opportunistic Encryption: On
  Always Use HTTPS: On
  Automatic HTTPS Rewrites: On
  Certificate Transparency Monitoring: On
  HTTP/2 to Origin: On
  HTTP/3: On

  Security Headers (via Cloudflare Transform Rules):
    - Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
    - X-Content-Type-Options: nosniff
    - X-Frame-Options: DENY
    - Referrer-Policy: strict-origin-when-cross-origin
    - Permissions-Policy: geolocation=(), microphone=()

  WAF Custom Rules:
    - Block missing CSP header
    - Block weak TLS versions
    - Rate limit by IP

CDN (Generic)

# When behind a CDN (e.g., CloudFront, Fastly, Akamai)
# TLS is terminated at the CDN edge — configure there.
# Origin server should still serve security headers:

server {
    listen 443 ssl http2;
    server_name origin.example.com;

    # Origin certificate (wildcard or Let's Encrypt)
    ssl_certificate /etc/ssl/certs/origin.pem;
    ssl_certificate_key /etc/ssl/private/origin.key;
    ssl_protocols TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    # Forward client certificate info from CDN
    ssl_verify_client optional;

    # Security headers
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "DENY" always;
    add_header Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none';" always;

    # ... application routing ...
}

Conclusion

HTTPS and security headers are essential, practical measures with immediate impact:

  • Use TLS 1.3 and prefer ECDHE for forward secrecy
  • Automate certificate issuance and renewal (Let’s Encrypt + certbot)
  • Use CSP (with nonces or hashes) to mitigate XSS
  • Gradually deploy HSTS and consider preload only when ready
  • Harden servers, monitor certificates, and test with SSL Labs and Mozilla Observatory

These controls significantly reduce common attack vectors and are well worth the engineering effort.


Further Reading

Resources

Comments

👍 Was this article helpful?