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-Securitysent on every HTTPS response with amax-ageappropriate to the rollout phaseX-Frame-Optionsset toDENYorSAMEORIGINon all endpoints, withContent-Security-Policy: frame-ancestorslayered 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.
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
includeSubDomainsserve valid HTTPS - Mixed-content assets (images, scripts, stylesheets served over
http://) are fully resolved - The
max-age=31536000configuration 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 throughframe-ancestorsin aContent-Security-Policyheader.
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.
Related
- Secure HTTP Header Configuration — parent cluster covering the full range of security-relevant response headers
- Cross-Site Request Forgery (CSRF) Defense — framing controls and CSRF tokens are complementary; read both together
- DOM-Based Vulnerability Sanitization — browser-side attack surface that overlaps with clickjacking threat scenarios
- Vulnerability Patterns & Web Mitigation Strategies — grandparent section covering the full web vulnerability mitigation landscape