Data chart on a dark analytics dashboard

Self-Sovereign Analytics: Running Matomo Behind Keycloak OIDC

GA4's default data retention is 2 months. Matomo with LoginOIDC and a pre-seeded plugin setting table gets you analytics you actually own.

Google Analytics 4's default data retention is two months. Two. Months. You can extend it to 14 months by reconfiguring, but longer history is gone forever - and GA4 cannot be audited for GDPR-subject-access-request purposes in any way that satisfies a thorough DPO.

For the insurance industry specifically - where I work a day job - this is already a compliance headache and within a year will be an existential one. Self-sovereign analytics, hosted by the company that owns the data, is not a 'nice to have.' It's the only path.

Matomo is the mature open-source answer. This post is the full configuration to run it behind Keycloak OIDC with silent login, embed it in a parent portal via iframe, and avoid the three non-obvious configuration traps that bite most people on the way.

The destination

- Matomo on its own subdomain: analytics.calegix.com.

- Keycloak realm 'primary' with a matomo OIDC client.

- LoginOIDC plugin enabled in Matomo, configured to silently authenticate against KC.

- Iframe embedding on the parent portal (calegix.com/app/matomo) with no first-load blank screen.

Step 1: matomo_plugin_setting seeding

LoginOIDC is configured via rows in matomo_plugin_setting. The Matomo admin UI exposes most of these, but several are either undocumented or have defaults that break cross-origin iframe embedding. Here's the complete seed set:

INSERT INTO matomo_plugin_setting (plugin_name, setting_name, setting_value, user_login) VALUES
  ('LoginOIDC', 'authorizeUrl', 'https://auth.calegix.com/realms/primary/protocol/openid-connect/auth', NULL),
  ('LoginOIDC', 'tokenUrl', 'https://auth.calegix.com/realms/primary/protocol/openid-connect/token', NULL),
  ('LoginOIDC', 'userinfoUrl', 'https://auth.calegix.com/realms/primary/protocol/openid-connect/userinfo', NULL),
  ('LoginOIDC', 'clientId', 'matomo', NULL),
  ('LoginOIDC', 'clientSecret', '<redacted>', NULL),
  ('LoginOIDC', 'scope', 'openid email profile', NULL),
  ('LoginOIDC', 'userinfoId', 'sub', NULL),
  ('LoginOIDC', 'autoLinking', '1', NULL),
  ('LoginOIDC', 'allowSignup', '1', NULL),
  ('LoginOIDC', 'disableDirectLoginUrl', '0', NULL),
  ('LoginOIDC', 'redirectUriOverride',
     'https://analytics.calegix.com/index.php?module=LoginOIDC&action=callback&provider=oidc', NULL);

Two of these will bite you if you get them wrong.

redirectUriOverride must include &provider=oidc

The LoginOIDC callback handler reads the 'provider' query parameter. If it's missing, you get:

LoginOIDC_MethodNotAllowed: parameter 'provider' isn't set

The default redirect URI Matomo builds does not include this parameter. You must override with the full URL including &provider=oidc. Every debugging attempt until I figured this out ended in a 400 on callback.

disableDirectLoginUrl=0 (yes, zero)

Counterintuitively: disableDirectLoginUrl=0 does not mean 'direct login is disabled.' It means 'the direct-login button is enabled.' Set this to 1 if you want to force OIDC. For iframe embedding where we want the iframe to land users directly into Matomo without a login picker, set this to 0 so the LoginOIDC plugin's redirect intercept fires automatically.

Step 2: pre-link the KC subject to the Matomo superuser

This is the one that wasted my most time.

First-time OIDC login for an existing Matomo user gets rejected with:

Login failed: Username already used as email

Matomo's LoginOIDC plugin creates a new Matomo user account on OIDC first-login and can't handle the case where the OIDC email matches an existing Matomo user's email. It sees the existing account, tries to link, and rejects the link because the existing account's username is the same as the email.

Solution: pre-link the existing superuser to the OIDC subject before the first login attempt.

-- Find the KC 'sub' for your admin user (from KC admin UI or token introspection)
-- Example: 413404c0-...-...

INSERT INTO matomo_loginoidc_provider
  (user, provider_user, provider, date_connected)
VALUES
  ('natej', '413404c0-...-...', 'oidc', NOW());

After this row exists, the first OIDC login for natej@calegix.net sees the pre-existing link, authenticates against it, and drops you into Matomo as the superuser. No 'Username already used' error. No auto-created second account.

This is the kind of thing you only hit once per Matomo install, but it derails a deployment for an afternoon if you don't know to pre-seed.

