Configuring HSTS and X-Frame-Options for Legacy Apps

Missing transport security and framing controls in legacy codebases expose applications to protocol downgrade attacks, man-in-the-middle interception, and clickjacking. Legacy monoliths and older frameworks rarely enforce HTTP security headers by default, creating compliance gaps. This guide is a step-by-step implementation reference that sits within the Secure HTTP Header Configuration cluster, which itself is part of Vulnerability Patterns & Web Mitigation Strategies. For context on how these transport-layer controls interact with broader browser attack surfaces, see DOM-Based Vulnerability Sanitization.

Prerequisites

  • Root or sudo access to the reverse proxy (Nginx, Apache, or a CDN edge layer)
  • TLS certificate valid for all subdomains you intend to cover with includeSubDomains
  • An inventory of third-party services that embed your pages in iframes (e.g., widget vendors, analytics dashboards)
  • A staging environment that mirrors production traffic, including legacy user-agents
  • CI/CD pipeline with a pre-deploy stage where a shell script can gate the build

Expected Outcomes

  • Strict-Transport-Security sent on every HTTPS response with a max-age appropriate to the rollout phase
  • X-Frame-Options set to DENY or SAMEORIGIN on all endpoints, with Content-Security-Policy: frame-ancestors layered alongside it for modern browsers
  • An automated header validation script integrated into the CI/CD pipeline as a fail-fast gate
  • A documented rollback procedure and compliance evidence trail ready for SOC 2 and PCI-DSS auditors

Step 1: Audit Endpoints and Map Iframe Dependencies

Before touching a single header, you need a complete picture of what will break. Legacy applications tend to accumulate implicit framing dependencies — internal dashboards embedded in parent portals, third-party widgets that pull your pages into iframes, and support tools that render application screens inside their own UIs.

Run a crawl of your application to capture all <iframe src="..."> and <frame> references, then cross-reference that list against your own origin:

# Scan HTML responses for iframe references to your own origin
grep -rE '<iframe[^>]+src=["\x27][^"]+your-domain\.example' /path/to/html-responses/

Simultaneously, pull the list of public-facing endpoints that require authentication or execute state-changing actions — these are the highest-priority targets for DENY. Read-only pages that legitimately serve as embedded widgets are candidates for SAMEORIGIN rather than DENY.

Produce a two-column matrix before writing any configuration:

