<?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: SHUBHAM LAKHERA</title>
    <description>The latest articles on DEV Community by SHUBHAM LAKHERA (@shubhamlakheraa).</description>
    <link>https://dev.to/shubhamlakheraa</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%2F726302%2Fe065e649-abf8-48b1-8d23-54ae86e07087.jpg</url>
      <title>DEV Community: SHUBHAM LAKHERA</title>
      <link>https://dev.to/shubhamlakheraa</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shubhamlakheraa"/>
    <language>en</language>
    <item>
      <title>[Boost]</title>
      <dc:creator>SHUBHAM LAKHERA</dc:creator>
      <pubDate>Sun, 28 Jun 2026 08:52:54 +0000</pubDate>
      <link>https://dev.to/shubhamlakheraa/-p5a</link>
      <guid>https://dev.to/shubhamlakheraa/-p5a</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/shubhamlakheraa/designing-auth-from-scratch-the-system-design-interview-you-can-actually-answer-end-to-end-3g15" class="crayons-story__hidden-navigation-link"&gt;Designing Auth From Scratch: The System Design Interview You Can Actually Answer End-to-End&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/shubhamlakheraa" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F726302%2Fe065e649-abf8-48b1-8d23-54ae86e07087.jpg" alt="shubhamlakheraa profile" class="crayons-avatar__image" width="400" height="400"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/shubhamlakheraa" class="crayons-story__secondary fw-medium m:hidden"&gt;
              SHUBHAM LAKHERA
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                SHUBHAM LAKHERA
                
              
              &lt;div id="story-author-preview-content-3986804" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/shubhamlakheraa" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F726302%2Fe065e649-abf8-48b1-8d23-54ae86e07087.jpg" class="crayons-avatar__image" alt="" width="400" height="400"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;SHUBHAM LAKHERA&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/shubhamlakheraa/designing-auth-from-scratch-the-system-design-interview-you-can-actually-answer-end-to-end-3g15" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Jun 25&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/shubhamlakheraa/designing-auth-from-scratch-the-system-design-interview-you-can-actually-answer-end-to-end-3g15" id="article-link-3986804"&gt;
          Designing Auth From Scratch: The System Design Interview You Can Actually Answer End-to-End
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag crayons-tag--filled  " href="/t/discuss"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;discuss&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/softwareengineering"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;softwareengineering&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/systemdesign"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;systemdesign&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/authentication"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;authentication&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/shubhamlakheraa/designing-auth-from-scratch-the-system-design-interview-you-can-actually-answer-end-to-end-3g15" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;1&lt;span class="hidden s:inline"&gt;&amp;nbsp;reaction&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/shubhamlakheraa/designing-auth-from-scratch-the-system-design-interview-you-can-actually-answer-end-to-end-3g15#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            17 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
    <item>
      <title>Designing Auth From Scratch: The System Design Interview You Can Actually Answer End-to-End</title>
      <dc:creator>SHUBHAM LAKHERA</dc:creator>
      <pubDate>Thu, 25 Jun 2026 08:24:22 +0000</pubDate>
      <link>https://dev.to/shubhamlakheraa/designing-auth-from-scratch-the-system-design-interview-you-can-actually-answer-end-to-end-3g15</link>
      <guid>https://dev.to/shubhamlakheraa/designing-auth-from-scratch-the-system-design-interview-you-can-actually-answer-end-to-end-3g15</guid>
      <description>&lt;p&gt;&lt;em&gt;From a naive login form to argon2id, rotating refresh tokens, MFA, RBAC, and horizontal scale — every decision mapped to the attack it stops. With a working Node.js + Postgres library you can clone.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Most "design authentication" answers stop at &lt;em&gt;"hash the password with bcrypt, hand back a JWT, done."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That sentence is where the interesting problems &lt;strong&gt;begin&lt;/strong&gt;, not where they end. What hash, with what parameters, and why? What's in the JWT, and what happens when you need to log someone out &lt;em&gt;before&lt;/em&gt; it expires? Where does the token live in the browser, and which attack does that choice invite? How does any of this survive being run on twelve machines behind a load balancer?&lt;/p&gt;

&lt;p&gt;I went and built the whole thing from scratch to find out — a production-grade auth library for Node.js + Express + PostgreSQL, raw &lt;code&gt;pg&lt;/code&gt;, no ORM, no framework doing the thinking for me. Call it &lt;strong&gt;oathkeeper&lt;/strong&gt;. The point wasn't to ship yet another auth package; it was to understand &lt;em&gt;the why&lt;/em&gt; behind every best practice by implementing each one and breaking it first.&lt;/p&gt;

&lt;p&gt;This post is that journey, structured the way you'd answer it on a whiteboard: scope the problem, walk the happy path, then attack each step until it's correct, assemble the layers, and scale it. Every defense maps to a named, real attack.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Want to build it alongside the article? The full source, migrations, tests, and docs are here: &lt;strong&gt;&lt;a href="https://github.com/shubhamlakheraa/oathkeeper" rel="noopener noreferrer"&gt;oathkeeper on GitHub&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 0 — Scope the question
&lt;/h2&gt;

