<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Oauth2 on hippotion</title><link>https://blog.hippotion.com/tags/oauth2/</link><description>Recent content in Oauth2 on hippotion</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Fri, 14 Mar 2025 00:00:00 +0000</lastBuildDate><atom:link href="https://blog.hippotion.com/tags/oauth2/index.xml" rel="self" type="application/rss+xml"/><item><title>📱 Building a QR Code Login for a Homelab (And Learning oauth2-proxy's Session Format the Hard Way)</title><link>https://blog.hippotion.com/posts/qr-device-login/</link><pubDate>Fri, 14 Mar 2025 00:00:00 +0000</pubDate><guid>https://blog.hippotion.com/posts/qr-device-login/</guid><description>My homelab uses oauth2-proxy for GitLab SSO. I wanted a QR code login for the TV dashboard. Two days and four complete rewrites later, I knew more about oauth2-proxy&amp;rsquo;s session format than I ever planned to.</description><content:encoded><![CDATA[<h2 id="the-problem">The problem</h2>
<p>My homelab runs a single-node k3s cluster with a full GitOps stack — Argo CD, Traefik, oauth2-proxy for GitLab SSO, the usual over-engineered personal project. One thing that always bothered me: when I want to show the Homer dashboard on the living room TV, I have to type my credentials on a keyboard that wasn&rsquo;t designed for the living room.</p>
<p>The obvious fix is a QR code. Phone scans it, phone authenticates, TV unlocks. Conceptually simple. In practice, a two-day debugging adventure that took me deep into oauth2-proxy&rsquo;s source code.</p>
<hr>
<h2 id="the-design">The design</h2>
<p>The flow I wanted:</p>
<ol>
<li>TV opens <code>qr.hippotion.com</code>, shows a QR code and polls for completion</li>
<li>Phone scans, opens the device URL, taps &ldquo;Continue with GitLab&rdquo;</li>
<li>Phone completes GitLab OAuth</li>
<li>Server marks the session as ready</li>
<li>TV&rsquo;s poll fires, gets redirected to Homer</li>
<li>Later: phone taps &ldquo;End Session&rdquo;, TV locks immediately</li>
</ol>
<p>This is the <a href="https://datatracker.ietf.org/doc/html/rfc8628">OAuth 2.0 Device Authorization Grant</a> pattern adapted for a single trusted user. I wrote it in Go with Redis for session storage. The service generates a device token, stores it with a 5-minute TTL, and uses it as the OAuth <code>state</code> parameter. The phone completes GitLab OAuth and the callback handler links the resulting session to the device token. The TV&rsquo;s poll loop picks it up and redirects.</p>
<p>That part was straightforward. The hard part was making the TV&rsquo;s session work for <em>all</em> protected apps on the domain, not just the QR page.</p>
<hr>
<h2 id="the-oauth2-proxy-problem">The oauth2-proxy problem</h2>
<p>My homelab uses oauth2-proxy as a ForwardAuth backend for Traefik. Every protected app (<code>home.hippotion.com</code>, <code>argo.hippotion.com</code>, <code>grafana.hippotion.com</code>, etc.) sends unauthenticated requests through oauth2-proxy, which redirects to GitLab if no valid <code>_oauth2_proxy</code> session cookie is present.</p>
<p>The QR auth service creates its own session cookie (<code>qr_session</code>), but oauth2-proxy knows nothing about it. After QR login, clicking any link from Homer would immediately ask for GitLab credentials again.</p>
<p>The obvious solution: after the phone authenticates, set a valid <code>_oauth2_proxy</code> cookie on the TV&rsquo;s browser. If I can forge a cookie that oauth2-proxy accepts, all apps work instantly.</p>
<p>How hard can it be?</p>
<hr>
<h2 id="attempt-1-aes-gcm--json">Attempt 1: AES-GCM + JSON</h2>
<p>I looked at the oauth2-proxy source and found what looked like the session format: a JSON struct with short field names (<code>&quot;e&quot;</code> for email, <code>&quot;ca&quot;</code> for created-at, etc.), encrypted with AES-GCM, base64url-encoded.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="kd">type</span><span class="w"> </span><span class="nx">oauthSession</span><span class="w"> </span><span class="kd">struct</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">CreatedAt</span><span class="w"> </span><span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="w"> </span><span class="s">`json:&#34;ca&#34;`</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">ExpiresOn</span><span class="w"> </span><span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="w"> </span><span class="s">`json:&#34;ea&#34;`</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">Email</span><span class="w">     </span><span class="kt">string</span><span class="w">     </span><span class="s">`json:&#34;e&#34;`</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">User</span><span class="w">      </span><span class="kt">string</span><span class="w">     </span><span class="s">`json:&#34;u&#34;`</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p>SHA256-hash the cookie secret → 32-byte AES key → GCM encrypt → base64url encode. Set as <code>_oauth2_proxy</code> cookie. Clean, simple, wrong.</p>
<p>oauth2-proxy returned 302 every time. I added debug logging to print the cookie value, copied it, and tested it directly against the ForwardAuth endpoint with curl. The logs revealed everything:</p>
<pre tabindex="0"><code>Error loading cookied session: cookie signature not valid, removing session
</code></pre><p><em>Cookie signature not valid.</em> Not &ldquo;decryption failed&rdquo;, not &ldquo;session expired&rdquo;. A signature check.</p>
<hr>
<h2 id="finding-the-real-format">Finding the real format</h2>
<p>The error came from <code>pkg/middleware/stored_session.go:94</code>. I fetched the source:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="nx">val</span><span class="p">,</span><span class="w"> </span><span class="nx">_</span><span class="p">,</span><span class="w"> </span><span class="nx">ok</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">encryption</span><span class="p">.</span><span class="nf">Validate</span><span class="p">(</span><span class="nx">c</span><span class="p">,</span><span class="w"> </span><span class="nx">secret</span><span class="p">,</span><span class="w"> </span><span class="nx">s</span><span class="p">.</span><span class="nx">Cookie</span><span class="p">.</span><span class="nx">Expire</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">if</span><span class="w"> </span><span class="p">!</span><span class="nx">ok</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="kc">nil</span><span class="p">,</span><span class="w"> </span><span class="nx">errors</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="s">&#34;cookie signature not valid&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p><code>encryption.Validate</code> splits the cookie value on <code>|</code> and expects three parts. Looking at <code>utils.go</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="kd">func</span><span class="w"> </span><span class="nf">Validate</span><span class="p">(</span><span class="nx">cookie</span><span class="w"> </span><span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Cookie</span><span class="p">,</span><span class="w"> </span><span class="nx">seed</span><span class="w"> </span><span class="kt">string</span><span class="p">,</span><span class="w"> </span><span class="nx">expiration</span><span class="w"> </span><span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">)</span><span class="w"> </span><span class="p">(</span><span class="nx">value</span><span class="w"> </span><span class="p">[]</span><span class="kt">byte</span><span class="p">,</span><span class="w"> </span><span class="nx">t</span><span class="w"> </span><span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">,</span><span class="w"> </span><span class="nx">ok</span><span class="w"> </span><span class="kt">bool</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">parts</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">strings</span><span class="p">.</span><span class="nf">Split</span><span class="p">(</span><span class="nx">cookie</span><span class="p">.</span><span class="nx">Value</span><span class="p">,</span><span class="w"> </span><span class="s">&#34;|&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="nb">len</span><span class="p">(</span><span class="nx">parts</span><span class="p">)</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="mi">3</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">return</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="nf">checkSignature</span><span class="p">(</span><span class="nx">parts</span><span class="p">[</span><span class="mi">2</span><span class="p">],</span><span class="w"> </span><span class="nx">seed</span><span class="p">,</span><span class="w"> </span><span class="nx">cookie</span><span class="p">.</span><span class="nx">Name</span><span class="p">,</span><span class="w"> </span><span class="nx">parts</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span><span class="w"> </span><span class="nx">parts</span><span class="p">[</span><span class="mi">1</span><span class="p">])</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="c1">// ...</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p>The cookie format is <code>encryptedValue|timestamp|hmac</code>. My cookie was just <code>encryptedValue</code>. Three-part, not one. First problem found.</p>
<p>For the HMAC, I needed to verify against a real cookie to get the key format right. oauth2-proxy sets <code>_oauth2_proxy_csrf</code> cookies during the login flow — I captured one from a 302 response and reverse-engineered it in Python:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">key</span> <span class="o">=</span> <span class="n">secret_raw</span><span class="o">.</span><span class="n">encode</span><span class="p">()</span>  <span class="c1"># raw string, not decoded</span>
</span></span><span class="line"><span class="cl"><span class="n">data</span> <span class="o">=</span> <span class="p">(</span><span class="n">cookie_name</span> <span class="o">+</span> <span class="n">enc_val</span> <span class="o">+</span> <span class="n">ts</span><span class="p">)</span><span class="o">.</span><span class="n">encode</span><span class="p">()</span>  <span class="c1"># concatenated, NO separators</span>
</span></span><span class="line"><span class="cl"><span class="n">sig</span> <span class="o">=</span> <span class="n">base64</span><span class="o">.</span><span class="n">urlsafe_b64encode</span><span class="p">(</span><span class="n">hmac</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="n">data</span><span class="p">,</span> <span class="n">hashlib</span><span class="o">.</span><span class="n">sha256</span><span class="p">)</span><span class="o">.</span><span class="n">digest</span><span class="p">())</span>
</span></span></code></pre></div><p>Two surprises: the HMAC key is the <strong>raw cookie secret string</strong> (not base64-decoded), and the input is a <strong>bare concatenation</strong> with no <code>|</code> separators between fields.</p>
<p>I ran the test. The CSRF cookie&rsquo;s signature matched. I had the format.</p>
<p>But oauth2-proxy still rejected the session.</p>
<hr>
<h2 id="the-wrong-cipher">The wrong cipher</h2>
<p>I switched from AES-GCM to the correct HMAC format and tried again. Still 302. <code>cookie signature not valid</code> again.</p>
<p>Wait — was it even getting to the signature check? If decryption failed first, it wouldn&rsquo;t reach that error. I added more debug logging to print the full cookie value and tested it with Python&rsquo;s <code>cryptography</code> library:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">candidates</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;24-byte std-b64 decode&#39;</span><span class="p">:</span>  <span class="n">base64</span><span class="o">.</span><span class="n">b64decode</span><span class="p">(</span><span class="n">secret_str</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;32-byte raw string&#39;</span><span class="p">:</span>      <span class="n">secret_str</span><span class="o">.</span><span class="n">encode</span><span class="p">(),</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;32-byte sha256 of b64&#39;</span><span class="p">:</span>   <span class="n">hashlib</span><span class="o">.</span><span class="n">sha256</span><span class="p">(</span><span class="n">base64</span><span class="o">.</span><span class="n">b64decode</span><span class="p">(</span><span class="n">secret_str</span><span class="p">))</span><span class="o">.</span><span class="n">digest</span><span class="p">(),</span>
</span></span><span class="line"><span class="cl">    <span class="o">...</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">label</span><span class="p">,</span> <span class="n">key</span> <span class="ow">in</span> <span class="n">candidates</span><span class="o">.</span><span class="n">items</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">pt</span> <span class="o">=</span> <span class="n">AESGCM</span><span class="p">(</span><span class="n">key</span><span class="p">)</span><span class="o">.</span><span class="n">decrypt</span><span class="p">(</span><span class="n">nonce</span><span class="p">,</span> <span class="n">ct_tag</span><span class="p">,</span> <span class="kc">None</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s1">&#39;SUCCESS [</span><span class="si">{</span><span class="n">label</span><span class="si">}</span><span class="s1">]: </span><span class="si">{</span><span class="n">pt</span><span class="o">.</span><span class="n">decode</span><span class="p">()</span><span class="si">}</span><span class="s1">&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">except</span> <span class="ne">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s1">&#39;FAIL    [</span><span class="si">{</span><span class="n">label</span><span class="si">}</span><span class="s1">]: </span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="s1">&#39;</span><span class="p">)</span>
</span></span></code></pre></div><p>The 24-byte base64-decoded key decrypted successfully. The cookie was correctly decrypted. But still rejected. Which meant the signature check was passing but <em>something else</em> was wrong upstream — it wasn&rsquo;t even getting to the signature.</p>
<p>I went back to the source. <code>session_store.go</code> → <code>NewCookieSessionStore</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="nx">cipher</span><span class="p">,</span><span class="w"> </span><span class="nx">err</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">encryption</span><span class="p">.</span><span class="nf">NewCFBCipher</span><span class="p">(</span><span class="nx">encryption</span><span class="p">.</span><span class="nf">SecretBytes</span><span class="p">(</span><span class="nx">secret</span><span class="p">))</span><span class="w">
</span></span></span></code></pre></div><p><strong>AES-CFB. Not GCM.</strong> The cookie session store uses CFB. GCM exists in the codebase for a different purpose (the Redis ticket store, which I hadn&rsquo;t discovered yet). I had been encrypting with the wrong cipher the entire time.</p>
<p>And <code>SecretBytes</code> — a function I&rsquo;d been reading but not understanding:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="kd">func</span><span class="w"> </span><span class="nf">SecretBytes</span><span class="p">(</span><span class="nx">secret</span><span class="w"> </span><span class="kt">string</span><span class="p">)</span><span class="w"> </span><span class="p">[]</span><span class="kt">byte</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">b</span><span class="p">,</span><span class="w"> </span><span class="nx">err</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">base64</span><span class="p">.</span><span class="nx">RawURLEncoding</span><span class="p">.</span><span class="nf">DecodeString</span><span class="p">(</span><span class="nx">strings</span><span class="p">.</span><span class="nf">TrimRight</span><span class="p">(</span><span class="nx">secret</span><span class="p">,</span><span class="w"> </span><span class="s">&#34;=&#34;</span><span class="p">))</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="nx">err</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="kc">nil</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">for</span><span class="w"> </span><span class="nx">_</span><span class="p">,</span><span class="w"> </span><span class="nx">i</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="k">range</span><span class="w"> </span><span class="p">[]</span><span class="kt">int</span><span class="p">{</span><span class="mi">16</span><span class="p">,</span><span class="w"> </span><span class="mi">24</span><span class="p">,</span><span class="w"> </span><span class="mi">32</span><span class="p">}</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="k">if</span><span class="w"> </span><span class="nb">len</span><span class="p">(</span><span class="nx">b</span><span class="p">)</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="nx">i</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span><span class="k">return</span><span class="w"> </span><span class="nx">b</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="p">[]</span><span class="nb">byte</span><span class="p">(</span><span class="nx">secret</span><span class="p">)</span><span class="w">  </span><span class="c1">// fallback: raw string</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p>The cookie secret <code>q7OF9sK2/Pnt9QKNoBBmxWRL3GAbWzvj</code> contains <code>/</code>. That&rsquo;s valid standard base64 but not URL-safe base64 — <code>RawURLEncoding</code> fails. Fallback to raw string: 32 bytes, valid AES-256 key. My Python test had used standard base64 decoding, which <em>did</em> succeed (and produced a different 24-byte key). My Go implementation had done the same. Both were deriving the wrong key.</p>
<p>I rewrote the cipher to AES-CFB with the raw-string key. New test. Same error. Still rejecting.</p>
<hr>
<h2 id="messagepack-and-lz4">MessagePack and LZ4</h2>
<p>Back to the source. <code>EncodeSessionState</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="kd">func</span><span class="w"> </span><span class="p">(</span><span class="nx">s</span><span class="w"> </span><span class="o">*</span><span class="nx">SessionState</span><span class="p">)</span><span class="w"> </span><span class="nf">EncodeSessionState</span><span class="p">(</span><span class="nx">c</span><span class="w"> </span><span class="nx">encryption</span><span class="p">.</span><span class="nx">Cipher</span><span class="p">,</span><span class="w"> </span><span class="nx">compress</span><span class="w"> </span><span class="kt">bool</span><span class="p">)</span><span class="w"> </span><span class="p">([]</span><span class="kt">byte</span><span class="p">,</span><span class="w"> </span><span class="kt">error</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">packed</span><span class="p">,</span><span class="w"> </span><span class="nx">err</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">msgpack</span><span class="p">.</span><span class="nf">Marshal</span><span class="p">(</span><span class="nx">s</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c1">// ...</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">compressed</span><span class="p">,</span><span class="w"> </span><span class="nx">err</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nf">lz4Compress</span><span class="p">(</span><span class="nx">packed</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c1">// ...</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="nx">c</span><span class="p">.</span><span class="nf">Encrypt</span><span class="p">(</span><span class="nx">compressed</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p><strong>MessagePack. LZ4 compression. Then AES-CFB.</strong></p>
<p>I had been encrypting raw JSON. The whole time.</p>
<p>The struct tags confirmed it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="kd">type</span><span class="w"> </span><span class="nx">SessionState</span><span class="w"> </span><span class="kd">struct</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">CreatedAt</span><span class="w"> </span><span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="w"> </span><span class="s">`msgpack:&#34;ca,omitempty&#34;`</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">ExpiresOn</span><span class="w"> </span><span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="w"> </span><span class="s">`msgpack:&#34;eo,omitempty&#34;`</span><span class="w">  </span><span class="c1">// &#34;eo&#34;, not &#34;ea&#34; as I&#39;d assumed</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">AccessToken</span><span class="w"> </span><span class="kt">string</span><span class="w">   </span><span class="s">`msgpack:&#34;at,omitempty&#34;`</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">Email</span><span class="w">      </span><span class="kt">string</span><span class="w">    </span><span class="s">`msgpack:&#34;e,omitempty&#34;`</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">User</span><span class="w">       </span><span class="kt">string</span><span class="w">    </span><span class="s">`msgpack:&#34;u,omitempty&#34;`</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p>Even the ExpiresOn field name was different from what I&rsquo;d guessed (<code>&quot;eo&quot;</code> not <code>&quot;ea&quot;</code>).</p>
<p>I added the <code>vmihailenco/msgpack</code> and <code>pierrec/lz4</code> dependencies, rewrote the encoding pipeline: msgpack → lz4 → AES-CFB(raw-string key) → base64url(encrypted) → sign with HMAC.</p>
<p>Ran the curl test. <strong>HTTP 200.</strong></p>
<p>After three days and four complete rewrites of the encoding logic, oauth2-proxy accepted the forged session.</p>
<hr>
<h2 id="the-access-token-problem">The access token problem</h2>
<p>Celebrating was premature. The browser test worked from curl, but real ForwardAuth requests kept failing intermittently. Looking at the logs:</p>
<pre tabindex="0"><code>Error loading cookied session: session is invalid
</code></pre><p>This came from <code>validateSession</code> in the storedSessionLoader — after successfully loading the session, it was calling the provider&rsquo;s <code>ValidateSession</code> method and getting false back. I checked the GitLab provider:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="kd">func</span><span class="w"> </span><span class="p">(</span><span class="nx">p</span><span class="w"> </span><span class="o">*</span><span class="nx">GitLabProvider</span><span class="p">)</span><span class="w"> </span><span class="nf">ValidateSession</span><span class="p">(</span><span class="nx">ctx</span><span class="w"> </span><span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span><span class="w"> </span><span class="nx">s</span><span class="w"> </span><span class="o">*</span><span class="nx">sessions</span><span class="p">.</span><span class="nx">SessionState</span><span class="p">)</span><span class="w"> </span><span class="kt">bool</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="nf">validateToken</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span><span class="w"> </span><span class="nx">p</span><span class="p">,</span><span class="w"> </span><span class="nx">s</span><span class="p">.</span><span class="nx">AccessToken</span><span class="p">,</span><span class="w"> </span><span class="nf">makeOIDCHeader</span><span class="p">(</span><span class="nx">s</span><span class="p">.</span><span class="nx">IDToken</span><span class="p">))</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p>oauth2-proxy calls GitLab&rsquo;s <code>/oauth/token/info</code> endpoint with the access token to verify the session is still active. My forged session had an empty <code>AccessToken</code> field. Empty access token → <code>validateToken</code> returns false immediately → session rejected.</p>
<p>The fix: during the phone&rsquo;s GitLab OAuth flow, <code>exchangeCode</code> was already calling GitLab&rsquo;s token endpoint and receiving an access token, but I&rsquo;d been discarding it. I changed the function signature to return it, stored it in the session, included it in the forged session&rsquo;s <code>at</code> field.</p>
<p>The token was issued for my qr-auth GitLab app, not oauth2-proxy&rsquo;s app. But GitLab&rsquo;s <code>/oauth/token/info</code> endpoint doesn&rsquo;t check the issuing application — it just validates the token is active and returns 200. oauth2-proxy only checks for a 200 response. The token worked.</p>
<p>Everything worked.</p>
<hr>
<h2 id="the-end-session-problem--three-attempts">The End Session problem — three attempts</h2>
<h3 id="attempt-1-delete-qr_session-lock-the-qr-page">Attempt 1: Delete qr_session, lock the QR page</h3>
<p>The first End Session implementation deleted the <code>qr_session</code> key from Redis. To make this actually lock the screen, I restored the Homer proxy at <code>qr.hippotion.com</code> — the TV would show Homer via an ExternalName Kubernetes service pointing at the Homer pod, guarded by a Traefik ForwardAuth middleware that checked the <code>qr_session</code> cookie. Homer makes status API calls every ~30 seconds, which re-triggered ForwardAuth, and deleting <code>qr_session</code> meant the screen would lock within 30 seconds automatically.</p>
<p>This worked for <code>qr.hippotion.com</code>, but the <code>_oauth2_proxy</code> cookie was stateless — a signed, self-contained encrypted blob in the browser. There was no server-side record to delete. Other apps (<code>argo.hippotion.com</code>, <code>grafana.hippotion.com</code>, etc.) kept working until the 8-hour cookie expiry.</p>
<p>The TV screen was locked. The session wasn&rsquo;t.</p>
<h3 id="attempt-2-shorter-cookie-ttl">Attempt 2: Shorter cookie TTL</h3>
<p>The tempting quick fix: reduce the forged cookie&rsquo;s TTL from 8 hours to something shorter, like 30 minutes. End Session would lock the TV immediately. Other apps would expire within 30 minutes on their own.</p>
<p>Rejected. 30 minutes of residual access on a shared TV is too long, and the TTL is arbitrary — it doesn&rsquo;t match what End Session is supposed to mean.</p>
<h3 id="attempt-3-redis-backed-oauth2-proxy-sessions">Attempt 3: Redis-backed oauth2-proxy sessions</h3>
<p>The correct fix is what oauth2-proxy calls <em>persistence tickets</em>. Instead of encoding the entire session into the cookie, oauth2-proxy stores the session in Redis and puts only a ticket reference in the cookie. When the ticket is deleted from Redis, the session is gone on the next request.</p>
<p>The ticket format, from <code>pkg/sessions/persistence/ticket.go</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="c1">// ticketID format: &#34;_oauth2_proxy-&lt;hex(16 random bytes)&gt;&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">ticketID</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">fmt</span><span class="p">.</span><span class="nf">Sprintf</span><span class="p">(</span><span class="s">&#34;%s-%s&#34;</span><span class="p">,</span><span class="w"> </span><span class="nx">cookieOpts</span><span class="p">.</span><span class="nx">Name</span><span class="p">,</span><span class="w"> </span><span class="nx">hex</span><span class="p">.</span><span class="nf">EncodeToString</span><span class="p">(</span><span class="nx">rawID</span><span class="p">))</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c1">// ticket string in the cookie: &#34;v2.&lt;base64url(ticketID)&gt;.&lt;base64url(ticketSecret)&gt;&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kd">func</span><span class="w"> </span><span class="p">(</span><span class="nx">t</span><span class="w"> </span><span class="o">*</span><span class="nx">ticket</span><span class="p">)</span><span class="w"> </span><span class="nf">encodeTicket</span><span class="p">()</span><span class="w"> </span><span class="kt">string</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="nx">fmt</span><span class="p">.</span><span class="nf">Sprintf</span><span class="p">(</span><span class="s">&#34;v2.%s.%s&#34;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nx">base64</span><span class="p">.</span><span class="nx">RawURLEncoding</span><span class="p">.</span><span class="nf">EncodeToString</span><span class="p">([]</span><span class="nb">byte</span><span class="p">(</span><span class="nx">t</span><span class="p">.</span><span class="nx">id</span><span class="p">)),</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nx">base64</span><span class="p">.</span><span class="nx">RawURLEncoding</span><span class="p">.</span><span class="nf">EncodeToString</span><span class="p">(</span><span class="nx">t</span><span class="p">.</span><span class="nx">secret</span><span class="p">))</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c1">// session stored in Redis, encrypted with the *ticket* secret (not the cookie secret)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kd">func</span><span class="w"> </span><span class="p">(</span><span class="nx">t</span><span class="w"> </span><span class="o">*</span><span class="nx">ticket</span><span class="p">)</span><span class="w"> </span><span class="nf">saveSession</span><span class="p">(</span><span class="nx">s</span><span class="w"> </span><span class="o">*</span><span class="nx">sessions</span><span class="p">.</span><span class="nx">SessionState</span><span class="p">,</span><span class="w"> </span><span class="nx">saver</span><span class="w"> </span><span class="nx">saveFunc</span><span class="p">)</span><span class="w"> </span><span class="kt">error</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">c</span><span class="p">,</span><span class="w"> </span><span class="nx">err</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">encryption</span><span class="p">.</span><span class="nf">NewGCMCipher</span><span class="p">(</span><span class="nx">t</span><span class="p">.</span><span class="nx">secret</span><span class="p">)</span><span class="w">  </span><span class="c1">// GCM, not CFB</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c1">// ...</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">ciphertext</span><span class="p">,</span><span class="w"> </span><span class="nx">err</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">s</span><span class="p">.</span><span class="nf">EncodeSessionState</span><span class="p">(</span><span class="nx">c</span><span class="p">,</span><span class="w"> </span><span class="kc">false</span><span class="p">)</span><span class="w">  </span><span class="c1">// msgpack, NO lz4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="nf">saver</span><span class="p">(</span><span class="nx">t</span><span class="p">.</span><span class="nx">id</span><span class="p">,</span><span class="w"> </span><span class="nx">ciphertext</span><span class="p">,</span><span class="w"> </span><span class="nx">t</span><span class="p">.</span><span class="nx">options</span><span class="p">.</span><span class="nx">Expire</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p>This is a completely different format from the cookie session:</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Cookie session</th>
					<th>Redis session (ticket)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Cipher</td>
					<td>AES-CFB</td>
					<td>AES-128-GCM</td>
			</tr>
			<tr>
					<td>Key</td>
					<td>cookie secret (raw string)</td>
					<td>per-session ticket secret</td>
			</tr>
			<tr>
					<td>Serialization</td>
					<td>msgpack</td>
					<td>msgpack</td>
			</tr>
			<tr>
					<td>Compression</td>
					<td>lz4</td>
					<td><strong>none</strong></td>
			</tr>
			<tr>
					<td>Storage</td>
					<td>in the cookie</td>
					<td>Redis, keyed by ticket ID</td>
			</tr>
			<tr>
					<td>Revocable</td>
					<td>no</td>
					<td>yes</td>
			</tr>
	</tbody>
</table>
<p>I rewrote the session creation to generate a random ticket ID and secret, encrypt the msgpack session with AES-GCM using the ticket secret, store it in Redis, and set the signed ticket reference as the <code>_oauth2_proxy</code> cookie.</p>
<p>I stored the ticket ID alongside the <code>qr_session</code> in Redis:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;email&#34;</span><span class="p">:</span> <span class="s2">&#34;user@example.com&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;username&#34;</span><span class="p">:</span> <span class="s2">&#34;username&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;access_token&#34;</span><span class="p">:</span> <span class="s2">&#34;...&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;oauth2_ticket_id&#34;</span><span class="p">:</span> <span class="s2">&#34;_oauth2_proxy-eeeb18501625dee77f344c0a6193d0bc&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>End Session now does two Redis deletes:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="kd">func</span><span class="w"> </span><span class="nf">handleLogout</span><span class="p">(</span><span class="nx">w</span><span class="w"> </span><span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span><span class="w"> </span><span class="nx">r</span><span class="w"> </span><span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">sessionID</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">r</span><span class="p">.</span><span class="nf">FormValue</span><span class="p">(</span><span class="s">&#34;session_id&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">ctx</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">r</span><span class="p">.</span><span class="nf">Context</span><span class="p">()</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="nx">raw</span><span class="p">,</span><span class="w"> </span><span class="nx">err</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">rdb</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span><span class="w"> </span><span class="s">&#34;session:&#34;</span><span class="o">+</span><span class="nx">sessionID</span><span class="p">).</span><span class="nf">Result</span><span class="p">();</span><span class="w"> </span><span class="nx">err</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="kc">nil</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="kd">var</span><span class="w"> </span><span class="nx">sd</span><span class="w"> </span><span class="nx">sessionData</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">if</span><span class="w"> </span><span class="nx">json</span><span class="p">.</span><span class="nf">Unmarshal</span><span class="p">([]</span><span class="nb">byte</span><span class="p">(</span><span class="nx">raw</span><span class="p">),</span><span class="w"> </span><span class="o">&amp;</span><span class="nx">sd</span><span class="p">)</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="kc">nil</span><span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span><span class="nx">sd</span><span class="p">.</span><span class="nx">OAuth2TicketID</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="s">&#34;&#34;</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nx">rdb</span><span class="p">.</span><span class="nf">Del</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span><span class="w"> </span><span class="nx">sd</span><span class="p">.</span><span class="nx">OAuth2TicketID</span><span class="p">)</span><span class="w">  </span><span class="c1">// kills oauth2-proxy session</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">rdb</span><span class="p">.</span><span class="nf">Del</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span><span class="w"> </span><span class="s">&#34;session:&#34;</span><span class="o">+</span><span class="nx">sessionID</span><span class="p">)</span><span class="w">  </span><span class="c1">// kills qr session</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p>I configured oauth2-proxy to use Redis session storage pointing at the same Redis instance, added the Cilium network policy to allow ingress from the oauth2-proxy namespace, and removed the Homer proxy from <code>qr.hippotion.com</code> — it was no longer needed.</p>
<p>One final gotcha: <code>session_store_type = &quot;redis&quot;</code> in oauth2-proxy&rsquo;s legacy config file does nothing. There&rsquo;s no error, no warning. It silently ignores the option. The flag only works when passed as an actual CLI argument via <code>extraArgs</code> in the Helm chart values:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">extraArgs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">session-store-type</span><span class="p">:</span><span class="w"> </span><span class="l">redis</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">redis-connection-url</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;redis://qr-auth-redis:6379&#34;</span><span class="w">
</span></span></span></code></pre></div><p>After that, End Session worked correctly. Phone taps the button, ticket is deleted from Redis, the next ForwardAuth request for any app on the domain immediately redirects to the QR lock screen.</p>
<hr>
<h2 id="what-the-final-architecture-looks-like">What the final architecture looks like</h2>
<pre tabindex="0"><code>Phone: scan QR
  → /device?token=xxx → intermediate page (&#34;Continue with GitLab&#34;)
  → GitLab OAuth on phone (already logged in → direct callback)
  → /callback: exchange code → get email + access token
  → create Redis ticket: AES-128-GCM(msgpack(session), ticketSecret)
  → store ticket in Redis at &#34;_oauth2_proxy-&lt;hex&gt;&#34;
  → mark device token as authed, store ticketID in qr session

TV: poll fires
  → read qr session from Redis (has email, accessToken, ticketID)
  → set _oauth2_proxy cookie: signed ticket reference
  → set qr_session cookie
  → redirect to home.hippotion.com

Any protected app (home, argo, grafana, ...):
  → Traefik ForwardAuth → oauth2-proxy
  → oauth2-proxy reads _oauth2_proxy cookie → decodes ticket
  → looks up &#34;_oauth2_proxy-&lt;hex&gt;&#34; in Redis → decrypts session
  → validates email, access token → 200 OK

Phone: &#34;End Session&#34;
  → POST /logout with session_id
  → delete &#34;session:&lt;id&gt;&#34; from Redis (qr session gone)
  → delete &#34;_oauth2_proxy-&lt;hex&gt;&#34; from Redis (oauth2 ticket gone)
  → next ForwardAuth on TV: Redis lookup fails → redirect to login
</code></pre><p>The intermediate page on the phone (&ldquo;Continue with GitLab&rdquo; button instead of auto-redirect) was an unexpected requirement. Mobile browsers opened by the camera app often don&rsquo;t share sessions with the browser where GitLab is logged in. When you auto-redirect to GitLab in a browser with no existing session, GitLab redirects to the sign-in page. The OAuth state is stored in a session cookie that GitLab sets during the initial authorize request. On mobile, the sign-in form submission can lose this cookie due to SameSite restrictions — after sign-in, GitLab can&rsquo;t resume the OAuth flow and falls back to <code>/users/sign_in</code> with no further redirect. The intermediate page gives the user a visible moment to confirm they&rsquo;re in a browser with an active GitLab session before initiating the OAuth redirect.</p>
<hr>
<h2 id="lessons">Lessons</h2>
<p><strong>Read the source, not the docs.</strong> The docs say &ldquo;AES encryption&rdquo; without specifying the mode or how the key is derived. The source has the answer in twenty lines.</p>
<p><strong>Test at the boundary.</strong> The curl test against the ForwardAuth endpoint was the most valuable debugging step. It isolated exactly which layer was failing and gave me the real error message instead of a browser redirect loop. Without it, I&rsquo;d still be guessing.</p>
<p><strong>Format assumptions are fragile.</strong> I assumed JSON because JSON is the default for everything. oauth2-proxy uses MessagePack because it produces smaller cookies. LZ4 because it decompresses fast. AES-CFB because that&rsquo;s what was chosen when the code was written. None of this is unreasonable, but none of it is obvious from the outside.</p>
<p><strong>Two formats, same codebase.</strong> Cookie sessions and Redis ticket sessions use different ciphers, different compression, different key derivation. The GCM cipher I found first is correct — but for Redis sessions, not cookie sessions. The CFB cipher is for cookie sessions. I had the right code in the wrong place.</p>
<p><strong>Config files can silently ignore options.</strong> <code>session_store_type = &quot;redis&quot;</code> in oauth2-proxy&rsquo;s legacy config file does nothing. <code>--session-store-type=redis</code> on the command line works. No error, no warning, no indication that the option was parsed but not applied.</p>
<p><strong>Revocability requires server-side state.</strong> A self-contained encrypted cookie cannot be revoked without adding a denylist (which has its own scaling problems). If you need End Session to mean something, you need a server-side session store. oauth2-proxy supports Redis sessions precisely for this reason — the ticket design is clean and the revocation path is a single Redis delete.</p>
<p>The code is at <a href="https://github.com/janos-gyorgy/qr-device-login">github.com/janos-gyorgy/qr-device-login</a>.</p>
]]></content:encoded></item></channel></rss>