BYTESLIP ← Back
Knowledge Base // Tutorial 109

HTTPS, HSTS and Security Headers on Shared Hosting: Audit, Test and Roll Out a CSP via .htaccess

Difficulty: Hard ~90 min curl · Python 3 · headless Chrome · .htaccess

Security headers are a few lines of config that tell browsers how strict to be with your site: always use HTTPS, don’t guess file types, don’t let other sites frame you, and only run the scripts you approved. Getting them wrong can break your own pages, so this guide works the careful way. Audit first, build the policy from what your pages actually load, prove it in a local browser, and only then roll it out, in report-only mode first. Every command was run against this site, and the real output is shown.

Safety // Read First

Warnings

CriticalA strict Content-Security-Policy can blank out styles, fonts or scripts site-wide. Deploy it as Content-Security-Policy-Report-Only first. HSTS is sticky by design: browsers remember it for max-age seconds, and preload lists are much harder still to undo. Back up .htaccess before every edit.

Checklist // Before You Start

What You Need

Walkthrough // Steps 01–07

Audit → HTTPS → HSTS → inventory → CSP → test → roll out → re-audit

  1. Step 1: Audit what the server sends today

    Response headers aren’t in the page source, so ask the server directly:

    #!/bin/sh
    # usage: sh headers_audit.sh example.com
    H="$1"
    echo "== redirects"
    for u in "http://$H/" "http://www.$H/" "https://www.$H/"; do
      curl -s -m 10 -o /dev/null -w "%{http_code} -> %{redirect_url}   ($u)\n" "$u" || echo "no answer   ($u)"
    done
    echo "== security headers on https://$H/"
    curl -sI -m 10 "https://$H/" | tr -d '\r' > /tmp/hdrs.txt
    for h in strict-transport-security content-security-policy content-security-policy-report-only \
             x-content-type-options x-frame-options referrer-policy permissions-policy; do
      v=$(grep -i "^$h:" /tmp/hdrs.txt | cut -d' ' -f2-)
      printf '%-36s %s\n' "$h" "${v:-MISSING}"
    done

    This site, when this guide was written. It runs on LiteSpeed, and plain-HTTP results were taken directly against the origin:

    == redirects
    301 -> https://byteslip.org/   (http://byteslip.org/)
    301 -> https://byteslip.org/   (http://www.byteslip.org/)
    301 -> https://byteslip.org/   (https://www.byteslip.org/)
    == security headers on https://byteslip.org/
    strict-transport-security            max-age=31536000
    content-security-policy              MISSING
    content-security-policy-report-only  MISSING
    x-content-type-options               nosniff
    x-frame-options                      MISSING
    referrer-policy                      strict-origin-when-cross-origin
    permissions-policy                   MISSING

    Redirects, HSTS, nosniff and Referrer-Policy were already in place. The gaps were a CSP, frame protection and Permissions-Policy. Also check a 404 page and a redirect with curl -sI. Headers that only appear on 200 responses are a common miss.

  2. Step 2: Force HTTPS and one hostname (only if the audit says so)

    If http:// or www. don’t 301 to your https apex, cPanel’s Domains page has a Force HTTPS Redirect toggle. Or use .htaccess:

    RewriteEngine On
    # 1) www -> https apex, in one hop
    RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
    RewriteRule ^ https://%1%{REQUEST_URI} [R=301,L,NE]
    # 2) any remaining http -> https
    RewriteCond %{HTTPS} !=on
    RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L,NE]

    The order matters. Handling www first sends http://www. to the final URL in one hop, instead of http://www → https://www → https://. Tested on Apache 2.4: example.com and www.example.com over HTTP both returned 301 → https://example.com/tutorials/a-b/?q=1. If your host terminates TLS at a proxy or CDN, %{HTTPS} may never be “on”. Use the host’s toggle instead, or you’ll get a redirect loop.

  3. Step 3: Get HSTS right, and think twice about preload

    Strict-Transport-Security tells browsers to use HTTPS for your host from now on. MDN says hosts must send it over HTTPS only, because browsers ignore it over plain HTTP. This site’s plain-HTTP 301 also carries the header. That’s harmless, since it’s ignored, but it’s a small tidy-up item.

    • max-age=31536000 (one year) is common. Start shorter, e.g. max-age=300, while testing.
    • includeSubDomains covers every subdomain, so add it only if every subdomain serves HTTPS, including ones you forgot about.
    • preload asks to be built into browsers. hstspreload.org requires a max-age of at least one year, includeSubDomains and preload, plus the HSTS header on HTTPS redirects too. It now says plainly that “HSTS preloading is not recommended” because browsers already auto-upgrade navigations. Its benefit is small, and removal is slow.

    For a small site: keep one year, add includeSubDomains once you’ve checked every subdomain, and skip preload.

  4. Step 4: Inventory what your pages load

    A CSP is an allowlist, so first list what needs allowing. Run this on each page type (home, category, article):

    # every <script> and its src, inline <style> blocks, and third-party hosts
    curl -s https://example.com/ > page.html
    grep -oE '<script[^>]*>' page.html | sort | uniq -c
    grep -c '<style' page.html
    grep -oE '(src|href)="https?://[^/"]+' page.html | sed 's/.*="//' | sort | uniq -c

    On this site, that showed:

    • Scripts are all same-origin (/js/live-*.js), plus one inline script on the home page that logs a view to /api/views.php.
    • One inline <style> block per page, and a style= attribute on the home page.
    • Google Fonts: CSS from fonts.googleapis.com and font files from fonts.gstatic.com.
    • SVG backgrounds from /css/. Everything else is ordinary outbound links, which CSP doesn’t restrict.

    Then hash any inline scripts so you don’t need 'unsafe-inline' for scripts:

    python3 - <<'PY'
    import re, hashlib, base64
    html = open('page.html').read()
    for m in re.finditer(r'<script>(.*?)</script>', html, re.S):
        print("'sha256-" + base64.b64encode(hashlib.sha256(m.group(1).encode()).digest()).decode() + "'")
    PY

    Any edit to that inline script, even whitespace, changes the hash. Moving it to a file under /js/ is the lower-maintenance option.

  5. Step 5: Write the policy

    Start from default-src 'self' and open only what the inventory showed. Here’s the set for this site, in report-only form:

    <IfModule mod_headers.c>
    Header always set X-Content-Type-Options "nosniff"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"
    Header always set X-Frame-Options "SAMEORIGIN"
    Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
    Header always set Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'sha256-lX5OQqKOLEuX99W95cT5EIkhCdZg6DPg7binROxT90k='; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'"
    </IfModule>

    What each part does, per MDN:

    • script-src allows same-origin files plus the one hashed inline script. No 'unsafe-inline' and no 'unsafe-eval', which is where most of a CSP’s XSS protection comes from.
    • style-src still needs 'unsafe-inline' because of the inline <style> blocks. Moving them to CSS files would remove that.
    • frame-ancestors replaces the older X-Frame-Options. Sending both covers old browsers. Neither can be set in a <meta> tag.
    • object-src 'none' and base-uri 'self' close two classic injection tricks.
    • Permissions-Policy with () turns off features the site never uses.

    Apache’s Header always set also applies the header to error pages and redirects. LiteSpeed reads the same Header syntax from .htaccess. If your server already sets a header such as HSTS somewhere else, check the audit for duplicate lines after adding yours.

  6. Step 6: Prove it in a real browser, before it’s live

    Serve a copy of your pages with the header, e.g. a local Apache using the .htaccess above with Content-Security-Policy enforced, and let headless Chrome report violations:

    google-chrome --headless=new --no-sandbox --enable-logging=stderr --v=0 \
      --virtual-time-budget=5000 --dump-dom "https://example.com/" 2>&1 >/dev/null \
      | grep -iE 'Content Security|violates'
    
    # no output = no violations on that page

    The home page and a tutorial page both came back clean under the enforced policy. To make sure the test can actually catch a failure, break it on purpose: remove the hash and swap font-src to 'self'. You should get:

    "Executing inline script violates the following Content Security Policy directive 'script-src 'self''.
     Either the 'unsafe-inline' keyword, a hash ('sha256-lX5OQqKOLEuX99W95cT5EIkhCdZg6DPg7binROxT90k='), ..."
    "Loading the font 'https://fonts.gstatic.com/s/inter/v20/....woff2' violates the following
     Content Security Policy directive: "font-src 'self'". The action has been blocked."

    Chrome even prints the hash it expected, which is a handy way to fill in a missing one.

  7. Step 7: Roll out report-only, then enforce, then re-audit

    Put the header block in the live .htaccess as Content-Security-Policy-Report-Only. Browse every page type with DevTools open (Console tab). Report-only policies log violations without blocking anything. Once a few days pass with nothing logged, rename the header to Content-Security-Policy. Re-run the step 1 audit, check that nothing appears twice, and run curl -sI on a 404 and a redirect. Mozilla’s HTTP Observatory (on MDN) gives a second opinion.

Pro Tips

Tips

Security Headers // FAQ

FAQ

Is X-XSS-Protection worth adding?

MDN marks it as non-standard and deprecated and points to a Content-Security-Policy instead, so this guide leaves it out.

Can I set these in a tag instead?

Partly. A CSP can be delivered via <meta http-equiv>, but frame-ancestors and report-only don’t work that way, and HSTS must be a real HTTP header. Server headers are the complete option.

Will a CSP break Google Fonts or YouTube embeds?

Only if you leave them out. Fonts need style-src for fonts.googleapis.com and font-src for fonts.gstatic.com. An embedded player would need frame-src for its host. Plain links to YouTube aren’t affected.

Why “always” in Header always set?

Apache’s mod_headers docs explain that “always” headers are added even to non-2xx responses such as redirects and error pages. Without it, those responses can go out without your headers.

Should I preload HSTS?

For most small sites, no. hstspreload.org itself now recommends against it, removal takes a long time, and it commits every subdomain to HTTPS.

References // Sources

Sources

← All Tutorials Development tutorials