DEV Community

Cover image for Third-Party Game iframe White Screen: How I Tracked Down the Root Cause with sandbox and postMessage Protocol
no momo
no momo

Posted on

Third-Party Game iframe White Screen: How I Tracked Down the Root Cause with sandbox and postMessage Protocol

When integrating third-party HTML5 games, I ran into two seemingly unrelated problems: the game worked fine when opened locally, but went white when embedded in my site; and the game loaded, but scores sometimes got lost or mixed up when sent back.

The root causes were: the game server blocked iframe embedding via X-Frame-Options: DENY, and the postMessage protocol lacked origin validation and message type constraints. The first caused the white screen; the second caused the score mix-ups. Here's the full debugging and solution process.


Environment

  • Vue 3.4 + Vite 5.2
  • Test devices: Chrome 125, Safari 17.4
  • Game source: third-party HTML5 games deployed on a separate domain
  • Embedding method: <iframe> + postMessage

Problem 1: The Game Plays Locally but Goes White When Embedded

Symptoms

I got the game package from a third-party vendor. Opening index.html locally worked fine. But after embedding it in /play/:id, the iframe area was blank, with no JavaScript errors in the console.

How I Tracked It Down

My first thought was that the iframe's src path was wrong. After checking, the path was correct, and the game HTML request returned 200 in the Network panel.

But the Console panel had a line I hadn't noticed before:

Refused to display 'https://www.playfiddlebops.com/sprunki-mod/' in a frame because it set 'X-Frame-Options' to 'deny'.
Enter fullscreen mode Exit fullscreen mode

Root cause: The game server set X-Frame-Options: DENY, explicitly forbidding the page from being embedded in any iframe.

The Fix

This requires the game vendor to change their configuration. If you can't modify the game server, you can set up a reverse proxy on your own server to proxy game assets under a same-origin path:

# nginx configuration
location /proxied-games/ {
    proxy_pass https://www.playfiddlebops.com/sprunki-mod/;
    proxy_set_header Host playfiddlebops.com;
    # Remove headers that block embedding
    proxy_hide_header X-Frame-Options;
    proxy_hide_header Content-Security-Policy;
}
Enter fullscreen mode Exit fullscreen mode

But the reverse proxy has costs: all game requests go through your server, increasing bandwidth and latency. Also, if the game relies on cookies or localStorage under playfiddlebops.com, those contexts are lost after proxying.

My trade-off: Prioritize talking to the game vendor and ask them to deploy the game on a separate domain, using CSP frame-ancestors to allow only my domain to embed it. That's the cleanest solution, but it requires vendor cooperation.


Problem 2: The postMessage Protocol Lacked Constraints

Symptoms

After fixing the white screen, the game loaded correctly. But scores had two problems when sent back:

  1. Scores occasionally got lost. The game showed "saved," but there was no record on the leaderboard.
  2. When switching games quickly, game A's score appeared on game B's leaderboard.

How I Tracked It Down

The original protocol was very crude:

// Parent page receives
window.addEventListener('message', (event) => {
  if (event.data.type === 'END') {
    saveScore(event.data.score)
  }
})

// Game sends
window.parent.postMessage({ type: 'END', score: 123 }, '*')
Enter fullscreen mode Exit fullscreen mode

There were three problems:

First, targetOrigin used '*'. The spec explicitly requires that messages containing any confidential information should not use the wildcard * as targetOrigin, because it can't guarantee the message is delivered only to the intended receiver.

Second, the receiver didn't validate event.origin. Any window can send postMessage. Without origin validation, a malicious page can forge score messages. OWASP recommends always checking the message's origin property and using a whitelist to ensure messages come from trusted domains.

Third, the receiver didn't validate event.source. When multiple iframes exist at the same time, event.source is what distinguishes which iframe the message came from. A security issue in a GameHub project on GitHub pointed out that when same-origin iframes lack the sandbox attribute, game scripts can directly read the parent window's localStorage and steal user credentials.

The Fix

Design a communication protocol with type constraints and origin validation:

// Protocol constants
const MSG_TYPE = {
  READY: 'game:ready',
  END: 'game:end',
  PAUSE: 'game:pause',
  RESUME: 'game:resume',
  FULLSCREEN: 'game:fullscreen',
  ERROR: 'game:error'
}

const ALLOWED_ORIGINS = [
  'https://www.playfiddlebops.com/'
]

// Parent page receives
const iframeRef = ref(null)
let activeSource = null

onMounted(() => {
  iframeRef.value?.addEventListener('load', () => {
    activeSource = iframeRef.value.contentWindow
  })
})

function onMessage(event) {
  // Three checks: origin, sender, message type
  if (!ALLOWED_ORIGINS.includes(event.origin)) return
  if (event.source !== activeSource) return
  if (!event.data?.type || !Object.values(MSG_TYPE).includes(event.data.type)) return

  switch (event.data.type) {
    case MSG_TYPE.END:
      if (typeof event.data.score !== 'number') return
      saveScore(event.data.score)
      break
    case MSG_TYPE.ERROR:
      reportError(event.data.message)
      break
  }
}

window.addEventListener('message', onMessage)

// Game sends
window.parent.postMessage(
  { type: 'game:end', score: 123 },
  window.location.origin  // Not '*'
)
Enter fullscreen mode Exit fullscreen mode

