<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Anton Vanin</title>
    <description>The latest articles on DEV Community by Anton Vanin (@sagaflow).</description>
    <link>https://dev.to/sagaflow</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4239830%2F5102e9fa-0d09-4c83-abbd-adfb17af1a6e.png</url>
      <title>DEV Community: Anton Vanin</title>
      <link>https://dev.to/sagaflow</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sagaflow"/>
    <language>en</language>
    <item>
      <title>Embedding a signed-in web app in itch.io: frame-ancestors, partitioned cookies and OAuth</title>
      <dc:creator>Anton Vanin</dc:creator>
      <pubDate>Wed, 11 Nov 2026 20:38:09 +0000</pubDate>
      <link>https://dev.to/sagaflow/embedding-a-signed-in-web-app-in-itchio-frame-ancestors-partitioned-cookies-and-oauth-2b6n</link>
      <guid>https://dev.to/sagaflow/embedding-a-signed-in-web-app-in-itchio-frame-ancestors-partitioned-cookies-and-oauth-2b6n</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: two frames, not one
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;iframe&lt;/span&gt;
  &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/game/en/some-story/"&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Play the story"&lt;/span&gt;
  &lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"autoplay; fullscreen; clipboard-write"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/iframe&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So our page sits two frames deep: &lt;code&gt;yourname.itch.io&lt;/code&gt; → &lt;code&gt;html-classic.itch.zone&lt;/code&gt; → our app. That chain matters for everything below.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Allow framing for one host family, on one set of paths
&lt;/h2&gt;

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

&lt;p&gt;Two details make this harder than it looks:&lt;/p&gt;

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

&lt;p&gt;We host on Cloudflare Pages and set headers in &lt;code&gt;_headers&lt;/code&gt;. Pages &lt;strong&gt;merges&lt;/strong&gt; 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 &lt;code&gt;/game*&lt;/code&gt;, the browser receives both policies, and the stricter &lt;code&gt;frame-ancestors 'none'&lt;/code&gt; still wins. You have to detach the inherited headers and restate the CSP in full:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/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'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;https://*.itch.io&lt;/code&gt; would let any itch project frame our pages, which brings the clickjacking risk straight back.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Cover every path a reload can land on
&lt;/h2&gt;

&lt;p&gt;Our first rule was &lt;code&gt;/game/*&lt;/code&gt;, 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.&lt;/p&gt;

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

&lt;p&gt;We checked it on a local build with &lt;code&gt;wrangler pages dev&lt;/code&gt; before shipping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; p &lt;span class="k"&gt;in&lt;/span&gt; / /game?id=abc /game-end /game/en/x/ /heroes&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  curl -s -o /dev/null -D - &lt;span class="s2"&gt;"http://127.0.0.1:8788$p"&lt;/span&gt; \
    &lt;span class="o"&gt;|&lt;/span&gt; grep -iE &lt;span class="s2"&gt;"^(x-frame-options|content-security-policy)"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  3. Make the cookie choice stick inside the frame
&lt;/h2&gt;

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

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

&lt;p&gt;The fix is a partitioned cookie (CHIPS): &lt;code&gt;SameSite=None; Secure; Partitioned&lt;/code&gt;. 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;isFramed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'undefined'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// A cross-origin parent can make the comparison throw: that is a frame.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;COOKIE_SAME_SITE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;isFramed&lt;/span&gt;
  &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'SameSite=None; Secure; Partitioned'&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'SameSite=Lax'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cookie&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="sb"&gt;`consent=${value}; max-age=31536000; path=/; ${COOKIE_SAME_SITE}`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One more trap: our consent tool writes its own cookie with &lt;code&gt;Lax&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Send Google sign-in out of the frame
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;STORY_PAGE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;/^&lt;/span&gt;\&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;game&lt;/span&gt;\&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;^/&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;\&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;^/&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;\&lt;span class="o"&gt;/?&lt;/span&gt;$&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;openOutsideFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pathname&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;location&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// A running game belongs to the frame's partitioned guest session,&lt;/span&gt;
  &lt;span class="c1"&gt;// which the new tab cannot see, so only a story page is carried over.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;STORY_PAGE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;pathname&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`${origin}${path}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'_blank'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'noopener'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What we still have to check
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>cloudflare</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