Step 3: iframe-friendly config

Matomo defaults to hostile-to-iframe behavior. Three settings fix that:

# config.ini.php

[General]
enable_framed_pages = 1
enable_framed_settings = 1
assume_secure_protocol = 1

[proxy]
proxy_client_headers[] = HTTP_X_FORWARDED_FOR
proxy_host_headers[] = HTTP_X_FORWARDED_HOST

enable_framed_pages disables Matomo's own X-Frame-Options: DENY header. Without this, the browser refuses to render Matomo inside any iframe regardless of what the parent says.

enable_framed_settings extends the 'OK to frame' permission to admin/settings pages (default is only the dashboard).

assume_secure_protocol is needed because Matomo is behind Traefik-terminated TLS. It trusts the X-Forwarded-Proto: https header that Traefik injects. Without it, Matomo sees HTTP internally and generates http:// URLs in HTML, which break on an https:// parent.

Step 4: disable the JS frame-buster

Matomo ships a JavaScript frame-buster in plugins/Morpheus/templates/_iframeBuster.twig. It detects iframe embedding and reloads the top window to break out. This fires before you see any page content, so even with headers correctly configured, your iframe will appear to load and then immediately go blank as the frame-buster redirects the parent.

The fix: comment out or empty the frame-buster:

{# plugins/Morpheus/templates/_iframeBuster.twig - neutered for portal embedding #}
{# Original contents disabled to support self-hosted portal iframes. #}
<!-- iframe buster disabled -->

Better: upstream a PR that makes this opt-in via a config flag. Until then, patch the template file and document the patch in your infrastructure's runbook.

Step 5: PHP session cookie SameSite

The last trap. Matomo stores its session in a PHP cookie. PHP's default SameSite is Lax (as of 7.3+). For a parent-origin iframe where the iframe is on a different sub-domain than the parent, Lax cookies don't ride the cross-site requests an iframe makes to its own origin.

Override PHP's cookie settings via php.ini injection:

# /usr/local/etc/php/conf.d/zz-calegix-samesite.ini
; Cross-site iframe support for portal-embedded Matomo
session.cookie_samesite = 'None'
session.cookie_secure = On
; SameSite=None is invalid without Secure - browsers reject the cookie
; silently if you forget the second line.

Restart Apache / PHP-FPM. Matomo's session cookies now ride cross-site requests within the iframe. Without this, every click in the embedded Matomo logs you out.

Step 6: the iframe src choice

Covered in a separate post , but the TL;DR: point your iframe at https://analytics.calegix.com/ - the root - not at the LoginOIDC signin action directly. Matomo's internal redirect to LoginOIDC is same-origin and doesn't trigger the browser's third-party cookie partitioning race that a direct cross-origin redirect would.

<!-- Good -->
<iframe src="https://analytics.calegix.com/"></iframe>

<!-- Bad: 499 on first load, works on refresh -->
<iframe src="https://analytics.calegix.com/index.php?module=LoginOIDC&action=signin"></iframe>

What this gets you

A self-hosted, OIDC-authenticated, iframe-embeddable analytics stack that your company fully controls.

- Data retention: whatever your DB retention is. Forever if you want.

- GDPR subject-access-requests: SELECT * FROM matomo_log_visit WHERE... Real answers to real auditor questions.

- Compliance posture: the data is on infrastructure you own. No third-party processor to explain to every client.

- Cost: one DB, one PHP container. Compared to enterprise GA360 ($150K+/year), it pays for itself in the first month.

For the insurance / regulated industry reader

If you're in insurance, finance, healthcare - any industry where DPOs and auditors actually care - this stack answers their questions cleanly. Every question an auditor asks about GA4 ('where's the data,' 'how long is it retained,' 'who has access,' 'can you produce an export for subject X') has an answer that's a psql query away for Matomo.

The operational burden is measured. One container restart a quarter. One version upgrade a year. You're not running a second product - you're running a standard LAMP-ish web app next to your existing infrastructure.

For Azure-shop readers: everything above runs cleanly on Azure Container Apps or AKS. Matomo has official Docker images; KC has a well-supported Helm chart; oauth2-proxy too. The patterns are cloud-portable.

Closing

Run your analytics on infrastructure you own. The one-time configuration pain is genuinely small - a weekend if you follow the steps above. The long-term benefit - auditability, control, continuity, cost - is structural.

GA4 is a marketing product. Matomo is an infrastructure product. Pick the one that matches how you think about your own data.

Related posts

No comments yet