&lt;p&gt;The web runs on HTTP, and HTTP is &lt;strong&gt;stateless&lt;/strong&gt;. Every request arrives with no memory of the last one. It's a coffee shop where the barista forgets you the instant you step away from the counter. So on &lt;em&gt;every&lt;/em&gt; request, the server has to answer two questions:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Q1 — Who is making this request?&lt;/strong&gt; → &lt;strong&gt;Authentication (AuthN)&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Q2 — Are they allowed to do this?&lt;/strong&gt; → &lt;strong&gt;Authorization (AuthZ)&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The airport analogy that makes this stick: at &lt;strong&gt;check-in&lt;/strong&gt;, they inspect your &lt;strong&gt;passport&lt;/strong&gt; to confirm you are who you claim — that's authentication. At the &lt;strong&gt;boarding gate&lt;/strong&gt;, they check your &lt;strong&gt;boarding pass&lt;/strong&gt; to confirm you're allowed on &lt;em&gt;this specific flight&lt;/em&gt; — that's authorization. You can be authenticated (we know who you are) and still not authorized (you can't board this flight). And you can never check authorization before authentication.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Mental model:&lt;/strong&gt; AuthN proves identity. AuthZ enforces policy. AuthN happens roughly once per session; AuthZ happens on every protected action.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A senior answer scopes both axes before drawing anything:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Functional&lt;/th&gt;
&lt;th&gt;Non-functional&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Signup, login, logout&lt;/td&gt;
&lt;td&gt;Security (assume an active attacker, not a polite one)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sessions that survive page reloads&lt;/td&gt;
&lt;td&gt;Low latency on the hot path (token checks)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MFA / second factor&lt;/td&gt;
&lt;td&gt;Horizontal scale (N stateless app servers)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Password reset &amp;amp; email verification&lt;/td&gt;
&lt;td&gt;Auditability (who did what, when, from where)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Role- and resource-level permissions&lt;/td&gt;
&lt;td&gt;Operability (bad config fails loud, at boot)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Keep that table on the board. Every decision below is in service of one of those cells.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1 — Walk the happy path first
&lt;/h2&gt;

&lt;p&gt;Before attacking anything, get the skeleton on the board: what does a normal user's lifetime look like?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5r1j6a3p86ev7g90edzc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5r1j6a3p86ev7g90edzc.png" alt="A user's full lifecycle: signup → login → access a protected route → token expiry → refresh → logout." width="800" height="610"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Six interactions: &lt;strong&gt;signup → login → access → expiry → refresh → logout.&lt;/strong&gt; Everything that follows is a deep dive into one of these joints and the ways an attacker tries to pry it open.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2 — Storing passwords (the part everyone gets wrong first)
&lt;/h2&gt;

&lt;p&gt;Build it from broken to correct.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 1 — plaintext.&lt;/strong&gt; &lt;code&gt;password = 'hunter2'&lt;/code&gt; in a column. One database leak and you've handed an attacker every user's password — and because people reuse passwords, their &lt;em&gt;other accounts&lt;/em&gt; too. Catastrophic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 2 — encrypt it.&lt;/strong&gt; Encryption is &lt;em&gt;reversible&lt;/em&gt; by design; if you can decrypt, so can whoever steals your key. The deeper insight: &lt;strong&gt;you should never need to know a user's password.&lt;/strong&gt; You only need to verify that the string typed at login matches the one set at signup. That's a one-way check, not a recovery problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 3 — hash it.&lt;/strong&gt; A hash is one-way: easy forward, infeasible backward. Better — but a &lt;em&gt;fast&lt;/em&gt; general-purpose hash (MD5, SHA-256) is the wrong tool. A modern GPU computes billions of SHA-256 hashes per second, so an attacker with your hash dump just brute-forces the space. And identical passwords produce identical hashes, so a precomputed &lt;strong&gt;rainbow table&lt;/strong&gt; cracks common passwords instantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 4 — salt + a slow, memory-hard hash.&lt;/strong&gt; Add a unique random &lt;strong&gt;salt&lt;/strong&gt; per user (kills rainbow tables — identical passwords now hash differently), and use a function that is &lt;em&gt;deliberately expensive in time and memory&lt;/em&gt; so each guess costs the attacker real money.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxgt1132swvol6liqircw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxgt1132swvol6liqircw.png" alt="Five steps from plaintext to argon2id, each broken by a named attack, color-coded red (broken) to green (correct)" width="676" height="1800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;oathkeeper uses &lt;strong&gt;argon2id&lt;/strong&gt; with OWASP 2023 parameters — 64 MB of memory, ~250 ms per hash. The memory cost is the point: GPUs are fast but memory-starved, so memory-hardness is what makes large-scale cracking economically infeasible. argon2 also embeds a random salt in every hash automatically, so there's nothing extra to manage.&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="nx"&gt;argon2&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;argon2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createArgon2Hasher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;argon2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argon2id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;memoryCost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;memoryCost&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;65536&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 64 MB — the GPU-resistance lever&lt;/span&gt;
    &lt;span class="na"&gt;timeCost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timeCost&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;parallelism&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parallelism&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plaintext&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;argon2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hash&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;argon2&lt;/span&gt;&lt;span class="p"&gt;.&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;hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// constant-time&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;The &lt;code&gt;users&lt;/code&gt; table is intentionally boring — note the soft-delete column, which matters later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;citext&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;             &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;email&lt;/span&gt;          &lt;span class="n"&gt;CITEXT&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&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;,&lt;/span&gt;        &lt;span class="c1"&gt;-- case-insensitive, no LOWER() everywhere&lt;/span&gt;
  &lt;span class="n"&gt;password_hash&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&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;,&lt;/span&gt;
  &lt;span class="n"&gt;email_verified&lt;/span&gt; &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;FALSE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;mfa_enabled&lt;/span&gt;    &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;FALSE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;mfa_secret&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;last_login_at&lt;/span&gt;  &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;     &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;deleted_at&lt;/span&gt;     &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;                    &lt;span class="c1"&gt;-- soft delete; the DB stays authoritative&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Reference: &lt;a href="https://dev.to{{REPO_URL}}"&gt;&lt;code&gt;adapters/hasher/&lt;/code&gt;&lt;/a&gt; and migration &lt;code&gt;001_users.sql&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 3 — Sessions vs. tokens (the fork that decides how you scale)
&lt;/h2&gt;

&lt;p&gt;Now we've authenticated someone — how do we &lt;em&gt;remember&lt;/em&gt; them across stateless requests?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A — server-side sessions.&lt;/strong&gt; On login, generate a random session ID, store the session server-side, hand the ID to the client as a cookie. Every request, look the ID up. Simple, and trivially revocable (delete the row). The catch is the lookup: the session lives on the server, so either every request hits a shared session store, or you pin users to one machine with sticky sessions. Both fight horizontal scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B — JWTs (stateless tokens).&lt;/strong&gt; Put the claims (&lt;code&gt;sub&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;exp&lt;/code&gt;) &lt;em&gt;in&lt;/em&gt; the token, sign it, and verify the signature on each request with zero storage lookups. Any server with the secret can validate it. This scales beautifully — and has one nasty property: a stateless token is valid until it expires, so &lt;strong&gt;you cannot revoke it&lt;/strong&gt;. Log a user out, ban an account, rotate a leaked token — the JWT keeps working until &lt;code&gt;exp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9fxenhcv7119t8e81jcz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9fxenhcv7119t8e81jcz.png" alt="Side-by-side comparison of how each stores identity, what the hot-path cost is, and the trade-off each carries." width="526" height="1800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The production answer takes both: a &lt;strong&gt;hybrid&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;short-lived access token&lt;/strong&gt; — a stateless JWT (HS256, ~15 min). It carries identity and authorizes the hot path with no DB lookup. Because it dies in minutes, an inability to revoke it is a small window.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;long-lived refresh token&lt;/strong&gt; — a &lt;em&gt;stateful&lt;/em&gt;, opaque random string (default 7 days), stored hashed in the DB. It does nothing but mint new access tokens, and because it's stateful, you &lt;em&gt;can&lt;/em&gt; revoke it the instant you need to.
Short-lived where you want speed, stateful where you need control. That split is the spine of the whole system.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 4 — The refresh-token problem (the part worth the whole article)
&lt;/h2&gt;

&lt;p&gt;A refresh token is a long-lived credential, so it's the prize an attacker actually wants. If they steal one, they can mint fresh access tokens for a week. Three defenses, layered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Defense 1 — hash at rest.&lt;/strong&gt; Never store the raw refresh token. Store &lt;code&gt;sha256(rawToken)&lt;/code&gt;. A database dump then contains hashes that are useless for authenticating — same logic as passwords, except refresh tokens are already high-entropy random, so a plain fast SHA-256 is enough (no argon2 needed).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Defense 2 — rotate on every use.&lt;/strong&gt; Each call to &lt;code&gt;/refresh&lt;/code&gt; invalidates the presented token and issues a brand-new one. A refresh token is single-use. This alone shrinks the value of a stolen token to a single window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Defense 3 — reuse detection with family revocation.&lt;/strong&gt; This is the clever part. Every token descended from one login shares a &lt;code&gt;family_id&lt;/code&gt;. When a token is rotated, it's marked used. If a token that has &lt;em&gt;already been rotated&lt;/em&gt; shows up again, exactly one thing has happened: there are two copies of it in the wild — the legitimate client's and a thief's. You can't tell which one you're looking at, so you &lt;strong&gt;revoke the entire family&lt;/strong&gt; and force a fresh login. The attacker's stolen copy self-destructs the moment either party uses it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn3okppefzs242a87wim9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn3okppefzs242a87wim9.png" alt="A legit client and an attacker both presenting the same stolen token, showing family revocation firing and the attacker gaining nothing" width="800" height="539"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The rotation runs inside a single Postgres transaction. If the process crashes between "revoke old" and "issue new," the whole thing rolls back — the old token stays valid and the user isn't locked out.&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;rotateRefreshToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withTransaction&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;tx&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;existing&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;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRefreshToken&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;rawToken&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;existing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvalidRefreshTokenError&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Reuse detection: an already-rotated/revoked token is being presented again.&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;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rotated_at&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;revoked_at&lt;/span&gt;&lt;span class="p"&gt;)&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;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;revokeRefreshTokenFamily&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;family_id&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;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;logEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="na"&gt;familyId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;family_id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RefreshTokenReuseError&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;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generateToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;              &lt;span class="c1"&gt;// new opaque secret&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertRefreshToken&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user_id&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;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;family_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;// stays in the same family&lt;/span&gt;
      &lt;span class="na"&gt;parentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;existing&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="c1"&gt;// rotation chain for forensics&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;expiresAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;addTtl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refreshTokenTtl&lt;/span&gt;&lt;span class="p"&gt;),&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;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rotateRefreshToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&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="c1"&gt;// atomic CAS: mark this one rotated&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="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user_id&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;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;logEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;token.refresh&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="na"&gt;userId&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="k"&gt;return&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="na"&gt;refreshToken&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="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;refresh_tokens&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;    &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;family_id&lt;/span&gt;  &lt;span class="n"&gt;UUID&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;,&lt;/span&gt;                       &lt;span class="c1"&gt;-- all tokens from one login&lt;/span&gt;
  &lt;span class="n"&gt;token_hash&lt;/span&gt; &lt;span class="nb"&gt;TEXT&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;,&lt;/span&gt;                        &lt;span class="c1"&gt;-- sha256(rawToken), never the raw value&lt;/span&gt;
  &lt;span class="n"&gt;parent_id&lt;/span&gt;  &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;refresh_tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;-- rotation chain&lt;/span&gt;
  &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&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;,&lt;/span&gt;
  &lt;span class="n"&gt;rotated_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                           &lt;span class="c1"&gt;-- set when used to mint a successor&lt;/span&gt;
  &lt;span class="n"&gt;revoked_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                           &lt;span class="c1"&gt;-- set on logout / family revocation&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;refresh_tokens&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_hash&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;refresh_tokens&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;family_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;One honest caveat.&lt;/strong&gt; A client on a flaky connection can receive a new token but fail to persist it, then retry with the old (now-rotated) one — which fires reuse detection and logs them out. Treat &lt;code&gt;REFRESH_REUSE_DETECTED&lt;/code&gt; as "ask the user to log in again," not as a confirmed breach. Naming this trade-off out loud is exactly the kind of nuance that separates a senior answer from a memorized one.&lt;/p&gt;

