Skip to content
Boomspot
  • Home
Loading...
Boomspot

Daily tech news, software development coverage, Apple reporting, and the gear behind modern music making.

TwitterLinkedIn

Browse

  • Categories
  • Tags
  • Authors

Company

  • About
  • Contact

Legal

  • Privacy Policy
  • Terms of Service
  • Unsubscribe

© 2026 Boomspot. All rights reserved.

Built by Boomspot
Updated hourly

AI Content Disclosure: Articles on Boomspot are researched, written, and edited with the assistance of advanced AI systems. We combine software-assisted research with editorial oversight to deliver useful, accurate, and practical technical and music production content. Learn more about our editorial approach.

Browse by Category

Technology657Coding168Linux45SEO38Music Production29Studio Gear23Apple Rumors11

Popular Posts

Discover's 'Dive Deeper' AI Test: A Publisher Checklist

Discover's 'Dive Deeper' AI Test: A Publisher Checklist

4 min read
Raspberry Pi 5 Alternatives After the $77.50 Price Hike

Raspberry Pi 5 Alternatives After the $77.50 Price Hike

6 min read
Why External Hard Drive Prices Are Skyrocketing in 2026

Why External Hard Drive Prices Are Skyrocketing in 2026

5 min read
How to Calibrate Confidence Thresholds in AI Agents

How to Calibrate Confidence Thresholds in AI Agents

7 min read
Adobe Audition Won't Open on Windows 10? Fix It Fast

Adobe Audition Won't Open on Windows 10? Fix It Fast

6 min read

Recent Posts

SRV-330 Reverb Emulation vs Stock Reverb: A/B Test Guide

SRV-330 Reverb Emulation vs Stock Reverb: A/B Test Guide

Oct 10, 2026•7 min
ADAM Sub8 PRO vs Sub10 PRO: Which Suits a Small Studio?

ADAM Sub8 PRO vs Sub10 PRO: Which Suits a Small Studio?

Oct 10, 2026•7 min
AI Video Ad Workflow Audit for Agencies: 3-Step Checklist

AI Video Ad Workflow Audit for Agencies: 3-Step Checklist

Oct 10, 2026•9 min
Go HTTP Server Graceful Shutdown: Fix the Common Bugs

Go HTTP Server Graceful Shutdown: Fix the Common Bugs

Oct 10, 2026•8 min
Fix EA App Games on Linux: Switch Proton Versions in Steam

Fix EA App Games on Linux: Switch Proton Versions in Steam

Oct 10, 2026•6 min
  1. Home
  2. Coding
  3. How to Detect Refresh Token Reuse in Go and Postgres
coding8 min read

How to Detect Refresh Token Reuse in Go and Postgres

A revoked refresh token that gets replayed should not just fail quietly. Here's how to add family-based reuse detection to a Go and Postgres auth service, with tests.

S

Staff

September 8, 2026

Updated:Oct 6, 2026

How to Detect Refresh Token Reuse in Go and Postgres

An attacker steals a refresh token from a compromised laptop, waits a week, and finally uses it. The rotation logic does exactly what it's designed to do: the token is already revoked, so the request fails with a generic error. Nobody gets paged. Nobody knows a stolen credential just tried to walk back in the door.

That's the gap in most refresh token implementations, including the one this post is fixing. Rotation alone tells you a token was reused, but it doesn't tell your system to react. If a revoked refresh token gets replayed, that's not a normal expiry or a stale tab. It's a strong signal that a token was copied out of band, and the only safe response is to kill every session descended from it, not just the one that got caught.

The Missing Branch: What Happens When a Revoked Token Comes Back

The original Refresh handler treats every failure the same way. GetSessionByRefreshHash returns a row, the handler checks RevokedAt, and if it's set, the function returns ErrSessionRevokedOrExpired and stops. That's correct for an expired token. It's the wrong response for a revoked one, because a revoked-and-replayed token means someone other than the legitimate client is holding a copy.

To tell those two cases apart, the lookup needs to stop discarding revoked rows and instead treat them as a signal. Change GetSessionByRefreshHash so it doesn't filter out revoked sessions at the query level. Instead, fetch the row regardless of state, then branch in the service layer on what you find. If RevokedAt is set and the token still matches the hash on file, you've caught a reuse attempt, and that's the trigger for RevokeSessionFamily, not a plain rejection.

This only works if sessions know which family they belong to. Add a family_id column to the sessions table, generated once at login and carried forward on every rotation:

ALTER TABLE sessions ADD COLUMN family_id UUID NOT NULL DEFAULT gen_random_uuid();
CREATE INDEX idx_sessions_family_id ON sessions (family_id);

When a brand-new login happens, family_id gets a fresh UUID. When ReplaceSession rotates a token, the new row inherits the old row's family_id instead of generating a new one. That single column turns a flat list of session rows into a chain you can revoke all at once. See related: sample size calculator for robot policy comparison for additional background.

Schema and Transaction Logic for Family-Based Revocation

RevokeSessionFamily is a single statement. But it has to run in the same transaction as the lookup that triggered it, or you open a race window where a second replay slips through before the first revocation lands.

func (r *Repository) RevokeSessionFamily(ctx context.Context, tx pgx.Tx, familyID string) error {
    _, err := tx.Exec(ctx, `
        UPDATE sessions SET revoked_at = now()
        WHERE family_id = $1 AND revoked_at IS NULL
    `, familyID)
    return err
}

The Refresh handler wraps the whole check-and-branch sequence in one transaction, using SELECT ... FOR UPDATE to lock the row before deciding anything:

func (s *Service) Refresh(ctx context.Context, refreshToken, ip, ua string) (*TokenPair, error) {
    tx, err := s.pool.Begin(ctx)
    if err != nil {
        return nil, err
    }
    defer tx.Rollback(ctx)

    session, err := s.repo.GetSessionByRefreshHashForUpdate(ctx, tx, HashToken(refreshToken))
    if errors.Is(err, ErrNotFound) {
        return nil, ErrSessionRevokedOrExpired
    }
    if err != nil {
        return nil, err
    }

    if session.RevokedAt != nil {
        if revErr := s.repo.RevokeSessionFamily(ctx, tx, session.FamilyID); revErr != nil {
            return nil, revErr
        }
        if commitErr := tx.Commit(ctx); commitErr != nil {
            return nil, commitErr
        }
        s.logger.Warn("refresh token reuse detected", "family_id", session.FamilyID, "user_id", session.UserID)
        return nil, ErrSessionRevokedOrExpired
    }

    if time.Now().After(session.ExpiresAt) {
        return nil, ErrSessionRevokedOrExpired
    }

    newRefreshToken, err := GenerateOpaqueToken()
    if err != nil {
        return nil, err
    }
    newSessionID, err := s.repo.ReplaceSessionTx(
        ctx, tx, session.ID, session.UserID, session.FamilyID,
        HashToken(newRefreshToken), ua, ip, time.Now().Add(s.refreshTokenTTL),
    )
    if err != nil {
        return nil, err
    }
    accessToken, err := s.jwt.Issue(session.UserID, newSessionID)
    if err != nil {
        return nil, err
    }
    if err := tx.Commit(ctx); err != nil {
        return nil, err
    }
    return &TokenPair{AccessToken: accessToken, RefreshToken: newRefreshToken, SessionID: newSessionID}, nil
}

Note what changed from the original: the revoked branch commits its own transaction before returning an error, because the family revocation has to persist even though the refresh itself fails. Returning early without committing would roll back the very fix you just triggered.

The row lock matters more than it seems. Without FOR UPDATE, two concurrent requests using the same stolen token can both read the row as "not yet revoked," both pass the check, and both mint new sessions before either commits. The lock forces the second request to wait for the first transaction to finish, so it sees the post-rotation state and hits the revoked branch honestly instead of racing past it.

Testing the Race Condition

A reuse-detection path that isn't tested under concurrency isn't really tested. Write an integration test that fires two goroutines at Refresh with the same refresh token simultaneously, using a sync.WaitGroup to launch them together and a channel to collect both results.

func TestRefresh_ConcurrentReuse_RevokesFamily(t *testing.T) {
    token := seedSession(t, db, userID)
    results := make(chan error, 2)
    var wg sync.WaitGroup
    for i := 0; i < 2; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            _, err := svc.Refresh(ctx, token, "127.0.0.1", "test-agent")
            results <- err
        }()
    }
    wg.Wait()
    close(results)

    var successes, failures int
    for err := range results {
        if err == nil {
            successes++
        } else {
            failures++
        }
    }
    require.Equal(t, 1, successes)
    require.Equal(t, 1, failures)

*Also read:* [a closer look at gemini 3.8 flash: avoid a bigger api bill now](/gemini-3-8-flash-avoid-a-bigger-api-bill-now)

    var revokedCount int
    db.QueryRow(ctx, `SELECT count(*) FROM sessions WHERE family_id = $1 AND revoked_at IS NULL`, familyID).Scan(&revokedCount)
    require.Equal(t, 1, revokedCount)
}

That last assertion is the one worth staring at. After a genuine race, exactly one session in the family should remain active: the single new row created by whichever goroutine won. RevokeSessionFamily should have revoked everything else in that family, including the row the loser tried to create, if your logic ever got that far. If the count comes back at two or more, your locking isn't doing its job and a stolen token can still ride alongside a legitimate one.

A second test worth adding replays an already-rotated token after a clean, non-concurrent refresh, and asserts that a follow-up call with the original session's other still-valid-looking token also fails. That confirms the family revocation actually reached sibling sessions and didn't just flag the one row that got caught.

This closes the loop the original design left open on purpose. Rotation limits how long a stolen token stays useful; reuse detection makes the theft visible and shuts down every session it touched, not just the one instance that got replayed. The family_id column costs one migration and one extra join condition. The payoff is a system that reacts to a break-in attempt instead of quietly absorbing it and leaving the real user wondering why they got logged out for no reason.

Tags

CybersecuritySoftware DevelopmentCoding Best PracticesProgramming LanguagesCoding Tutorials

Keep reading

WebAssembly: Unleashing Native Speed in Web Browsers
Coding•4 min read

WebAssembly: Unleashing Native Speed in Web Browsers

WebAssembly is transforming web development with near-native performance, enabling more complex and efficient applications.

Sep 6, 2025

Revolutionizing Code with GitHub Copilot X
Coding•3 min read

Revolutionizing Code with GitHub Copilot X

GitHub Copilot X revolutionizes software development, offering AI-driven pair programming to enhance efficiency, learning, and code quality.

Sep 6, 2025

Learn Python in 10 Minutes: A Beginner Tutorial
Coding•3 min read

Learn Python in 10 Minutes: A Beginner Tutorial

Dive into Python coding with a beginner-friendly tutorial. Learn the basics in just 10 minutes and practice with real-world homework assignments.

Sep 6, 2025

More stories for your next project

Get tech, coding, and music production updates in your inbox.

Unsubscribe anytime.