<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Crydensync Publication]]></title><description><![CDATA[Crydensync Publication]]></description><link>https://crydensync.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Crydensync Publication</title><link>https://crydensync.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 09 Sep 2026 11:11:07 GMT</lastBuildDate><atom:link href="https://crydensync.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How Refresh Token Rotation and Reuse Detection Actually Work (and Why Most Tutorials Get It Wrong)]]></title><description><![CDATA[If you've implemented "refresh tokens" in an auth system before, there's a good chance you built something that looks secure but isn't. This post walks through what refresh token rotation actually nee]]></description><link>https://crydensync.hashnode.dev/how-refresh-token-rotation-and-reuse-detection-actually-work-and-why-most-tutorials-get-it-wrong</link><guid isPermaLink="true">https://crydensync.hashnode.dev/how-refresh-token-rotation-and-reuse-detection-actually-work-and-why-most-tutorials-get-it-wrong</guid><dc:creator><![CDATA[Raymond Nicholas]]></dc:creator><pubDate>Mon, 31 Aug 2026 16:29:36 GMT</pubDate><content:encoded><![CDATA[<p>If you've implemented "refresh tokens" in an auth system before, there's a good chance you built something that <em>looks</em> secure but isn't. This post walks through what refresh token rotation actually needs to do, why the naive version fails silently, and how it's implemented in <a href="https://github.com/crydensync/cryden">CrydenSync</a>, an open-source Go auth engine including the real code, the real database schema, and the one property that most tutorials skip entirely: reuse detection.</p>
<h2>The problem with a single, long-lived refresh token</h2>
<p>The simplest possible refresh flow looks like this: issue a refresh token at login, store it, and let the client trade it for a new access token whenever the old one expires. The refresh token itself never changes.</p>
<p>This works, until the token leaks. Browser history, a logging system that captures request bodies, a compromised device, a misconfigured CDN cache — any of these can expose a long-lived refresh token, and once it's out, it's valid until it naturally expires (which, for a refresh token, is often weeks or months). There's no signal that anything went wrong, and no way to distinguish the attacker's usage from the legitimate user's.</p>
<h2>Rotation: the first real improvement</h2>
<p>The standard fix is <strong>rotation</strong>: every time a refresh token is used, it's invalidated and a brand new one is issued in its place. The client must always use the newest token; using an old one is now, by definition, invalid.</p>
<p>This alone is a real improvement, but it has a gap that's easy to miss: rotation without any <em>memory</em> of what happened doesn't detect theft, it just delays the consequences of it. If an attacker steals a refresh token and uses it once, they get a valid new token — same as anyone else who presents a valid token. There's nothing in a bare rotation scheme that distinguishes "the legitimate user refreshed" from "an attacker who stole the token refreshed."</p>
<h2>Reuse detection: the piece that's usually missing</h2>
<p>Here's the insight that makes rotation actually secure: <strong>once a refresh token has been used and rotated away, it should never be valid again — not even once.</strong> If it's ever presented a second time, that's not a normal client mistake, it's a signal. It means someone has a copy of a token that the legitimate holder already moved past — which can only happen if that token leaked at some point.</p>
<p>When this happens, the correct response isn't "reject this one token." It's "revoke every token descended from the same original login." Here's why that specific scope matters.</p>
<p>Imagine a normal login produces token A. The client rotates: A → B. Now imagine an attacker had also gotten a copy of A (say, from a logged request) and tries to use it <em>after</em> the legitimate client already rotated to B. The attacker's request with A fails — good, A is already dead — but if you stop there, B (which the legitimate client is now actively using) is untouched. The attacker doesn't get in, but there's no signal that anything happened, and if the attacker actually got a copy of a token <em>before</em> the legitimate client rotated it, whoever wins the race gets B, and the other party's next refresh attempt is the one that reveals reuse — at which point you need to kill the <em>whole chain</em>, because you can no longer be sure which of the two parties holding pieces of that chain is legitimate.</p>
<p>The rule that makes this tractable: <strong>track a family, not just individual tokens.</strong> Every token descended from one original login shares a <code>family_id</code>. Reuse of any token in that family — detected the moment an already-revoked token is presented again — kills every token sharing that family ID, including ones that were legitimately rotated forward. Both the attacker and the legitimate user get logged out. That's an intentional, correct outcome: the alternative is guessing which party is legitimate, and guessing wrong is worse than forcing a full re-login.</p>
<h2>What this looks like in a real schema</h2>
<p>CrydenSync's <code>sessions</code> table carries both an <code>id</code> (unique per token) and a <code>family_id</code> (shared across a rotation chain):</p>
<pre><code class="language-sql">CREATE TABLE sessions (
    id          UUID PRIMARY KEY,
    family_id   UUID NOT NULL,
    user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    token_hash  TEXT NOT NULL UNIQUE,
    ip          TEXT,
    user_agent  TEXT,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    revoked_at  TIMESTAMPTZ
);