Endpoint pattern X-Frame-Options directive
/admin/*, /account/*, /transfer/* DENY
/dashboard/widget/*, /reports/embed/* SAMEORIGIN
/api/* (non-HTML responses) DENY

Document which subdomains exist under your root domain. Any subdomain not fully migrated to HTTPS must be resolved before you enable includeSubDomains on your HSTS header — otherwise browsers will refuse to load the non-HTTPS subdomain, with no recovery path short of the user clearing their HSTS cache.

Header enforcement flow: browser request through reverse proxy to legacy app and back Diagram showing a browser sending an HTTP request, a reverse proxy upgrading it to HTTPS, attaching HSTS and X-Frame-Options headers, forwarding to the legacy application, and returning the secured response to the browser. Browser (HSTS cache) Reverse Proxy Nginx / Apache / CDN Adds HSTS header Adds X-Frame-Options Legacy App (no header logic) HTTP → 301 HTTPS HTTPS response + security headers Proxy terminates TLS, injects headers, hides upstream response headers Browser caches HSTS policy; future HTTP requests are upgraded locally before leaving the device

Step 2: Deploy Progressive HSTS max-age Escalation

HSTS forces browsers to communicate exclusively over HTTPS, and the max-age directive determines how long that instruction is cached on the client. For legacy systems, setting a high max-age immediately risks irreversible lockout if mixed-content errors or misconfigured internal proxies surface after deployment. The safe approach is a three-phase escalation.

Phase 1 — Staging validation (max-age 5 minutes):

# /etc/nginx/conf.d/legacy-hsts.conf
map $http_user_agent $hsts_max_age {
    default  "300";
    ~*MSIE   "0";     # Suppress for IE11
    ~*Trident "0";    # Suppress for IE11 Trident engine
}

server {
    listen 443 ssl;
    server_name legacy-app.example.com;

    ssl_certificate     /etc/ssl/certs/legacy-app.crt;
    ssl_certificate_key /etc/ssl/private/legacy-app.key;

    # Phase 1: short max-age, no preload
    add_header Strict-Transport-Security "max-age=$hsts_max_age; includeSubDomains" always;

    # Prevent upstream application from emitting its own HSTS header
    proxy_hide_header Strict-Transport-Security;

    # Redirect any plain-HTTP ingress at the server block level
    error_page 497 https://$host$request_uri;
}

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

Run this configuration for at least 24 hours in staging while exercising all application flows with legacy user-agents including IE11, Android WebView, and any internal tooling that makes programmatic requests.

Phase 2 — Production promotion (max-age 30 days):

Once staging shows no mixed-content errors and no TLS handshake failures in error logs, change the default max-age to 2592000 (30 days). Deploy to production and monitor 403, 404, and connection-reset spikes in access logs over 48 hours.

Phase 3 — Full enforcement (max-age 1 year):

After 30 days of clean production metrics, set max-age=31536000. This is the minimum value required for HSTS preload list submission, but do not submit for preloading until:

  • All subdomains referenced in includeSubDomains serve valid HTTPS
  • Mixed-content assets (images, scripts, stylesheets served over http://) are fully resolved
  • The max-age=31536000 configuration has been stable in production for at least 90 days

Preload list inclusion is effectively irreversible without contacting browser vendors, which can take months. Treat it as a one-way door.

Internal proxy considerations: Legacy load balancers and caching layers frequently strip or override Strict-Transport-Security. Verify that your terminating proxy is the only layer adding the header and that upstream application servers are not emitting it separately with a shorter max-age. Use proxy_hide_header (Nginx) or RequestHeader unset (Apache) to suppress application-level HSTS before the proxy adds its own authoritative value.

If the legacy application generates absolute http:// URLs internally, deploy a response rewrite module or update the application’s base URL configuration to use relative paths — the HSTS header alone does not rewrite embedded URLs in HTML responses.

Step 3: Configure X-Frame-Options with CSP Fallback

X-Frame-Options controls whether a browser allows a page to be rendered inside a <frame>, <iframe>, <embed>, or <object>. The header predates the Content-Security-Policy frame-ancestors directive and remains the only framing control recognised by IE11 and older Android WebViews. Deploying both headers in parallel gives complete coverage across the browser spectrum.

Directive selection:

  • DENY — blocks all framing unconditionally. Use this on login pages, account management flows, admin panels, payment forms, and any endpoint that executes a privileged action.
  • SAMEORIGIN — permits framing only from the exact same origin (scheme + host + port). Use this where the application itself embeds its own pages in internal dashboards.
  • ALLOW-FROM — deprecated and unsupported in all modern browsers. Never use it. If you require cross-origin framing, implement explicit origin validation at the application layer and control it through frame-ancestors in a Content-Security-Policy header.

Apache mod_headers configuration:


    ServerName legacy-app.example.com

    # Load the headers module if not already enabled
    # LoadModule headers_module modules/mod_headers.so

    # Default: block framing on all responses
    Header always set X-Frame-Options "DENY"

    # Override for specific embed-friendly paths
    
        Header always set X-Frame-Options "SAMEORIGIN"
    

    # CSP frame-ancestors for modern browsers (takes precedence over X-Frame-Options)
    Header always set Content-Security-Policy "frame-ancestors 'self';"

    # Override CSP for embed paths to match X-Frame-Options intent
    
        Header always set Content-Security-Policy "frame-ancestors 'self';"
    

Browser compatibility reference:

Browser X-Frame-Options CSP frame-ancestors Precedence when both present
Chrome 70+ Supported Supported CSP wins
Firefox 65+ Supported Supported CSP wins
Safari 12.1+ Supported Supported CSP wins
IE11 Supported Ignored X-Frame-Options only
Android WebView < 4.4 Partial support Ignored X-Frame-Options only
Edge (Chromium) Supported Supported CSP wins

Because modern browsers give CSP frame-ancestors precedence, ensure the two headers never contradict each other. If X-Frame-Options says SAMEORIGIN but CSP says frame-ancestors 'none', modern browsers will refuse framing (CSP wins) while IE11 will permit it from the same origin (X-Frame-Options wins). Align them intentionally, not accidentally.

For the cross-site request forgery defense perspective: framing controls are a complementary layer alongside CSRF tokens, not a replacement. An <iframe> that loads a form page cannot typically read the CSRF token from the parent, but clickjacking exploits user gestures rather than token theft — so both controls are necessary.

Verification

After deploying headers, confirm correct behaviour using curl against your staging environment before promoting to production:

#!/usr/bin/env bash
# verify-headers.sh — run against staging URL before production promotion
set -euo pipefail

TARGET="${1:?Usage: $0 <https://staging-url>}"
FAIL=0

response=$(curl -sI -L --max-time 10 "$TARGET")

# HSTS check
hsts=$(echo "$response" | grep -i "^strict-transport-security:" || true)
if [[ -z "$hsts" ]]; then
    echo "FAIL  Strict-Transport-Security header is absent"
    FAIL=1
elif ! echo "$hsts" | grep -qi "max-age=[0-9]"; then
    echo "FAIL  HSTS max-age directive is missing or malformed: $hsts"
    FAIL=1
else
    echo "PASS  HSTS: $hsts"
fi

# X-Frame-Options check
xfo=$(echo "$response" | grep -i "^x-frame-options:" || true)
if [[ -z "$xfo" ]]; then
    echo "FAIL  X-Frame-Options header is absent"
    FAIL=1
elif ! echo "$xfo" | grep -iEq "(DENY|SAMEORIGIN)"; then
    echo "FAIL  X-Frame-Options value unexpected: $xfo"
    FAIL=1
else
    echo "PASS  X-Frame-Options: $xfo"
fi

# CSP frame-ancestors check
csp=$(echo "$response" | grep -i "^content-security-policy:" || true)
if ! echo "$csp" | grep -qi "frame-ancestors"; then
    echo "WARN  Content-Security-Policy frame-ancestors directive absent (required for modern browsers)"
fi

exit $FAIL

Integrate this script as a mandatory pipeline gate:

# .github/workflows/security-headers.yml
name: Security Header Validation
on:
  push:
    branches: [staging, main]
  schedule:
    - cron: "0 2 * * *"   # Daily compliance drift check

jobs:
  validate-headers:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Validate security headers on staging
        run: |
          chmod +x ./scripts/verify-headers.sh
          ./scripts/verify-headers.sh "https://staging.legacy-app.example.com"

      - name: Upload validation log
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: header-validation-${{ github.run_id }}
          path: /tmp/header-check.log
          retention-days: 90

Set the daily cron scan to detect configuration drift caused by infrastructure-as-code changes or proxy updates that inadvertently remove headers. Store validation artifacts for 90 days minimum to satisfy audit evidence requirements.

Compliance evidence produced by this setup:

Framework Control Satisfied By
PCI-DSS v4 Req 6.2.4 (secure communications) HSTS header + CI validation logs
SOC 2 CC6.1 (system boundary enforcement) Automated scan reports, versioned proxy config
OWASP ASVS v4 14.4.3 (clickjacking defense) X-Frame-Options + CSP frame-ancestors
OWASP ASVS v4 9.1.1 (TLS enforcement) HSTS + HTTP→HTTPS redirect

Troubleshooting

HSTS header absent on some responses but present on others : Most commonly caused by the legacy application emitting its own HSTS header that conflicts with the proxy value, or by error responses (400, 500) being served by a different code path that bypasses the proxy middleware. Use add_header ... always (Nginx) or Header always set (Apache) to attach the header to all response codes including errors. Add proxy_hide_header Strict-Transport-Security to suppress the upstream application’s own value.

Browsers report mixed-content warnings after enabling HSTS : The application is generating absolute http:// URLs for assets (images, scripts, fonts). HSTS upgrades the initial page request but cannot rewrite inline URLs in the HTML body. Fix the application’s base URL configuration to use https:// or relative paths, or deploy a response rewrite module (nginx_substitutions_filter, Apache mod_substitute) as a temporary workaround while the application is patched.

X-Frame-Options SAMEORIGIN blocks expected internal embeds : Verify that the embedding page and the framed page share the exact same origin (scheme, host, and port number all match). A mismatch on any component — such as https://admin.example.com trying to frame https://app.example.com — causes SAMEORIGIN to block. If cross-subdomain framing is genuinely required, replace X-Frame-Options with Content-Security-Policy: frame-ancestors https://admin.example.com; and apply it only to the endpoints that need it.

IE11 users report blank white frames or framing errors : IE11 does not support Content-Security-Policy: frame-ancestors. Confirm that X-Frame-Options is set correctly and that ALLOW-FROM is not present (it is ignored by all modern browsers and inconsistently parsed by IE11). For IE11-critical embed scenarios, SAMEORIGIN is the only safe directive; DENY will block all framing including same-origin for some IE11 versions.

Pipeline gate reports header missing on redirect responses : The curl -L flag follows redirects, so the header check runs against the final destination response. If the redirect itself (301/302) is what you expect to carry HSTS, remove -L from the curl command and check the redirect response directly. HSTS on redirect responses is valid and recommended — it ensures even the redirect instructs the browser to pin the HTTPS preference.