ObservabilityLXIV · TLS MonitoringTLSMonitoring
Mixed Content Detection
What you'll learn
- Recognise mixed-content violations in browser DevTools and CSP reports
- Distinguish active mixed content (blocked) from passive mixed content (was allowed, now blocked)
- Configure CSP directives that prevent mixed content at the browser layer
- Build a synthetic monitor that loads HTTPS pages with a headless browser and reports mixed content
Prerequisites
Verified against Prometheus 2.55.x · Alertmanager 0.28.x · node_exporter 1.8.x · blackbox_exporter 0.26.x · Grafana 11.x · Loki 3.x · Tempo current · OpenTelemetry Collector 0.110.x · Grafana Alloy current · Docker Engine 28.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-13
A customer opened a support ticket with a screenshot of the
browser console. The page loaded over HTTPS but every JavaScript
file was failing to load. The error was Mixed Content: The page at 'https://app.example.com/' was loaded over HTTPS, but requested an insecure script 'http://cdn.legacy.example.com/legacy.js'. This request has been blocked; the content must be served over HTTPS.
The application team looked at the source. The HTML template
contained a hardcoded http:// reference to a CDN that had
been migrated to HTTPS years ago. The CDN still served the
content over HTTP for legacy clients; the new domain was the
same content under HTTPS. Nobody had updated the template in
six years.
The page was functionally broken. The error appeared in console but not in the network panel as a failed HTTP request (because the browser blocked it before the request was sent). Server-side monitoring showed green: the HTTPS page returned 200; the HTTP resource was not requested at all.
That incident is the reason this lesson exists.
What it is
Mixed content is the condition that arises when an HTTPS page loads a sub-resource over HTTP. The TLS session is established between the browser and the page’s origin, but the sub-resource traverses an unencrypted connection. An attacker on the network path can read or modify the sub-resource without breaking the TLS session to the page.
The W3C Mixed Content Level 2 specification defines two categories:
- Active mixed content. Scripts, stylesheets, iframes, fonts, XHR/fetch requests, WebSocket connections. Historically blocked by default by every browser; the W3C spec formally classifies these as “blockable.”
- Passive mixed content. Images, video, audio. Historically allowed with a console warning; since Chrome 86 (2020) and the equivalent Firefox/Safari updates, these are now also blocked by default in most contexts.
The browser’s behaviour when it encounters mixed content:
Browser loads https://app.example.com/ (TLS negotiated)
|
+-- discovers <img src="http://cdn.example.com/foo.jpg">
|
v
Does the CSP header include block-all-mixed-content?
|
+-- yes: block the request, do not send
|
+-- no: send the request, log a console warning
| (and block it if passive-mixed-content is
| also now blocked)
|
v
Console:
Mixed Content: The page at 'https://app.example.com/'
was loaded over HTTPS, but requested an insecure
image 'http://cdn.example.com/foo.jpg'.
The warning is the visible signal. The block is the user-visible
behaviour. Modern browsers also send a CSP report (if the page
declares a report-uri or report-to directive) so the server
team can see the violation in their logs.
Why a sysadmin cares
Mixed content is one of the few TLS-layer failures that escapes server-side monitoring. The TLS handshake to the page succeeds. The HTTP request to the sub-resource never reaches the server (because the browser blocks it before sending). The server sees no failed requests, no elevated error rate, no anomaly. The user sees a broken page.
The cost is real and asymmetric:
- It is silent at the server. Standard Prometheus blackbox probes, expiry metrics, and handshake metrics (lessons 01-04) all report green. The failure is between the browser and the sub-resource, which the server never sees.
- It is loud to the user. The console fills with red errors. The page may render incorrectly (missing stylesheets, broken JavaScript). The user blames the application, not the underlying transport.
- It is easy to introduce. A legacy CDN reference, a
template that hardcodes
http://, a third-party tracker added by marketing. Each one is a single line of HTML.
The detection mechanisms are also asymmetric: the server cannot detect it directly; only the browser can. So the monitoring must also be browser-side: synthetic monitors that load the page with a headless browser and report mixed content warnings.
How it works
The detection mechanisms, layered from least to most authoritative:
Layer 1: Browser DevTools
- Console warning on every mixed-content request.
- Network panel marks the request as "blocked".
- Manual review only; not automatable at scale.
Layer 2: Content Security Policy (CSP)
- Server emits a CSP header with block-all-mixed-content.
- Browser blocks the request before sending.
- Browser sends a CSP report to the report-uri if declared.
- Server team ingests the CSP reports and counts violations.
Layer 3: Synthetic monitor
- Headless browser (Chrome via Puppeteer, Playwright) loads
the page on a schedule.
- Reads the console messages; counts mixed-content warnings.
- Pushes a counter to Prometheus.
Layer 4: Real User Monitoring (RUM)
- Browser SDK in the page reports CSP violations as they
happen.
- Aggregated to a counter in Prometheus or a SaaS RUM
backend.
A production-grade implementation uses layers 2 and 3. Layer 1 is for development. Layer 4 is the gold standard but requires JS in the page.
The browser-side detection is:
page URL: https://app.example.com/
response headers:
Content-Security-Policy: default-src 'self';
block-all-mixed-content;
upgrade-insecure-requests;
report-uri /csp-report
response body contains:
<img src="http://cdn.legacy.example.com/banner.jpg">
<script src="http://cdn.legacy.example.com/legacy.js"></script>
browser behaviour:
1. img: passive mixed content. Blocked (Chrome 86+).
2. script: active mixed content. Blocked unconditionally.
3. CSP violation: report sent to /csp-report.
4. Console: two Mixed Content warnings.
The browser sends a POST to /csp-report with a JSON body
describing the violation. The server team ingests these into
Loki or a dedicated CSP-report endpoint.
Under the hood
How to configure it
Three pieces: the CSP header on the origin server, the CSP report ingestion, and the synthetic monitor.
The CSP header
nginx (/etc/nginx/conf.d/csp.conf):
# Block all mixed content. Upgrade http:// to https://.
# Report violations to the CSP endpoint.
add_header Content-Security-Policy "
default-src 'self';
block-all-mixed-content;
upgrade-insecure-requests;
report-uri /csp-report;
report-to csp-endpoint;
" always;
add_header Report-To '{"group":"csp-endpoint","max_age":10886400,"endpoints":[{"url":"/csp-report"}]}' always;
HAProxy (/etc/haproxy/haproxy.cfg):
http-response set-header Content-Security-Policy "default-src 'self'; block-all-mixed-content; upgrade-insecure-requests; report-uri /csp-report"
The upgrade-insecure-requests directive is the primary defence:
the browser rewrites http:// to https:// and the resource
loads successfully. The block-all-mixed-content directive is
the fallback: if upgrade-insecure-requests cannot find an
HTTPS equivalent, the resource is blocked. The combination is
the modern recommendation from the W3C.
The CSP report endpoint
The simplest endpoint accepts the POST and logs the JSON body. A 5-line nginx config:
location = /csp-report {
if ($request_method != POST) { return 405; }
access_log /var/log/nginx/csp-report.log json_combined;
proxy_pass http://localhost:5000/csp-report;
}
The upstream service parses the JSON and pushes a metric:
# Pseudocode for the CSP endpoint
@app.post("/csp-report")
async def csp_report(request: Request):
body = await request.json()
report = body.get("csp-report", {})
blocked_uri = report.get("blocked-uri", "unknown")
directive = report.get("violated-directive", "unknown")
csp_violations_total.labels(
page=report.get("document-uri", "unknown"),
directive=directive,
blocked_uri=blocked_uri,
).inc()
return Response(status_code=204)
The Prometheus counter csp_violations_total is the metric
the alert watches.
The synthetic monitor
A Puppeteer-based monitor that loads the page and counts mixed-content warnings:
// /opt/monitors/mixed-content.js
const puppeteer = require('puppeteer');
const { collectDefaultMetrics, Counter, Registry } = require('prom-client');
const registry = new Registry();
collectDefaultMetrics({ register: registry });
const violations = new Counter({
name: 'mixed_content_violations_total',
help: 'Number of mixed-content violations detected on a page load',
labelNames: ['page', 'directive', 'blocked_uri'],
registers: [registry],
});
const PAGES = [
'https://app.example.com/',
'https://app.example.com/checkout',
'https://app.example.com/account',
];
async function check(page) {
const browser = await puppeteer.launch();
const p = await browser.newPage();
const consoleMessages = [];
p.on('console', (msg) => consoleMessages.push(msg.text()));
await p.goto(page, { waitUntil: 'networkidle0', timeout: 30000 });
await browser.close();
for (const text of consoleMessages) {
if (text.includes('Mixed Content:')) {
const match = text.match(/'(http[^']+)'/);
const blocked = match ? match[1] : 'unknown';
violations.labels(page, 'mixed-content', blocked).inc();
}
}
}
async function loop() {
for (const page of PAGES) {
try {
await check(page);
} catch (e) {
console.error(`failed to check ${page}: ${e}`);
}
}
// expose metrics
const express = require('express');
const app = express();
app.get('/metrics', async (_req, res) => {
res.set('Content-Type', registry.contentType);
res.end(await registry.metrics());
});
app.listen(9100);
}
setInterval(loop, 5 * 60 * 1000);
loop();
This runs as a long-lived service, exposes /metrics on port
9100, and counts mixed-content warnings per page per 5-minute
window.
The alert
groups:
- name: mixed_content_alerts
interval: 5m
rules:
- alert: MixedContentViolations
expr: >
sum by (page) (
rate(csp_violations_total[1h])
) > 0
for: 30m
labels:
severity: warning
category: tls
annotations:
summary: 'Mixed content violations detected on {{ $labels.page }}'
description: '{{ $value }} mixed-content violations per second over the last hour on {{ $labels.page }}. Investigate the blocked_uri label to find the offending resource.'
runbook_url: 'https://runbooks.example.com/tls/mixed-content'
How to validate it
Three checks: confirm the CSP header is emitted, trigger a violation, and inspect the synthetic monitor output.
Confirm the CSP header:
curl -sI https://app.example.com/ | grep -i content-security-policy
Realistic output:
Content-Security-Policy: default-src 'self'; block-all-mixed-content; upgrade-insecure-requests; report-uri /csp-report; report-to csp-endpoint
Trigger a violation by adding a <img src="http://..."> tag
to a staging page. Open the page in Chrome and watch the
console:
Mixed Content: The page at 'https://staging.example.com/'
was loaded over HTTPS, but requested an insecure image
'http://httpbin.org/image/png'. This request has been
blocked; the content must be served over HTTPS.
The httpbin.org reference is a useful test target; it serves
content over both HTTP and HTTPS, so you can confirm the
browser behaviour on a known source.
Inspect the synthetic monitor:
curl -s http://monitor.example.com:9100/metrics | grep mixed_content
Realistic output:
mixed_content_violations_total{page="https://app.example.com/",directive="mixed-content",blocked_uri="http://cdn.legacy.example.com/banner.jpg"} 3
The counter has incremented three times (one per 5-minute
scrape). The blocked_uri label identifies the offending
resource.
In the CSP report stream (Loki):
{job="csp-report"} | json | violated_directive="block-all-mixed-content"
This returns every CSP violation report received in the
selected time range. The blocked_uri field tells you which
resource was blocked.
How it can fail
Five failure modes appear in production.
-
The CSP header is set but
report-uripoints to a non-existent endpoint. The browser sends the report; the server returns 404; the violation is logged nowhere. Symptom: the alert never fires because no reports reach the metric endpoint. -
upgrade-insecure-requestsis set but the target does not support HTTPS. The browser trieshttps://, fails, and blocks the resource. Symptom: pages render without critical assets that were loaded from HTTP-only CDNs. The fix is to migrate the resource to HTTPS or remove the reference. -
The CSP header is set on the application but not on the CDN that serves static assets. The CDN serves with its own cache-control and no CSP. If the page includes an
<iframe>to the CDN, the iframe is treated as a separate document and the CSP does not apply. Symptom: mixed content violations in iframes appear without CSP reports. -
The synthetic monitor loads a page that requires authentication. The monitor logs in once, then keeps a session, but the session cookie expires. Symptom: the monitor loads the login page (no mixed content) and reports zero violations for an application that has them.
-
Third-party scripts introduce mixed content. Marketing adds a tracking pixel from a vendor that has not migrated to HTTPS. The application team cannot control the third party; the CSP report shows the violation but the fix is external. Symptom: persistent violations with no internal resolution path.
How to troubleshoot it
The diagnostic order for “the alert fires”:
- Inspect the CSP report stream. The
blocked_urilabel is the answer. Most mixed-content violations are caused by one of: a hardcodedhttp://in a template, a legacy CDN reference, a third-party tracker. - Search the codebase.
grep -r 'http://' src/ | grep -v 'https://'— every literalhttp://in the source is a candidate. (Ignore XML namespace URIs and similar.) - Check the third-party list. If the violation is for a third-party domain, contact the vendor. Many have an HTTPS migration already in progress; the CSP report is the signal that triggers the migration.
- Confirm the CDN supports HTTPS. For first-party CDN
references, verify the CDN origin is reachable over HTTPS.
curl -I https://cdn.example.com/asset.jsshould return 200. If it returns 404 or refuses, the resource must be re-uploaded to the HTTPS endpoint. - Confirm the CSP header is on every response. Some CDN configs strip CSP headers from cached responses. Check the response from a CDN edge, not just the origin.
- Re-test in the browser. After the fix, open the page in Chrome with DevTools open, reload, and confirm the console is clean.
Security implications
Mixed content is a security vulnerability, not just a UX issue:
- Passive mixed content can be modified in transit. A
network attacker can replace
banner.jpgwith an image that exfiltrates information or carries a phishing payload. The TLS session to the page does not protect the resource. - Active mixed content can be modified to execute
arbitrary code. A modified
legacy.jsruns in the page’s origin and has access to cookies, local storage, and the DOM. This is the same security context as the page itself. - Mixed content defeats HSTS. HSTS upgrades
http://tohttps://at the browser level, but only for the page request. Sub-resources are still subject to mixed-content rules.
The CSP header is the defence. upgrade-insecure-requests
makes the violations disappear (the resource loads over HTTPS).
block-all-mixed-content makes them hard failures (the
resource is blocked). The combination is the W3C-recommended
baseline.
The synthetic monitor is itself a security control: a production page that silently regresses to mixed content is a regression that no other layer of the observability stack catches. The monitor is the canary.
Performance implications
The CSP header itself is cheap: a few hundred bytes per
response, parsed once by the browser. The report-uri POST
adds a request per violation; in production, a single violating
resource may produce one report per page load, which is a few
hundred requests per hour per affected user. The report
endpoint must handle this rate; a 1kB POST is trivial for any
HTTP server.
The synthetic monitor is the dominant cost:
- A single page load via headless Chrome: 1-3 seconds (startup + render).
- 50 pages, 5-minute interval: ~600 page loads per hour.
- CPU on the monitor host: ~10-20 percent of one core.
- Memory: ~200-400 MiB resident (one headless Chrome per concurrent page).
For larger inventories, parallelise across multiple monitor hosts or use a SaaS synthetic monitoring service.
How to roll this back
Removing the CSP header is a configuration change.
- Remove the
add_header Content-Security-Policy ...directive from nginx (or equivalent from HAProxy/Envoy). nginx -tto validate.nginx -s reload.- Confirm with
curl -sI https://app.example.com/ | grep -i content-security-policy— should return empty. - Disable the alert in Prometheus (or leave it active to catch regressions in the future).
The synthetic monitor can stay; it will report zero violations once the CSP header is removed and the page loads without browser-side enforcement. The CSP report endpoint can stay or be decommissioned.
The rollback does not affect the certificate or the TLS configuration. It is a pure HTTP-header change.
Verification
You should now be able to answer:
- What is the difference between active and passive mixed content, and how does modern browser behaviour treat each?
- What does the CSP
upgrade-insecure-requestsdirective do, and why is it paired withblock-all-mixed-content? - Why is server-side monitoring insufficient for detecting mixed content, and what fills the gap?
- How does a synthetic monitor count mixed-content violations from a headless browser?
- What is the most common production cause of mixed content, and how do you find it in the codebase?
Quiz
Knowledge check · 8 questions
Q1. What is mixed content?
Q2. Active mixed content (scripts, stylesheets, iframes) is blocked by browsers by default; passive mixed content (images, video) was historically allowed but is now blocked in most browsers.
Q3. What does the CSP directive upgrade-insecure-requests do?
Q4. Which of these are valid detection mechanisms for mixed content in production?
Q5. What is the most common production cause of mixed content, and what is the first thing to search the codebase for?
Q6. The CSP report stream shows a violation for a third-party tracker that the marketing team added. What is the appropriate response?
Q7. A reverse proxy terminating TLS and forwarding to an HTTP backend can produce mixed content even though the user only sees the HTTPS URL.
Q8. Which of these are signs that the CSP header is configured but not enforced?
Passing score: 75%. Answers are checked in this browser.