CREATE INDEX idx_sessions_family_id ON sessions(family_id);
</code></pre>
<p>Two things worth noting immediately:</p>
<p><code>token_hash</code><strong>, not the raw token.</strong> The refresh token itself is never stored. On issue, the raw token (32 bytes from <code>crypto/rand</code>, hex-encoded) is hashed with SHA-256 before it touches the database. If the database is ever compromised, there's nothing usable inside it — an attacker with read access to the table still can't reconstruct a valid token from a hash.</p>
<p><code>revoked_at</code><strong>, nullable, is the whole state machine.</strong> A session with <code>revoked_at IS NULL</code> is live. Anything else is dead. Reuse detection is just: "was this token already revoked when it was presented?"</p>
<h2>The rotation logic itself</h2>
<pre><code class="language-go">func Rotate(
    ctx context.Context,
    sessions store.SessionStore,
    gen TokenGenerator,
    ids security.IDGenerator,
    rawToken string,
) (RefreshResult, error) {
    hash := HashToken(rawToken)

    existing, err := sessions.GetByTokenHash(ctx, hash)
    if err != nil {
        return RefreshResult{}, ErrInvalidToken
    }

    if existing.RevokedAt != nil {
        // Reuse of a token that was already rotated away — the
        // whole family is compromised, not just this token.
        if revokeErr := sessions.RevokeFamily(ctx, existing.FamilyID); revokeErr != nil {
            return RefreshResult{Session: existing}, revokeErr
        }
        return RefreshResult{Session: existing}, ErrTokenReused
    }

    newRaw, err := gen.New()
    if err != nil {
        return RefreshResult{}, err
    }
    newID, err := ids.New()
    if err != nil {
        return RefreshResult{}, err
    }

    newSession := store.Session{
        ID:        newID,
        FamilyID:  existing.FamilyID, // stays constant across the whole chain
        UserID:    existing.UserID,
        TokenHash: HashToken(newRaw),
        IP:        existing.IP,
        UserAgent: existing.UserAgent,
    }

    if err := sessions.RotateToken(ctx, existing.ID, newSession); err != nil {
        return RefreshResult{}, err
    }

    return RefreshResult{RawToken: newRaw, Session: newSession}, nil
}
</code></pre>
<p>Two subtleties in this function are easy to get wrong, and both were real bugs caught during development, not hypothetical concerns.</p>
<h3>Subtlety 1: the reuse-detected error path still needs to carry context</h3>
<p>Notice that on the <code>ErrTokenReused</code> path, the function returns <code>RefreshResult{Session: existing}</code> — not a zero-valued <code>RefreshResult{}</code>. This matters because the caller needs <code>existing.UserID</code> and <code>existing.FamilyID</code> to write an accurate audit log entry (<code>token_reuse_detected</code>, attributed to the right user). The first version of this function returned a zero value on that path, which meant the resulting security event had no user attached to it — completely undermining the point of logging it in the first place. If you're building this yourself: whatever error path represents your most security-critical event is exactly the path where losing context is least acceptable, and it's an easy place for that kind of gap to hide.</p>
<h3>Subtlety 2: rotation must be atomic, or you've built a worse bug than the one you're fixing</h3>
<p>Look at the last real step: <code>sessions.RotateToken(ctx, existing.ID, newSession)</code>. This is <em>not</em> two separate calls (revoke the old, then create the new). It's one atomic operation, and here's the Postgres implementation underneath it:</p>
<pre><code class="language-go">func (s *SessionStore) RotateToken(ctx context.Context, oldSessionID string, newSession store.Session) error {
    tx, err := s.db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback() // no-op if Commit succeeds

    result, err := tx.ExecContext(ctx, `
        UPDATE sessions SET revoked_at = now()
        WHERE id = $1 AND revoked_at IS NULL
    `, oldSessionID)
    if err != nil {
        return err
    }
    if err := checkRowsAffected(result); err != nil {
        return err
    }

    _, err = tx.ExecContext(ctx, `
        INSERT INTO sessions (id, family_id, user_id, token_hash, ip, user_agent)
        VALUES ($1, $2, $3, $4, $5, $6)
    `, newSession.ID, newSession.FamilyID, newSession.UserID,
       newSession.TokenHash, newSession.IP, newSession.UserAgent)
    if err != nil {
        return err
    }

    return tx.Commit()
}
</code></pre>
<p>If revoking the old token and creating the new one were two independent database calls, a crash between them leaves a session family with no live token in it at all — not a security hole exactly (the user just gets logged out), but a real reliability bug, and one that would be maddening to reproduce and debug in production. Wrapping both operations in a single transaction, with <code>defer tx.Rollback()</code> as a safety net, means the operation either fully succeeds or leaves no trace of having started. This is the kind of thing that's invisible in a demo and very visible the first time it happens under real concurrent load.</p>
<h2>Proving it actually works</h2>
<p>None of the above matters if it isn't tested against real conditions. Here's the property that actually matters, verified with a real integration test against live Postgres (not mocked):</p>
<pre><code class="language-go">func TestRotate_ReuseDetectionRevokesEntireFamily(t *testing.T) {
    // ... set up an original session, rotate it once (A -&gt; B) ...

    // Replay the ORIGINAL token, which is now stale.
    _, err := Rotate(ctx, sessions, gen, ids, rawOriginal)
    if err != ErrTokenReused {
        t.Fatalf("expected ErrTokenReused, got %v", err)
    }

    // The critical assertion: token B — the one that legitimately
    // rotated forward and was never "stolen" — must ALSO be dead now.
    _, err = Rotate(ctx, sessions, gen, ids, firstResult.RawToken)
    if err == nil {
        t.Error("expected the legitimately rotated token to also be revoked")
    }
}
</code></pre>
<p>And, one level up, the same property proven over real HTTP, against a running server, hitting real endpoints:</p>
<pre><code class="language-plaintext">OK   refresh rotation
OK   reused (old) refresh token rejected — the property that matters most
OK   session family fully revoked — even the legitimately rotated token now dead
</code></pre>
<p>That second layer of proof matters more than it might seem. Unit tests prove the Go function behaves correctly in isolation. An end-to-end test against a real, running HTTP server proves the <em>whole system</em> — the handler, the JSON serialization, the actual database round trip — produces the right outcome for a real client. Both layers caught different classes of bugs during development; neither alone would have been sufficient.</p>
<h2>What a client should actually do with this</h2>
<p>If you're consuming an API built this way (this exact contract is what CrydenSync's <a href="https://github.com/crydensync/api">HTTP API</a> exposes), the client-side handling is simple but easy to get subtly wrong:</p>
<pre><code class="language-js">async function refreshTokens(refreshToken) {
  const res = await fetch("/v1/refresh", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ refresh_token: refreshToken }),
  });

  if (res.status === 401) {
    const { error } = await res.json();
    if (error.code === "token_reused") {
      // The entire session family is dead. There is no other stored
      // token that will work. Clear everything and force login.
      clearStoredTokens();
      redirectToLogin();
      return;
    }
  }

  const { data } = await res.json();
  storeTokens(data); // overwrite BOTH tokens — never keep the old one around
}
</code></pre>
<p>The mistake to avoid: treating <code>token_reused</code> like any other refresh failure and retrying, or worse, falling back to some other cached token. There isn't one. Once you're in this state, a full re-login is the only correct next step.</p>
<h2>The takeaway</h2>
<p>"Refresh token rotation" as a phrase undersells what actually needs to happen. Rotation alone slows down misuse of a leaked token; it doesn't detect it. The piece that makes the whole scheme actually secure — tracking a family, detecting reuse of an already-rotated token, and revoking the entire chain the instant that happens — is exactly the part most quick tutorials skip, because it requires real state (a <code>family_id</code>, a revocation timestamp), a genuinely atomic database operation, and tests that go looking for the failure mode instead of just the happy path.</p>
<p>If you want to see the whole thing in context the store interfaces, the Postgres schema, the full test suite — the code is open source: <a href="https://github.com/crydensync/cryden">github.com/crydensync/cryden</a>.</p>
<p><em>CrydenSync is a self-hosted, framework-agnostic authentication engine for Go. No hosted service, no telemetry, no vendor lock-in your users stay in your own database. If this kind of design work interests you, we're looking for testers and documentation contributors.</em></p>
]]></content:encoded></item></channel></rss>