Portal Embed Security — This guide exists to avoid iframe regressions when adding new items to the Portal. ⬆ índice
Portal Embed Security
This guide exists to avoid iframe regressions when adding new items to the Portal.
Problem model
When a Portal item is rendered inside an iframe, all of these layers must allow embedding:
- Portal CSP must allow the target origin in
frame-src. - The embedded app must allow being framed with
Content-Security-Policy: frame-ancestors ...and/or a compatibleX-Frame-Options. - The public reverse proxy must not overwrite the embedded app headers with
X-Frame-Options: DENY. - Any SSO or redirect hop must also be allowlisted.
If one layer blocks, the browser shows a generic frame error and Portal-side postMessage will fail with origin null.
Golden rules
- Prefer same-origin embedding when a Portal rewrite already exists.
- If the app is cross-origin, allow the public origin, not
localhost. - Treat auth providers and redirect domains as first-class iframe origins.
- Verify the final public URL with
curl -I; do not trust container config alone. - Fix the lowest blocking layer first.
Required changes for a new Portal item
1. Register the public endpoint
The remote endpoint must resolve to its real public hostname.
Examples:
REMOTE_ENDPOINT_DOMAIN_<KEY>=<public-hostname>
REMOTE_ENDPOINT_CERT_<KEY>=true
If the endpoint metadata is wrong, Portal will usually build an allowlist with localhost, which is invalid for remote iframe usage.
2. Allow the origin in Portal UI CSP
Portal CSP is built in:
platform/portal/ui/src/middleware.ts
Add the new public URL env to the frame source builder if it is a direct iframe target.
Also include any auth/redirect origins through:
ZITADEL_ISSUERZITADEL_ACCOUNT_URLPORTAL_IFRAME_EXTRA_ORIGINS
Important:
- the effective allowlist is assembled in middleware from env vars such as
DOCS_URL,CUSTOMER_SERVICE_URL,ZITADEL_ISSUER,ZITADEL_ACCOUNT_URL, andPORTAL_IFRAME_EXTRA_ORIGINS - those envs are injected into the Portal image at build/release time
- a restart is not enough
portal-uimust be rebuilt/released
3. Sync the remote URL into Portal deploy env
Remote Portal deploy env is assembled in:
platform/portal/ansible.remote.yml
Add the endpoint lookup and sync the resulting URL into the Portal env if missing.
Typical keys:
- { key: <APP>_URL, value: "{{ ('https://' if (...) else 'http://') ~ (...) }}" }
If the app may redirect to a different public host, include that host in PORTAL_IFRAME_EXTRA_ORIGINS.
4. Allow embedding in Traefik
Public reverse-proxy framing behavior is generated in:
deployment/scripts/python/render_traefik_config.py
By default, proxied apps get deny-frame.
If the app must be embeddable inside Portal, it must use portal-embed:
if endpoint["key"] in {"THINGSBOARD", "PORTAINER", "N8N", "EVOLUTION", "INFLUXDB", "DOCS", "CHATWOOT"}:
frame_header_mode = "portal-embed"
If this step is skipped, the public site may still return:
X-Frame-Options: DENY
even when the app container itself is configured correctly.
5. Allow embedding in the app itself
The embedded app must not deny framing.
Typical fixes:
- Remove or override
X-Frame-Options: DENY - Set a compatible
Content-Security-Policy: frame-ancestors ... - If a local nginx sits in front of the app, make sure it does not reintroduce a deny policy
Important:
- validate the final public URL, not only container-local headers
- Traefik may intentionally narrow a permissive app header to the Portal-safe public policy
- for example, an app may emit
frame-ancestors *internally while the public host correctly returnsframe-ancestors 'self' https://portal...
Examples in this repo:
visualization/docusaurus/nginx.confinfrastructure/portainer/nginx/proxy.confplatform/chatwoot/custom_build/patches/iframe_fix.rb
6. Validate dynamic iframe URLs
If Portal loads a URL returned by an API or SSO flow, validate the final origin before rendering the iframe.
Current example:
platform/portal/ui/src/components/apps/CustomerServiceClient/CustomerServiceClient.tsx
Pattern:
- Parse the returned URL
- Compare
url.originagainst the allowlist - Render only if allowed
- Otherwise fail with a controlled Portal error
Verification checklist
Portal headers
Check the Portal CSP:
curl -skI https://<portal-host> | grep -i content-security-policy
Confirm frame-src contains:
- the embedded app public origin
- auth/redirect origins
- any extra iframe origins
Embedded app headers
Check the embedded app response:
curl -skI https://<embedded-app-host> | grep -i -E 'x-frame-options|content-security-policy'
Correct result:
- no
X-Frame-Options: DENY frame-ancestorscompatible with Portal embedding- if the app is intended only for Portal embedding, a narrowed public policy such as
frame-ancestors 'self' https://<portal-host>is acceptable
Traefik generated config
Render and inspect:
tmpdir=$(mktemp -d)
python3 deployment/scripts/python/render_traefik_config.py --output-dir "$tmpdir"
rg -n "<app>|portal-embed-frame|deny-frame" "$tmpdir/dynamic/routes.yml"
For embeddable apps, the router must use:
- security-headers-base
- portal-embed-frame
Browser symptoms
If you still see any of these, embedding is still blocked somewhere:
Refused to display ... because it set 'X-Frame-Options' to 'deny'Framing ... violates the following Content Security Policy directive: frame-src ...Failed to execute 'postMessage' ... recipient window's origin ('null')
The postMessage error is usually secondary. Fix the frame block first.
Portal runtime/cache symptoms
The Portal can keep iframe instances alive across window moves/reopens.
If headers were fixed after an earlier failed load, the browser may still show the old failure state until the iframe is recreated.
Typical signs:
- public
curl -Ichecks already look correct - Portal still shows
chrome-error://chromewebdata/ - a full Portal reload or a fresh browser session makes the error disappear
Before assuming the server is still broken:
- reload the full Portal session
- test in a fresh incognito window
- verify the iframe target directly outside Portal
Release checklist
For Portal iframe changes:
./infra release <server> portal
For app-side header changes:
./infra release <server> <project>
For Traefik/embed policy changes:
./infra remote deploy <server> core
Use all required steps if the fix spans multiple layers.
Important:
- image-changing app fixes require
release;remote deployalone does not rebuild the app image - after release, verify the exact public behavior you changed instead of assuming the new image is active
Examples:
curl -skI https://docs.<domain>/
curl -sk https://cs.<domain>/enterprise/api/v1/accounts/1/limits
If a route patch was supposed to return 200 and the public host still returns 401 or 404, the running image is not the one you expected.
Current embeddable surfaces
At the time of writing, these require Portal-safe frame handling:
THINGSBOARDPORTAINERN8NEVOLUTIONINFLUXDBDOCSCHATWOOT/ customer service flows
Any new Portal app should be reviewed against this checklist before release.