Matomo loads fine as a subdomain. Stick the same Matomo inside an iframe on another origin, and the first load fails. Every time. Refresh: works. Refresh again: still works. Close the tab, open fresh: fails again.
The failure isn't a 500. Isn't a CORS error. Isn't a content-security-policy block. It's an HTTP 499 - 'client closed connection' - recorded by the reverse proxy, with an origin response time of 6ms and a total duration of 30ms. The browser aborted the request 24ms after getting a clean response from the server.
This post is the diagnosis and the fix. The root cause lives in the intersection of iframe sandbox, cross-site cookie policy, and OIDC redirect chains.
The setup
I was embedding Matomo in my self-hosted portal . Parent origin: calegix.com. Iframe origin: analytics.calegix.com. Both auth-gated by the same oauth2-proxy + Keycloak realm, with session cookies scoped to .calegix.com so both origins see them.
The iframe tag itself:
<iframe
src="https://analytics.calegix.com/index.php?module=LoginOIDC&action=signin"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads allow-top-navigation-by-user-activation"
referrerpolicy="no-referrer-when-downgrade"></iframe> I deliberately pointed the src at Matomo's LoginOIDC signin action to skip the landing page and go straight to the SSO start. That felt clean. It was wrong.
The evidence
Traefik's structured access log gives a precise shape of the failure:
{"RequestPath":"/index.php?module=LoginOIDC&action=signin",
"DownstreamStatus":499,
"OriginStatus":302,
"Duration":30000000, // nanoseconds
"OriginDuration":6000000,
"RouterName":"matomo@file"} Breakdown:
- Matomo responded 302 in 6ms. It did its job.
- The browser closed the connection 24ms after the response headers arrived.
- The total Traefik-side duration was 30ms: a clean server-side transaction the client then aborted.
A successful run (after a single refresh) looks nothing like this:
{"DownstreamStatus":302,"OriginStatus":302,
"Duration":75000000,"OriginDuration":31000000} Same endpoint, same everything, no abort. The only variable between the two runs is browser state.
Why the abort happens
The iframe's redirect chain for this flow looks like this:
1. GET analytics.calegix.com/index.php?module=LoginOIDC&action=signin → 302 to auth.calegix.com
2. GET auth.calegix.com/realms/primary/.../auth → KC checks for session cookie (AUTH_SESSION_ID on auth.calegix.com) → if present, 302 back to Matomo callback
3. GET analytics.calegix.com/index.php?module=LoginOIDC&action=callback → Matomo sets its own session cookie, 302 to CoreHome
4. GET analytics.calegix.com/index.php?module=CoreHome → renders the UI
Step 2 is where first-load chokes. The iframe is inside a sandboxed frame on calegix.com. When the browser issues the request to auth.calegix.com, it's a cross-site request from the browser's cookie policy perspective - because the top-level document is calegix.com, not auth.calegix.com.
Modern Chromium ships with third-party cookie partitioning. Keycloak's AUTH_SESSION_ID, even marked SameSite=None and Secure, can be partitioned per top-level origin on first contact. The iframe's request arrives at KC without the session cookie KC expects. KC responds with a login page, not a 302.
KC's login page sets X-Frame-Options: SAMEORIGIN. The browser kills the frame. The original request - Matomo's /signin - is still pending at the reverse proxy when the navigation is torn down. Traefik records: 499.
On refresh, the partitioned cookie is now associated with the iframe origin pair, and the silent redirect works.
The fix
Don't point the iframe at a redirect endpoint. Point it at the app root and let the app's own redirect chain handle the OIDC dance internally:
<iframe src="https://analytics.calegix.com/"></iframe> When the browser lands on analytics.calegix.com/, Matomo sees no session and redirects to LoginOIDC. Crucially, this first redirect is same-origin (analytics.calegix.com → analytics.calegix.com). The cross-origin leg to auth.calegix.com happens only after the iframe has already committed its navigation, so partitioned cookies have a different lifecycle.
In the context of an Astro portal shell, that's a one-line change in the route that renders the iframe:
const EXTERNAL_SOURCES: Record<string, string> = {
// Was: /index.php?module=LoginOIDC&action=signin
// Now: let Matomo redirect itself internally.
matomo: "https://analytics.calegix.com/",
}; Why this teaches something
Every iframe-in-SSO debugging session I've done ends up at the same truth: iframe src should always be the destination URL the user ultimately wants to see, not an intermediate step. Browsers are increasingly aggressive about cross-site state, and each redirect hop is a chance for a policy to fire and kill the navigation.
Corollaries worth internalizing:
- If your app has an OIDC 'silent login' endpoint that you're tempted to iframe directly, test first-load behavior in a completely fresh Chromium profile. Incognito doesn't always reproduce third-party-cookie partitioning behavior.
- A 499 from a reverse proxy is always a client abort. If the origin took 6ms, the client aborted. Look at iframe sandbox, redirect chain, and Strict-Transport-Security before blaming the app.
- Pointing an iframe at an app root is also more tolerant of future-you changing the app's auth mechanism. You won't rewrite the iframe src when you migrate from OIDC to SAML.
Ops notes
Keep the 'works on refresh, fails on first load' pattern in your head. It's almost always a cookie state mismatch at the exact point a redirect crosses an origin boundary.
For any iframe you embed, grep the parent's Traefik log for DownstreamStatus:499 against the iframe's origin. If you see a tight distribution around 20-50ms Duration with near-zero OriginDuration, you have this bug.
frame-ancestors on the iframe target matters more than X-Frame-Options. Set both, and make sure the iframe's CSP explicitly allows your parent origin.
No comments yet