&lt;p&gt;Reference: &lt;a href="https://dev.to{{REPO_URL}}"&gt;&lt;code&gt;services/tokenService.js&lt;/code&gt;&lt;/a&gt; and migration &lt;code&gt;002_refresh_tokens.sql&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 5 — Where the token lives, and the CSRF tax
&lt;/h2&gt;

&lt;p&gt;You've got tokens. Where does the client keep them? Two modes, two threat profiles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Header mode (mobile / API / CLI).&lt;/strong&gt; Both tokens come back in the JSON body; the client holds the access token in memory and sends &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt;. No cookies, so &lt;strong&gt;no CSRF risk&lt;/strong&gt; — a forged cross-site request can't attach a header it can't read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cookie mode (browsers).&lt;/strong&gt; The refresh token is set as an &lt;code&gt;HttpOnly&lt;/code&gt;, &lt;code&gt;Secure&lt;/code&gt;, &lt;code&gt;SameSite=Lax&lt;/code&gt; cookie scoped to the refresh path, so JavaScript can't read it (that defeats token theft via XSS) and the browser only sends it to the one endpoint that needs it. The access token still lives in memory — &lt;strong&gt;never&lt;/strong&gt; &lt;code&gt;localStorage&lt;/code&gt;, which any injected script can read.&lt;/p&gt;

