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.

Escalate max-age, Because Rollback Gets Harder Four rungs of a strict transport rollout. A five-minute lifetime lets you revert almost immediately. One week is long enough to expose subdomain problems while still recoverable. One year is the value required before submission. Preload is effectively permanent and should only follow a clean period at one year with every subdomain on HTTPS. max-age 300 — a five-minute commitment; a mistake expires before the incident call starts rollback: minutes max-age 604800 — one week; long enough for a full traffic cycle to surface HTTP-only assets rollback: one week max-age 31536000 with subdomains — the value required before any preload submission rollback: a year of clients preload — baked into browser builds; treat as permanent, not as a setting rollback: months, if at all

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.

Choosing a Framing Policy Without Breaking Partners Three options for framing policy. Denying all framing is correct for most application pages. Naming specific ancestors is required when a partner legitimately embeds the page, and only the modern directive can express more than one. During migration both headers ship together, with the modern directive taking precedence in browsers that understand it. Deny all framing Right for: dashboards, settings, anything with a state-changing control Cost: none, unless a team quietly embeds the page Audit referrers first Name the ancestors Right for: a page a named partner genuinely embeds The legacy header allows only one origin; the modern directive allows a list Review the list quarterly Ship both, for now Right for: a migration with old clients still in the mix Modern browsers honour the directive and ignore the legacy header entirely Keep both in agreement

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.