{"slug": "embedding-a-signed-in-web-app-in-itch-io-frame-ancestors-partitioned-cookies-and", "title": "Embedding a signed-in web app in itch.io: frame-ancestors, partitioned cookies and OAuth", "summary": "Anton, founder of the browser story game SagaFlow, embedded a signed-in story page inside itch.io's game window, working through four separate breakages involving frame-ancestors, Cloudflare Pages header merging, partitioned cookies and OAuth. The fixes required dropping X-Frame-Options on game routes, restating the full CSP with exact itch.io hosts, and broadening the route rule from /game/* to /game* after a mid-story refresh landed on /game?id=... and blanked the frame. The changes were tested locally with wrangler pages dev and on dev and production sites.", "body_md": "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.\n\nA 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.\n\nAn 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.\n\n```\n<iframe\n  src=\"https://example.com/game/en/some-story/\"\n  title=\"Play the story\"\n  allow=\"autoplay; fullscreen; clipboard-write\"></iframe>\n```\n\nSo our page sits two frames deep: `yourname.itch.io` → `html-classic.itch.zone` → our app. That chain matters for everything below.\n\nOur 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.\n\nTwo details make this harder than it looks:\n\n`frame-ancestors` is checked against `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.\nWe 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:\n\n```\n/game*\n  ! X-Frame-Options\n  ! Content-Security-Policy\n  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'\n```\n\nTwo 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.\n\nOur 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.\n\nThen 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.\n\nWe checked it on a local build with `wrangler pages dev` before shipping:\n\n```\nfor p in / /game?id=abc /game-end /game/en/x/ /heroes; do\n  curl -s -o /dev/null -D - \"http://127.0.0.1:8788$p\" \\\n    | grep -iE \"^(x-frame-options|content-security-policy)\"\ndone\n```\n\nThe 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`.\n\nOnce the game loaded, the consent banner came back on every visit, and an Accept never seemed to take effect after a reload.\n\nThe 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.\n\nThe 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:\n\n``` js\nexport const isFramed: boolean = (() => {\n  if (typeof window === 'undefined') return false;\n  try {\n    return window.self !== window.top;\n  } catch {\n    // A cross-origin parent can make the comparison throw: that is a frame.\n    return true;\n  }\n})();\n\nexport const COOKIE_SAME_SITE = isFramed\n  ? 'SameSite=None; Secure; Partitioned'\n  : 'SameSite=Lax';\n\ndocument.cookie =\n  `consent=${value}; max-age=31536000; path=/; ${COOKIE_SAME_SITE}`;\n```\n\nOne 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.\n\nClicking \"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.\n\nEven 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.\n\nSo 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:\n\n``` js\nconst STORY_PAGE = /^\\/game\\/[^/]+(\\/[^/]+)?\\/?$/;\n\nexport function openOutsideFrame(): void {\n  const { origin, pathname } = window.location;\n  // A running game belongs to the frame's partitioned guest session,\n  // which the new tab cannot see, so only a story page is carried over.\n  const path = STORY_PAGE.test(pathname) ? pathname : '/';\n  window.open(`${origin}${path}`, '_blank', 'noopener');\n}\n```\n\nAll 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.\n\nIf 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.", "url": "https://wpnews.pro/news/embedding-a-signed-in-web-app-in-itch-io-frame-ancestors-partitioned-cookies-and", "canonical_source": "https://dev.to/sagaflow/embedding-a-signed-in-web-app-in-itchio-frame-ancestors-partitioned-cookies-and-oauth-2b6n", "published_at": "2026-11-11 20:38:09+00:00", "updated_at": "2026-11-11 20:52:34.502329+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["SagaFlow", "itch.io", "Cloudflare Pages", "Anton"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/embedding-a-signed-in-web-app-in-itch-io-frame-ancestors-partitioned-cookies-and", "markdown": "https://wpnews.pro/news/embedding-a-signed-in-web-app-in-itch-io-frame-ancestors-partitioned-cookies-and.md", "text": "https://wpnews.pro/news/embedding-a-signed-in-web-app-in-itch-io-frame-ancestors-partitioned-cookies-and.txt", "jsonld": "https://wpnews.pro/news/embedding-a-signed-in-web-app-in-itch-io-frame-ancestors-partitioned-cookies-and.jsonld"}}