&lt;p&gt;Cookies buy XSS resistance but introduce &lt;strong&gt;CSRF&lt;/strong&gt;: the browser attaches that cookie automatically, even on a request forged by a malicious page. The fix is the &lt;strong&gt;double-submit cookie&lt;/strong&gt; pattern.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs24eu4abt5bt92910xc8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs24eu4abt5bt92910xc8.png" alt="A legit browser request succeeding with the echoed header alongside a forged cross-site request failing because Same-Origin Policy blocks reading the cookie" width="800" height="610"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The attacker's browser &lt;em&gt;sends&lt;/em&gt; the cookie but can't &lt;em&gt;read&lt;/em&gt; it (Same-Origin Policy), so it can't echo the matching header. The server compares header to cookie in constant time. Bearer requests skip the check entirely — they're not cookie-driven, not vulnerable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6 — Stop forged tokens (JWT hardening)
&lt;/h2&gt;

&lt;p&gt;A JWT is only as trustworthy as your verification. Three classic failures:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;alg: none&lt;/code&gt; attack (CVE-2015-9235).&lt;/strong&gt; Some libraries honor an &lt;code&gt;alg&lt;/code&gt; header of &lt;code&gt;none&lt;/code&gt; and accept &lt;em&gt;unsigned&lt;/em&gt; tokens — an attacker just edits the claims. The fix is to &lt;strong&gt;pin the algorithm&lt;/strong&gt; on verify and never trust the token's own header.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weak secret.&lt;/strong&gt; A short HS256 secret is brute-forceable offline against any captured token. oathkeeper refuses to boot unless &lt;code&gt;jwtSecret&lt;/code&gt; is ≥ 32 bytes of entropy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token confusion.&lt;/strong&gt; A token minted for one purpose shouldn't be reusable for another. The MFA challenge JWT carries &lt;code&gt;purpose: 'mfa_challenge'&lt;/code&gt;, and the MFA endpoint validates that claim — so a normal access token can't be used to walk past the second factor.
&lt;/li&gt;
&lt;/ol&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="nx"&gt;jwt&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;jsonwebtoken&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createJwtSigner&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;issuer&lt;/span&gt; &lt;span class="p"&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="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;byteLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&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;jwtSecret must be at least 32 bytes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// fail loud, at boot&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opts&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;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;algorithm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HS256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="na"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&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;jwt&lt;/span&gt;&lt;span class="p"&gt;.&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;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;algorithms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HS256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;issuer&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="c1"&gt;// pinned — alg:none rejected&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;
  
  
  Step 7 — Don't leak who exists (account enumeration)