There's a key constraint here: the event.source !== activeSource check breaks when the iframe redirects. The contentWindow reference points to a new Window object after iframe navigation, while event.source points to the Window object that sent the message. They're no longer equal. You need to re-capture contentWindow after each load event.


Problem 3: Item-by-Item sandbox Attribute Testing

Why sandbox Is Needed

Without the sandbox attribute, game scripts in a same-origin iframe can:

  • Read the parent window's localStorage and steal user credentials
  • Modify the parent page's DOM
  • Redirect the parent page to a phishing site

Google's HTML Service documentation explicitly states that IFRAME sandbox mode is based on the HTML5 iframe sandboxing feature, designed to protect users from malicious HTML or JavaScript.

Item-by-Item Test Results

I built a test page and enabled sandbox attributes one by one, observing the impact on game functionality:

sandbox attribute Does the game run? Side effects
Empty (maximum restriction) No Scripts completely disabled
allow-scripts Yes Game logic works, but can't submit forms
allow-scripts allow-same-origin Yes Can access localStorage, but has escape risk
allow-scripts allow-same-origin allow-forms Yes Forms work, no extra risk
allow-scripts allow-same-origin allow-popups Yes Popups work

Key Warning: The allow-scripts + allow-same-origin Combination

The spec explicitly states that when the embedded page is same-origin with the parent page, setting both allow-scripts and allow-same-origin allows the embedded page to simply remove the sandbox attribute and reload itself, effectively breaking out of the sandbox entirely.

This means: for third-party games you don't fully trust, never enable both attributes at the same time. The correct approach is to deploy games on a separate domain. Even with allow-same-origin enabled, game scripts can only access their own origin and can't read the parent page's data.

My final sandbox configuration:

<iframe
  ref="iframeRef"
  :src="game.entry"
  sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
  allow="fullscreen; autoplay; gamepad; pointer-lock"
  referrerpolicy="no-referrer"
/>
Enter fullscreen mode Exit fullscreen mode

The allow attribute grants the game the browser permissions it needs: fullscreen, autoplay, gamepad, and pointer lock. referrerpolicy="no-referrer" prevents the game server from getting the parent page's URL via the Referer header.


Problem 4: Load Timeouts and Error Handling

Symptoms

On a poor network, the iframe load could time out or fail completely, but the user just saw a blank area with no indication.

The Fix

Implement state management with timeout detection and a retry mechanism:

const states = ['idle', 'loading', 'ready', 'error']

const state = ref('idle')
const loadTimeout = ref(null)

function startGame() {
  state.value = 'loading'

  // 15-second timeout
  loadTimeout.value = setTimeout(() => {
    if (state.value === 'loading') {
      state.value = 'error'
      errorMessage.value = 'Game load timed out. Check your network and try again.'
    }
  }, 15000)
}

function onIframeLoad() {
  clearTimeout(loadTimeout.value)
  state.value = 'ready'
}

function retry() {
  state.value = 'idle'
  // Reset iframe src to force reload
  iframeKey.value++
}
Enter fullscreen mode Exit fullscreen mode

This follows a pattern from the DEV community: manage repeat launches, resets, timeouts, and fullscreen toggles with a reducer. If the load event doesn't arrive within a reasonable time, show help and a retry option.


Verification Data

Tested 10 times each on Chrome 125 and Safari 17.4:

Test Before After
Third-party game white screen rate 100% (blocked by X-Frame-Options) 0% (after reverse proxy)
Score loss rate 3/10 0/10
Score cross-contamination 2/10 0/10
Load timeout with no prompt Yes No, retry button shown

Test conditions: Wi-Fi, Chrome DevTools without throttling.


Trade-offs and Limitations

The reverse proxy adds bandwidth and latency. Game assets go through my server, increasing first-load time by about 80ms. If game traffic is high, CDN caching needs to be considered.

sandbox restricts some game functionality. One game relied on allow-modals to show native dialogs. After being blocked by sandbox, it was changed to an HTML overlay. This is a conscious trade-off: security > convenience.

Origin validation adds protocol complexity. Every new game vendor requires adding its domain to the ALLOWED_ORIGINS whitelist. If a vendor changes domains, the configuration needs manual updating.


Unresolved

  • iOS Safari WebGL memory issues: One WebGL game crashes to a white screen after about 5 minutes on iPhone. WebKit's bug tracker shows iOS Safari has an iframe WebGL memory leak issue — repeatedly switching WebGL pages causes memory to grow until it crashes. Currently falls back to limiting game session length and prompting the user to refresh.
  • Dynamic script injection alternative: For higher-trust partners, consider replacing iframe with dynamic script injection, but that requires solving script isolation and global namespace pollution. Not enough time to validate this yet.
  • Cross-origin storage solution: If a game needs to persist user data (like game progress), there's currently no cross-origin storage solution. localStorage is isolated by sandbox, and cookies are restricted by third-party cookie limits.

References

  • WHATWG HTML spec on the iframe sandbox attribute, especially the security warning about combining allow-scripts and allow-same-origin
  • OWASP HTML5 Security Cheat Sheet on postMessage origin validation
  • Google HTML Service documentation on IFRAME sandbox mode
  • Kongregate's iframe game embedding troubleshooting guide, covering X-Frame-Options, CSP frame-ancestors, and CORS
  • DEV community articles on click-to-load iframes and timeout recovery

Top comments (0)