Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 5 min read

Embedding a signed-in web app in itch.io: frame-ancestors, partitioned cookies and OAuth

I'm Anton, founder of SagaFlow, a browser story game with guest sessions, accounts and a cookie banner. This week we made one of our story pages playable inside itch.io's game window. A web app that was built to refuse f

I'm Anton, founder of SagaFlow, a browser story game with guest sessions, accounts and a cookie banner. This week we made one of our story pages playable inside itch.io's game window. A web app that was built to refuse framing does not survive that move on its own, and four separate things broke. Here is each one, with the code we ended up with.

A note on how this was made: this post is labelled Fully Autonomous because it was written by the AI coding assistant that built these fixes with me, from the code and our test results. I directed the work and approved the post, and the fixes were tested on our dev and production sites.

The setup: two frames, not one

An HTML project on itch.io is a zip with an index.html. itch serves it from its own CDN (for us, html-classic.itch.zone) inside an iframe on your project page (yourname.itch.io). Our index.html is tiny: one more iframe, pointing at a story page on our domain.

<iframe
  src="https://example.com/game/en/some-story/"
  title="Play the story"
  allow="autoplay; fullscreen; clipboard-write"></iframe>

So our page sits two frames deep: yourname.itch.io β†’ html-classic.itch.zone β†’ our app. That chain matters for everything below.

1. Allow framing for one host family, on one set of paths

Our site sent X-Frame-Options: DENY and Content-Security-Policy: frame-ancestors 'none' everywhere. That is the right default against clickjacking, and we kept it for every path except the game pages.

Two details make this harder than it looks:

  • frame-ancestors is checked against every ancestor in the chain, not just the direct parent. Listing only html-classic.itch.zone still fails, because the top window is yourname.itch.io.
  • X-Frame-Options has no allow-list. If it is present with DENY, the browser blocks the frame regardless of your CSP, so on these paths it has to go.

We host on Cloudflare Pages and set headers in _headers. Pages merges headers from every matching rule, and duplicate names are comma-joined. A more specific rule does not override the catch-all. If you only add a CSP to /game*, the browser receives both policies, and the stricter frame-ancestors 'none' still wins. You have to detach the inherited headers and restate the CSP in full:

/game*
  ! X-Frame-Options
  ! Content-Security-Policy
  Content-Security-Policy: default-src 'self'; script-src 'self' ...; frame-ancestors 'self' https://yourname.itch.io https://itch.io https://html-classic.itch.zone https://html.itch.zone; base-uri 'self'

Two consequences we accepted. First, the CSP now exists twice in the file, so the catch-all rule carries a comment saying any change must be copied into the game rule. Second, we list exact hosts. A wildcard like https://*.itch.io would let any itch project frame our pages, which brings the clickjacking risk straight back.

2. Cover every path a reload can land on

Our first rule was /game/*, which matches the story page. Inside the frame the app is a single-page app, so moving from the story page into the running game is a client-side navigation and no new document is loaded. The headers are only checked again on a real load.

Then a refresh mid-story landed on /game?id=..., which /game/* does not match, and the frame went blank. The same would happen on the end screen at /game-end, or when our version checker forces a reload after a deploy. The rule became /game*, which matches exactly our four game routes.

We checked it on a local build with wrangler pages dev before shipping:

for p in / /game?id=abc /game-end /game/en/x/ /heroes; do
  curl -s -o /dev/null -D - "http://127.0.0.1:8788$p" \
    | grep -iE "^(x-frame-options|content-security-policy)"
done

The output we wanted: / and /heroes still send DENY and frame-ancestors 'none', and the three game paths send exactly one CSP with the itch hosts and no X-Frame-Options.

3. Make the cookie choice stick inside the frame

Once the game loaded, the consent banner came back on every visit, and an Accept never seemed to take effect after a reload.

The reason: our consent choice lives in a cookie written with SameSite=Lax. Inside a cross-site iframe the browser neither stores nor sends a Lax cookie. The click was recorded and then forgotten.

The fix is a partitioned cookie (CHIPS): SameSite=None; Secure; Partitioned. The browser keeps it, but only for this top-level site, so it cannot be used to track anyone across other sites. We only use those attributes when the app is actually framed, so nothing changes on our own domain:

export const isFramed: boolean = (() => {
  if (typeof window === 'undefined') return false;
  try {
    return window.self !== window.top;
  } catch {
    // A cross-origin parent can make the comparison throw: that is a frame.
    return true;
  }
})();

export const COOKIE_SAME_SITE = isFramed
  ? 'SameSite=None; Secure; Partitioned'
  : 'SameSite=Lax';

document.cookie =
  `consent=${value}; max-age=31536000; path=/; ${COOKIE_SAME_SITE}`;

One more trap: our consent tool writes its own cookie with Lax attributes we don't control. When framed, we write our partitioned copy right after the tool's own call, so the choice survives the next load either way.

4. Send Google sign-in out of the frame

Clicking "Continue with Google" inside the frame blanked the window. OAuth navigates the current frame to Google's sign-in page, and that page refuses to be framed.

Even if it didn't, the round trip would fail. The OAuth flow stores a PKCE verifier before it leaves, and the frame's storage is partitioned away from the storage our site gets as a top-level tab. The callback would land somewhere the verifier isn't.

So inside a frame, the Google button doesn't start OAuth at all. It opens our site in a new top-level tab on the same story, where sign-in works normally, and shows a one-line toast saying so:

const STORY_PAGE = /^\/game\/[^/]+(\/[^/]+)?\/?$/;

export function openOutsideFrame(): void {
  const { origin, pathname } = window.location;
  // A running game belongs to the frame's partitioned guest session,
  // which the new tab cannot see, so only a story page is carried over.
  const path = STORY_PAGE.test(pathname) ? pathname : '/';
  window.open(`${origin}${path}`, '_blank', 'noopener');
}

What we still have to check

All four fixes were tested in desktop Chrome on our dev site, and the embed itself in production. The next check is a real iPhone, because Safari handles storage inside third-party frames differently from Chrome. If guest sessions don't persist there, the fix will be different from anything above.

If you've embedded an app with accounts into itch.io or another game portal, I'd like to know what broke for you, especially anything iOS-specific.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.