&lt;/h2&gt;

&lt;p&gt;Your login endpoint can betray your users without ever being "hacked." If "no such account" and "wrong password" return &lt;em&gt;different&lt;/em&gt; messages — or take &lt;em&gt;different&lt;/em&gt; amounts of time — an attacker can map out exactly which emails are registered, which fuels targeted phishing and credential stuffing.&lt;/p&gt;

&lt;p&gt;Two defenses, working together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Identical response.&lt;/strong&gt; Both cases return the same &lt;code&gt;INVALID_CREDENTIALS&lt;/code&gt;. The client can't distinguish them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identical timing.&lt;/strong&gt; When the account doesn't exist there's no hash to verify, so a naive implementation returns &lt;em&gt;faster&lt;/em&gt; — a measurable tell. Fix: verify the supplied password against a precomputed &lt;strong&gt;dummy hash&lt;/strong&gt; even when no user is found, so the ~250 ms argon2 cost is paid either way.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&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;email&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;ctx&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;credential&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;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getCredentialByEmail&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="c1"&gt;// Spend the same ~250ms whether or not the account exists.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;password_hash&lt;/span&gt; &lt;span class="o"&gt;??&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;DUMMY_HASH&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;ok&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;hasher&lt;/span&gt;&lt;span class="p"&gt;.&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;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hash&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;credential&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&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;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;logEvent&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.failure&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="nx"&gt;email&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvalidCredentialsError&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// identical message for both cases&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...issue tokens, or return an MFA challenge&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same principle applies to &lt;code&gt;/password/reset/request&lt;/code&gt; and &lt;code&gt;/email/verify/request&lt;/code&gt;: they always return a generic &lt;code&gt;200&lt;/code&gt;, whether or not the email exists. The reset email is sent only if the account is real — but the &lt;em&gt;response&lt;/em&gt; never reveals that.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 8 — A second factor (MFA via TOTP)
&lt;/h2&gt;

&lt;p&gt;Passwords leak. A second factor from a &lt;em&gt;different category&lt;/em&gt; (something you &lt;em&gt;have&lt;/em&gt;, not another thing you &lt;em&gt;know&lt;/em&gt;) means a leaked password alone isn't enough. oathkeeper implements &lt;strong&gt;TOTP&lt;/strong&gt; (RFC 6238) — the 6-digit codes from Google Authenticator, Authy, 1Password.&lt;/p&gt;

