<?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: Benoni Tshibamba</title>
    <description>The latest articles on DEV Community by Benoni Tshibamba (@benoni_tshibamba_d539cc45).</description>
    <link>https://dev.to/benoni_tshibamba_d539cc45</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%2F4165292%2F441c997a-449d-4575-a460-721f5a4fa370.png</url>
      <title>DEV Community: Benoni Tshibamba</title>
      <link>https://dev.to/benoni_tshibamba_d539cc45</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/benoni_tshibamba_d539cc45"/>
    <language>en</language>
    <item>
      <title>Refresh Token Rotation: The Security Feature Most Auth Tutorials Skip</title>
      <dc:creator>Benoni Tshibamba</dc:creator>
      <pubDate>Tue, 06 Oct 2026 04:58:19 +0000</pubDate>
      <link>https://dev.to/benoni_tshibamba_d539cc45/refresh-token-rotation-the-security-feature-most-auth-tutorials-skip-2pg7</link>
      <guid>https://dev.to/benoni_tshibamba_d539cc45/refresh-token-rotation-the-security-feature-most-auth-tutorials-skip-2pg7</guid>
      <description>&lt;h1&gt;
  
  
  Refresh Token Rotation: The Security Feature Most Auth Tutorials Skip
&lt;/h1&gt;

&lt;p&gt;Most JWT tutorials end with "here's your token, store it in localStorage, you're done." That token usually lives for days or weeks. If anyone steals it — XSS, a leaked log, a compromised dependency — they &lt;em&gt;are&lt;/em&gt; you until it expires. Here's the pattern production systems use instead, and how I implemented it in my npm package &lt;code&gt;express-jwt-refresh&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two-token system
&lt;/h2&gt;

&lt;p&gt;Instead of one long-lived token, you issue two:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Access token&lt;/strong&gt; — a JWT that lives ~15 minutes. The client sends it as &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt; with every request. Short life means a stolen one is nearly useless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh token&lt;/strong&gt; — an opaque random string that lives ~7 days. It lives in an &lt;code&gt;httpOnly&lt;/code&gt;, &lt;code&gt;SameSite=Lax&lt;/code&gt; cookie that JavaScript can't touch, so XSS can't steal it. Its only job: minting new access tokens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The client keeps the access token in memory. When it expires, the app calls &lt;code&gt;POST /refresh&lt;/code&gt; — the cookie goes along automatically — and gets a fresh access token. The user stays logged in for a week without retyping a password.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rotation: use it once, lose it
&lt;/h2&gt;

&lt;p&gt;Here's the part tutorials skip. Every time a refresh token is used, &lt;strong&gt;it's destroyed and replaced with a new one&lt;/strong&gt;. The client always holds the newest token in the chain.&lt;/p&gt;

&lt;p&gt;Why? Because it turns theft into a detectable event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reuse detection: the bank-card trick
&lt;/h2&gt;

&lt;p&gt;Think about what happens when a thief steals a refresh token:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Thief uses the stolen token → gets a fresh pair. The &lt;em&gt;original&lt;/em&gt; token is now consumed.&lt;/li&gt;
&lt;li&gt;The legitimate user's app tries to refresh with that same original token.&lt;/li&gt;
&lt;li&gt;The server sees a token that was already consumed. A legitimate client can never do this — it always moves forward to the newest token.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the signal. Like a bank seeing your card used in two countries at once, the server treats it as theft: &lt;strong&gt;the entire token family is revoked&lt;/strong&gt;. Every session for that user dies. The thief gets exactly one use; the real user just logs in again.&lt;/p&gt;

&lt;p&gt;Implementation-wise, you keep tokens in "families" — every rotation stays in the same family ID. When a token arrives that isn't in the store, you check whether it belongs to a rotated family. If yes, revoke the family. Done.&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="c1"&gt;// refresh handler (simplified)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sha256Hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;presentedToken&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;familyId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rotatedLedger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hashPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;presentedToken&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="nx"&gt;familyId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deleteByFamily&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;familyId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// suspected theft&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid refresh token.&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;// rotate: consume old, issue new in the same family&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tokenHash&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;rotatedLedger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hashPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;presentedToken&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;familyId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;randomToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;tokenHash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;familyId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;familyId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Store only hashes.&lt;/strong&gt; The database holds SHA-256 of refresh tokens, never the tokens themselves — a DB leak doesn't hand out sessions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh tokens are opaque&lt;/strong&gt;, not JWTs. JWTs can't be revoked without a denylist; random strings can, because the store is the source of truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access tokens stay stateless.&lt;/strong&gt; No denylist needed — just keep the TTL short.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The ledger is bounded.&lt;/strong&gt; In multi-process deployments, persist it in your shared store.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I shipped
&lt;/h2&gt;

&lt;p&gt;I packaged this as &lt;a href="https://www.npmjs.com/package/express-jwt-refresh" rel="noopener noreferrer"&gt;&lt;code&gt;express-jwt-refresh&lt;/code&gt;&lt;/a&gt; — drop-in Express middleware:&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createAuth&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express-jwt-refresh&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createAuth&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;JWT_SECRET&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/refresh&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/profile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus a pluggable token store (in-memory, SQLite, or your own Redis/Postgres), bcrypt helpers, a role guard, and 12 passing tests including one that simulates the theft scenario end to end.&lt;/p&gt;

&lt;p&gt;And because I kept rebuilding this same stack, I also shipped &lt;a href="https://www.npmjs.com/package/create-bb24-app" rel="noopener noreferrer"&gt;&lt;code&gt;create-bb24-app&lt;/code&gt;&lt;/a&gt; — &lt;code&gt;npx create-bb24-app my-app&lt;/code&gt; scaffolds the whole thing (Express + SQLite + this auth + React/Vite + Docker) in one command.&lt;/p&gt;

&lt;p&gt;Auth is one of those things that's easy to do and easy to do &lt;em&gt;wrong&lt;/em&gt;. Rotation plus reuse detection is a small amount of code for a large amount of safety. Steal the pattern even if you don't use the package.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;BUILT WITH LOVE AND CAFFEINE BY BB24&lt;/em&gt;&lt;/p&gt;

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