<?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>Forem: Dmytro</title>
    <description>The latest articles on Forem by Dmytro (@kiwidevelopment).</description>
    <link>https://forem.com/kiwidevelopment</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3875576%2Fb7e095a8-10da-4b37-aa2a-2155d5aa6b80.png</url>
      <title>Forem: Dmytro</title>
      <link>https://forem.com/kiwidevelopment</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/kiwidevelopment"/>
    <language>en</language>
    <item>
      <title>If your refresh token gets stolen, rotation alone won't save you — here's what does</title>
      <dc:creator>Dmytro</dc:creator>
      <pubDate>Thu, 23 Apr 2026 16:49:31 +0000</pubDate>
      <link>https://forem.com/kiwidevelopment/if-your-refresh-token-gets-stolen-rotation-alone-wont-save-you-heres-what-does-1f7n</link>
      <guid>https://forem.com/kiwidevelopment/if-your-refresh-token-gets-stolen-rotation-alone-wont-save-you-heres-what-does-1f7n</guid>
      <description>&lt;p&gt;Standard advice for refresh tokens is pretty straightforward:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;rotate on every use&lt;/li&gt;
&lt;li&gt;store them hashed&lt;/li&gt;
&lt;li&gt;keep expiry short&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Done, right?&lt;br&gt;
Not quite.&lt;br&gt;
Rotation alone does nothing against token theft. If malware or XSS lifts a refresh token from a legit client, the attacker and the client effectively race to rotate it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;whoever loses the race gets a "token revoked" error&lt;/li&gt;
&lt;li&gt;whoever wins keeps the session alive
From the server’s point of view, it just sees two valid requests seconds apart.
No alarm.
No signal.
Nothing.
---
What’s missing
The missing piece is what OAuth 2.0 Security BCP §4.14 calls:
&amp;gt; &lt;strong&gt;Refresh token reuse detection&lt;/strong&gt;
If a token that was already rotated is presented again, you treat it as evidence of compromise and invalidate the entire session.
---
The core idea
Every refresh token belongs to a family (&lt;code&gt;FamilyId&lt;/code&gt;) — all tokens derived from a single login share it.
If a rotated token shows up again (outside a small grace window), you revoke the entire family:
the attacker is locked out
the legit user is forced to re-authenticate
the session is no longer silently compromised
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReplacedByTokenHash&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RevokedAtUtc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;withinGrace&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RevokedAtUtc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;graceSeconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&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="n"&gt;withinGrace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"token_recently_rotated"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// benign race (SPA tabs, retries)&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;RevokeFamilyAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FamilyId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"reuse_detected"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"token_reuse_detected"&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;p&gt;Client-side impact&lt;br&gt;
From the client perspective, this is just one extra branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;token_reuse_detected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// "You've been signed out for security reasons. Please log in again."&lt;/span&gt;
  &lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/login?reason=compromised&lt;/span&gt;&lt;span class="dl"&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;p&gt;Observability (optional but useful)&lt;br&gt;
If you want visibility into these events (alerts, logging, SIEM), you can expose a hook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IAuthEventSink&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SlackAlertSink&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tricky parts&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Race vs theft look identical
Two requests with the same token arrive.
one is legitimate
one might be malicious
The only difference is timing.
Trade-off:
grace window too small → false positives (bad UX)
grace window too large → bigger attack window
~30 seconds worked well in practice.&lt;/li&gt;
&lt;li&gt;Revoking the whole chain
On reuse, you must invalidate all still-active tokens from that session.
Using a &lt;code&gt;FamilyId&lt;/code&gt; + index makes this a single bulk operation.&lt;/li&gt;
&lt;li&gt;Concurrency is common
This was more frequent than expected:
multi-tab SPAs
retry logic
mobile reconnects
Without a grace window, even normal usage can trigger false positives.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;I originally assumed refresh token rotation was “enough”. It isn’t.&lt;br&gt;
Without reuse detection, the server has no way to distinguish between normal rotation and a replayed (potentially stolen) token — and learns nothing from it. Adding reuse detection turned out to be a relatively small change, but it closes a much more serious gap than I expected.&lt;br&gt;
I ended up implementing this in a small self-hosted auth library I’ve been working on. Curious how others handle this — especially around race conditions and trade-offs between grace windows vs optimistic concurrency.&lt;br&gt;
Implementation is here: &lt;a href="https://github.com/KiwiDevelopment/KiwiAuth" rel="noopener noreferrer"&gt;https://github.com/KiwiDevelopment/KiwiAuth&lt;/a&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>security</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