&lt;p&gt;How TOTP works: server and client share a secret at enrollment. The code is &lt;code&gt;HOTP(secret, floor(currentUnixTime / 30))&lt;/code&gt; — both sides compute it independently from the shared secret and the clock, so nothing is transmitted. Enrollment is deliberately &lt;strong&gt;two-step&lt;/strong&gt; so a half-finished setup can't lock the user out of their own account.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwl2ahrk5rd5csi5t3p99.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwl2ahrk5rd5csi5t3p99.png" alt="Two-step enrollment (scan QR → confirm code → receive recovery codes) followed by the MFA-gated login flow with the purpose-scoped challenge token" width="800" height="739"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two subtleties most tutorials skip:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Replay protection.&lt;/strong&gt; A TOTP code is valid for a 30-second window, so an attacker who sniffs one could replay it within that window. oathkeeper tracks used codes in a replay store keyed by &lt;code&gt;{secretFingerprint}:{counter}&lt;/code&gt; — the fingerprint is a 16-char SHA-256 of the secret, so raw TOTP secrets never leak into the key namespace. A code accepted once is rejected for the rest of its window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recovery codes.&lt;/strong&gt; Lose your phone and you're locked out — so enrollment also returns 10 single-use backup codes. They're treated like passwords: &lt;strong&gt;argon2id-hashed at rest&lt;/strong&gt;, shown in plaintext exactly once.
&amp;gt; Reference: &lt;a href="https://dev.to{{REPO_URL}}"&gt;&lt;code&gt;utils/totp.js&lt;/code&gt;&lt;/a&gt; (the RFC 6238 implementation is validated against the spec's published Appendix D test vectors) and &lt;a href="https://dev.to{{REPO_URL}}"&gt;&lt;code&gt;services/mfaService.js&lt;/code&gt;&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 9 — Authorization, at last (RBAC + ABAC)
&lt;/h2&gt;

&lt;p&gt;We've spent eight steps on &lt;em&gt;who you are&lt;/em&gt;. Now: &lt;em&gt;what are you allowed to do?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RBAC (role-based) is the floor.&lt;/strong&gt; Users get roles; roles bundle permissions. "Is this user allowed to &lt;code&gt;doc:edit&lt;/code&gt;?" becomes "does any of their roles include the &lt;code&gt;doc:edit&lt;/code&gt; permission?" Clean and cacheable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbf31zvhod6o3pwilmvf7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbf31zvhod6o3pwilmvf7.png" alt="The two-gate check: RBAC permission is the floor, an optional policy can only narrow the result, never grant access" width="799" height="253"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But RBAC can't express &lt;em&gt;"can edit **this&lt;/em&gt;* document."* Roles don't know about individual resources. That's &lt;strong&gt;ABAC (attribute-based)&lt;/strong&gt; — rules that look at the resource itself. oathkeeper adds an optional policy registry on top of RBAC, with one inviolable rule: &lt;strong&gt;policies can only restrict, never grant.&lt;/strong&gt; RBAC is always checked first and is always the floor, so an attacker can't manufacture access by fiddling with a resource's attributes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fktnggvcmc742tal2g3jm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fktnggvcmc742tal2g3jm.png" alt="The two-gate check: RBAC permission is the floor, an optional policy can only narrow the result, never grant access" width="724" height="1800"&gt;&lt;/a&gt;&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;can&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;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 1. RBAC is the floor — no permission, no access. Full stop.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;permissions&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;getUserPermissions&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="c1"&gt;// Set&amp;lt;string&amp;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;permissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&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="c1"&gt;// 2. Optional ABAC policy can only NARROW, never grant.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;policies&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;action&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;policy&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;policy&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;resource&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&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;true&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;In routes this surfaces as guards: &lt;code&gt;app.put('/documents/:id', authenticate, requirePermission('doc:edit'), handler)&lt;/code&gt;. The library provides the &lt;em&gt;mechanism&lt;/em&gt; (&lt;code&gt;can()&lt;/code&gt;, the guards, the schema); your application provides the &lt;em&gt;rules&lt;/em&gt; (&lt;code&gt;'doc:edit': (user, doc) =&amp;gt; doc.ownerId === user.id&lt;/code&gt;), because only your domain knows them.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Reference: &lt;a href="https://dev.to{{REPO_URL}}"&gt;&lt;code&gt;services/rbacService.js&lt;/code&gt;&lt;/a&gt; and migration &lt;code&gt;006_rbac.sql&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 10 — Reset, verify, and the audit trail
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;One-time tokens (password reset, email verification)&lt;/strong&gt; are credentials too, and they fail in predictable ways: predictable values, reusability, no expiry, or not killing existing sessions. oathkeeper's reset tokens are 32 bytes of &lt;code&gt;crypto.randomBytes&lt;/code&gt; (unpredictable), stored as SHA-256 (a DB dump doesn't hand over working links), time-bound (30-minute default), and &lt;strong&gt;single-use under concurrency&lt;/strong&gt; via an atomic SQL consume:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;password_reset_tokens&lt;/span&gt;
   &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;used_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;token_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;used_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;          &lt;span class="c1"&gt;-- the atomicity: exactly one concurrent caller wins&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Zero rows back =&amp;gt; already used, expired, or never existed.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two requests racing to use the same token? The database arbitrates — one gets the row, the other gets nothing. And a successful reset &lt;strong&gt;revokes every refresh token&lt;/strong&gt; for that user, so an attacker who'd hijacked a session can't ride it through the password change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit log.&lt;/strong&gt; Every meaningful event — &lt;code&gt;login.success&lt;/code&gt;, &lt;code&gt;login.failure&lt;/code&gt;, &lt;code&gt;token.refresh&lt;/code&gt;, &lt;code&gt;token.reuse_detected&lt;/code&gt;, &lt;code&gt;mfa.enabled&lt;/code&gt;, &lt;code&gt;password.reset.completed&lt;/code&gt; — is written to an &lt;code&gt;auth_events&lt;/code&gt; table with the user, IP, user agent, timestamp, and a JSONB &lt;code&gt;metadata&lt;/code&gt; column for event-specific context. It's the difference between "we think we were fine" and "here's exactly what happened, indexed and queryable."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;auth_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;     &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="k"&gt;type&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&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;,&lt;/span&gt;                 &lt;span class="c1"&gt;-- 'login.success', 'token.reuse_detected', ...&lt;/span&gt;
  &lt;span class="n"&gt;ip&lt;/span&gt;          &lt;span class="n"&gt;INET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_agent&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;metadata&lt;/span&gt;    &lt;span class="n"&gt;JSONB&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;occurred_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;auth_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;auth_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 11 — Assemble the layers
