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.

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.
Related Articles

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
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
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