Rotating refresh tokens: Why critical in authorization
Every app that keeps you logged in for more than an hour has quietly made a trade. A long-lived credential is convenient and dangerous; a short-lived one is safe and annoying. The standard way out of that bind is a pair of tokens: a short access token you show on every request, and a longer-lived refresh token whose only job is to mint new access tokens. The interesting part isn't the pair. It's what happens when the refresh token itself leaks. This post walks through the session layer of a Go service I built — internal/auth, backed by Postgres via pgx — and is honest about where the design stops. The two tokens do different jobs The access token is a JWT, HS256, 15-minute TTL, carrying just two claims: type Claims struct { UserID string `json:"uid"` SessionID string `json:"sid"` jwt.RegisteredClaims } It's a bearer token in the purest sense: the server does zero database work to trust it. Parse, check the signature and expiry, read uid — done. That's the whole point of a JWT here, and it's why the TTL is 15 minutes and not 15 hours. If one leaks, the blast radius is one quarter of one hour. One detail that matters more than it looks: the parser pins the algorithm. token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, ErrInvalidToken } return j.secret, nil }) Without that ok check you're vulnerable to the classic JWT algorithm-confusion attack — an attacker flips the header to alg: none, or to RS256 so your HMAC secret gets verified as if it were an RSA public key. The library gives you the header; you decide what's acceptable. Decide narrowly. The refresh token is the opposite of a JWT. It's opaque — 32 bytes from crypto/rand, base64url-encoded, no structure, no claims: func GenerateOpaqueToken() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } It means nothing on its own. Its entire meaning is a row in the sessions table — and the server has to hit the database to resolve it, which is exactly what makes it revocable. Store the hash, never the token The refresh token is a password-equivalent: anyone holding it can keep your session alive. So it's stored the way you'd store a password — you don't. func HashToken(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } Plain SHA-256, no salt, no bcrypt — and that's the right call here, unlike for passwords. The input is 256 bits of uniform randomness, so there's no dictionary to attack and nothing for a slow hash to buy you. What you get instead: a database dump leaks only hashes, and a hash can't be replayed against the refresh endpoint. The lookup is a single indexed equality check on sessions.refresh_token_hash. Delivery matters as much as storage. The refresh token goes to the browser as an HttpOnly, Secure, SameSite=Strict cookie scoped to Path=/api/v1/auth — never in a JSON body, never readable by JavaScript, and only ever sent to the handful of endpoints that need it. The access token, by contrast, is handed to the SPA in the login response body and lives in memory. Two tokens, two completely different delivery channels, two different threat models. Rotation: every refresh burns the token that bought it Here's the core of it. When a client calls /auth/refresh, the old refresh token is consumed — it never works again — and a brand-new one comes back with the new access token: func (s *Service) Refresh(ctx context.Context, refreshToken, ip, ua string) (*TokenPair, error) { session, err := s.repo.GetSessionByRefreshHash(ctx, HashToken(refreshToken)) if errors.Is(err, ErrNotFound) { return nil, ErrSessionRevokedOrExpired } if err != nil { return nil, err } if session.RevokedAt != nil || time.Now().After(session.ExpiresAt) { return nil, ErrSessionRevokedOrExpired } newRefreshToken, err := GenerateOpaqueToken() if err != nil { return nil, err } newSessionID, err := s.repo.ReplaceSession( ctx, session.ID, session.UserID, 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 } return &TokenPair{AccessToken: accessToken, RefreshToken: newRefreshToken, SessionID: newSessionID}, nil } ReplaceSession does the swap inside a transaction so there's never a window with zero valid tokens or two: func (r *Repository) ReplaceSession(ctx context.Context, oldSessionID, userID, newRefreshTokenHash, userAgent, ip string, expiresAt time.Time) (string, error) { tx, err := r.pool.Begin(ctx) if err != nil { return "", err } defer tx.Rollback(ctx) if _, err := tx.Exec(ctx, `UPDATE sessions SET revoked_at = now() WHERE id = $1`, oldSessionID); err != nil { return "", err } var id string if err := tx.QueryRow(ctx, ` INSERT INTO sessions (user_id, refresh_token_hash, user_agent, ip_address, expires_at) VALUES ($1, $2, $3, $4, $5) RETURNING id `, userID, newRefreshTokenHash, userAgent, ip, expiresAt).Scan(&id); err != nil { return "", err } return id, tx.Commit(ctx) } Note the old row is UPDATEd, not DELETEd. It stays around with revoked_at set. That retained tombstone is what makes the next section possible. Why rotate at all? Because it collapses the window in which a stolen refresh token is useful. Without rotation, a token lifted from a cookie jar, a proxy log, or a backup is good for its full TTL — 30 days in this service. With rotation, it's good only until the legitimate client next refreshes, typically minutes. After that the stolen token is a dead string. What rotation catches — and what it doesn't Say an attacker steals a refresh token and races the real user to the refresh endpoint. One of two things happens: Attacker refreshes first. They get a new token; the victim's copy is now revoked. When the victim next refreshes, they hit session.RevokedAt != nil and get bounced to the login screen. Victim refreshes first. The attacker's copy is the revoked one and their refresh fails. Either way, a rotated token that gets replayed is rejected — the retained tombstone row guarantees the lookup succeeds and the RevokedAt check fails it. That's real protection and it's the reason not to delete the old row. But look at what the service does on that rejection: it returns ErrSessionRevokedOrExpired and stops. It doesn't ask why a revoked token was just presented. In the race above, the honest user gets logged out and the attacker is still holding a live, freshly-rotated token. Rotation detected that something was wrong and then punished the wrong party. The known fix is automatic reuse detection: when a revoked refresh token is replayed, treat it as a compromise signal and revoke the entire token family — every descendant session minted from that lineage — forcing a full re-login. That needs one more column (a parent_id or a shared family_id on sessions) and a branch in Refresh that, on the "found but revoked" path, calls something like RevokeSessionFamily. This service doesn't do that yet. It rotates, and it rejects replays, but it doesn't escalate. If you're building this, the escalation is the part worth adding on day one, not day one hundred. The rest of the lifecycle Rotation is one way a session ends. The others: Logout revokes the current session by id. Password reset, "log out everywhere", and account deletion all call RevokeAllSessionsForUser — changing your password should not leave a month-old refresh token alive on some other device, and it doesn't. Expiry is just the expires_at check in Refresh; nothing has to run. There's also CSRF to think about, because the refresh endpoint is the one place the API authenticates purely from an ambient cookie. Every other authenticated call carries Authorization: Bearer , which a cross-site page can't attach — that alone kills classic CSRF for most of the surface. The refresh cookie can't rely on that, so it gets a double-submit token: a non-HttpOnly csrf_token cookie whose value must be echoed back in an X-CSRF-Token header. A malicious page can cause the cookie to be sent but can't read it to set the header, so the two won't match. func ValidCSRF(cookieValue, headerValue string) bool { if cookieValue == "" || headerValue == "" { return false } return subtle.ConstantTimeCompare([]byte(cookieValue), []byte(headerValue)) == 1 } Takeaways Split the tokens by job. Stateless JWT for the per-request check, opaque DB-backed token for the thing you need to revoke. Don't make one token do both. Keep the access TTL genuinely short. 15 minutes, not "a day, but we call it short". The refresh token is what makes that livable. Store only the hash of the refresh token, and because it's high-entropy, a fast hash is correct — this is the one place SHA-256 beats bcrypt. Rotate on every refresh, inside a transaction, and keep the old row. The tombstone is what lets you reject replays. Rotation alone is not reuse detection. Rejecting a replayed token is not the same as responding to it. If a revoked token comes back, kill the whole family — and build that in from the start. This is part of a series pulling backend fundamentals out of real project code — health checks, the transactional outbox, polyglot persistence, load testing, and more. Next up: /healthz vs /readyz and why the split is not pedantic.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to