&lt;/h2&gt;

&lt;p&gt;Step back from the features and look at the shape. oathkeeper is three layers, each depending only on the one below it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc22daqib1whje7wt7kia.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc22daqib1whje7wt7kia.png" alt="HTTP layer → Service layer → Adapter layer, with each layer's single responsibility labeled and the dependency direction shown" width="800" height="1667"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Why this matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The HTTP layer contains no security logic.&lt;/strong&gt; Routes translate HTTP to a service call and back — read the body, validate required fields, call one method, shape the response. That's it. Security decisions have exactly one home.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The service layer holds every invariant.&lt;/strong&gt; It receives its dependencies through factory parameters and never reaches for global state, which makes each service independently testable and replaceable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The adapter layer is swappable.&lt;/strong&gt; Each adapter satisfies a small interface contract, so you can swap Postgres for another store, argon2 for scrypt, console mail for SES — without touching auth logic. This seam is precisely what makes the next step (scaling) a drop-in rather than a rewrite.
The full schema is seven migrations: &lt;code&gt;users&lt;/code&gt;, &lt;code&gt;refresh_tokens&lt;/code&gt;, &lt;code&gt;email_verification_tokens&lt;/code&gt;, &lt;code&gt;password_reset_tokens&lt;/code&gt;, &lt;code&gt;mfa_recovery_codes&lt;/code&gt;, the four RBAC tables, and &lt;code&gt;auth_events&lt;/code&gt;. And a small but underrated touch: &lt;strong&gt;boot-time config validation.&lt;/strong&gt; A missing or weak &lt;code&gt;jwtSecret&lt;/code&gt;, a &lt;code&gt;refreshTokenTtl&lt;/code&gt; shorter than the access TTL — these crash the process at startup with an actionable message, not during some user's first login at 2 a.m.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Reference: &lt;a href="https://github.com/shubhamlakheraa/oathkeeper/blob/main/docs/architecture.md" rel="noopener noreferrer"&gt;&lt;code&gt;docs/architecture.md&lt;/code&gt;&lt;/a&gt; walks every data flow.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 12 — Now scale it
&lt;/h2&gt;

&lt;p&gt;This is where the interview separates the people who've drawn auth from the people who've &lt;em&gt;run&lt;/em&gt; it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The good news: stateless access tokens scale for free.&lt;/strong&gt; Any app server with the secret can verify a JWT — no shared session store, no sticky sessions. Put N identical Node processes behind a load balancer and access-token verification just works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trap: one piece of state is hiding in process memory.&lt;/strong&gt; oathkeeper's default rate limiter and TOTP replay store keep their counters in an in-process &lt;code&gt;Map&lt;/code&gt;. On a single process, correct. The moment you run more than one process — Node cluster, PM2 cluster mode, multiple Kubernetes pods — &lt;strong&gt;each process has its own independent counters&lt;/strong&gt;, and that's a real security regression:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd9fvfuvzkt7am60xvhr8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd9fvfuvzkt7am60xvhr8.png" alt="A load balancer routing to two pods, each with its own Map, showing an attacker bypassing rate limits by retrying on the second pod" width="800" height="670"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A user rate-limited on Pod A simply retries and lands on Pod B with a fresh counter. A TOTP code spent on Pod A can be replayed on Pod B inside the same window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I know the fix, and I'm actively working toward it.&lt;/strong&gt; The reason Redis isn't in oathkeeper yet isn't that I skipped it — it's that I'm currently doing a deep dive into Redis internals from scratch before I touch it: how it handles persistence, how its data structures actually work under the hood, how it manages expiry, and what the replication model looks like. Same first-principles approach I took with auth. Once I've built that mental model, the integration is a drop-in: both interfaces are tiny — &lt;code&gt;{ isRateLimited(key, limit, windowMs), reset(key) }&lt;/code&gt; and &lt;code&gt;{ has(key), set(key, ttl) }&lt;/code&gt; — specifically designed to be swapped without touching a single line of auth logic above them. That seam was intentional from day one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyxyafs5epj5s50pw5otr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyxyafs5epj5s50pw5otr.png" alt="N pods behind a load balancer all reading from one Redis (rate limits + replay keys) and one Postgres primary with a read replica" width="800" height="356"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The rest of the scaling story:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Postgres read replicas.&lt;/strong&gt; The hot read is &lt;code&gt;authenticate&lt;/code&gt;'s per-request user lookup. Route those reads to replicas; keep token rotation and writes on the primary (rotation is a transaction with a CAS — it must hit the primary).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cost of being correct.&lt;/strong&gt; &lt;code&gt;authenticate&lt;/code&gt; does a live DB lookup on &lt;em&gt;every&lt;/em&gt; request — that's deliberate, so a soft-deleted, suspended, or locked user is rejected (&lt;code&gt;USER_NOT_FOUND&lt;/code&gt;) the moment it matters, regardless of how long their JWT has left. The DB is authoritative, not the token. At scale you can cache that lookup behind a short TTL, but you're now trading revocation latency for read throughput — name the trade-off, don't hide it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  - &lt;strong&gt;Stateless secret distribution.&lt;/strong&gt; Every app server needs the JWT secret; manage it through your secrets system, never in version control.
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Step 13 — What I'd deliberately punt on
&lt;/h2&gt;

&lt;p&gt;A senior answer is as much about what you &lt;em&gt;defer&lt;/em&gt; as what you build. For v1, explicitly out of scope:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WebAuthn / passkeys&lt;/strong&gt; — a different protocol; a planned future addition behind the same MFA seam.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distributed rate limiting beyond Redis&lt;/strong&gt;, and &lt;strong&gt;device/geo/velocity fingerprinting&lt;/strong&gt; for suspicious-login signals — these need behavioral-analysis infrastructure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HaveIBeenPwned check at signup&lt;/strong&gt; — an external dependency; opt-in for the host app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network-level DDoS protection&lt;/strong&gt; — that's a WAF/CDN concern, not an auth-library one.
Knowing where the library's responsibility ends — and saying so — is the difference between a mechanism you can reason about and a black box you hope is fine.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Recap, and how to build it yourself
&lt;/h2&gt;

&lt;p&gt;Walked end to end, the answer is a chain of &lt;em&gt;concept → attack → defense&lt;/em&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Defense&lt;/th&gt;
&lt;th&gt;Attack it closes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Passwords&lt;/td&gt;
&lt;td&gt;argon2id, 64 MB, ~250 ms&lt;/td&gt;
&lt;td&gt;GPU brute-force of leaked hashes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sessions&lt;/td&gt;
&lt;td&gt;Stateless access + stateful refresh&lt;/td&gt;
&lt;td&gt;Scale vs. revocation tension&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refresh&lt;/td&gt;
&lt;td&gt;Rotation + reuse detection + family revoke&lt;/td&gt;
&lt;td&gt;Stolen-token persistence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;SHA-256 at rest&lt;/td&gt;
&lt;td&gt;DB-dump token theft&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cookies&lt;/td&gt;
&lt;td&gt;Double-submit CSRF + HttpOnly&lt;/td&gt;
&lt;td&gt;CSRF and XSS token theft&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JWT&lt;/td&gt;
&lt;td&gt;Pinned algorithm + ≥32-byte secret + purpose claim&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;alg:none&lt;/code&gt; forgery, MFA bypass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Login&lt;/td&gt;
&lt;td&gt;Identical response + dummy hash&lt;/td&gt;
&lt;td&gt;Account enumeration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MFA&lt;/td&gt;
&lt;td&gt;TOTP + replay store + hashed recovery codes&lt;/td&gt;
&lt;td&gt;Password-only compromise, code replay&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AuthZ&lt;/td&gt;
&lt;td&gt;RBAC floor + restrict-only ABAC&lt;/td&gt;
&lt;td&gt;Privilege escalation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reset&lt;/td&gt;
&lt;td&gt;Atomic single-use token + revoke-all-sessions&lt;/td&gt;
&lt;td&gt;Token reuse, surviving sessions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scale&lt;/td&gt;
&lt;td&gt;Redis-backed shared state&lt;/td&gt;
&lt;td&gt;Multi-process rate-limit / replay bypass&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you want to &lt;em&gt;internalize&lt;/em&gt; this rather than just read it, the best move is to build it yourself and break each step before you fix it — that's the whole reason oathkeeper exists. The repo includes the full source, all seven migrations, a 30-line quickstart, worked examples (minimal and full), the architecture and threat-model docs, and a test suite (including the two non-negotiable anchors: a concurrent one-time-token race, and the RFC 6238 TOTP vectors). Clone it, read it top to bottom, and rebuild it from scratch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;→ &lt;a href="https://github.com/shubhamlakheraa/oathkeeper" rel="noopener noreferrer"&gt;oathkeeper on GitHub&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The barista still forgets you the second you walk away. Everything above is just the increasingly careful art of handing them a note that proves who you are — one that a thief can't forge, can't reuse, and can't replay on the machine next door.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If this was useful, the repo is the companion piece — and I post more first-principles backend deep-dives. Questions and corrections welcome in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>systemdesign</category>
      <category>authentication</category>
      <category>discuss</category>
    </item>
  </channel>
</rss>
