Stage 7 · Master
Phase 4 — Authentication Service
Refresh Tokens
Give access tokens a long-lived companion that survives a browser refresh without living in JavaScript-reachable storage, and make token theft detectable the moment a stolen token is reused.
A 15-Minute Access Token Needs a Renewal Story
If a token expires in 15 minutes with no renewal mechanism, every user is forcibly logged out every 15 minutes — untenable. The standard answer is a second, longer-lived refresh token that a client exchanges for a new access token without re-entering a password. The security question this raises is entirely about where that longer-lived, more valuable token lives and what happens the moment it leaks.
Unlike the access token, the refresh token is not a self-describing JWT — it is a random 256-bit value whose only meaning comes from a lookup in the refresh_tokens table. This is deliberate: a refresh token that can be revoked requires a database check on use anyway, so there is no verification-cost benefit to making it a JWT, and an opaque token leaks zero information if intercepted.
httpOnly Cookie Delivery Closes Off the Biggest Theft Vector
The refresh token is set via Set-Cookie with HttpOnly, Secure, and SameSite=Strict. HttpOnly means no JavaScript running on the page — including an XSS payload from a compromised third-party script — can ever read document.cookie and exfiltrate it. SameSite=Strict means the browser will not attach the cookie to any cross-site request, closing the most common CSRF delivery path before it starts.
SameSite=Strict is not honored by every legacy browser, and top-level navigations from external links can still, in some configurations, be treated as same-site. A double-submit CSRF token — a value the server also expects as a header, which a cross-site attacker cannot read or set — is layered on top and covered fully in security-best-practices.
One Row Per Token, Chained Into a Revocable Family
CREATE TABLE refresh_tokens (
id uuid PRIMARY KEY,
family_id uuid NOT NULL,
user_id uuid NOT NULL,
org_id uuid NOT NULL,
token_hash text NOT NULL UNIQUE,
replaced_by uuid REFERENCES refresh_tokens (id),
revoked_at timestamptz,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_refresh_tokens_family ON refresh_tokens (family_id);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens (user_id);
token_hash stores SHA-256 of the token, never the raw value — the database is a plausible leak surface (a backup, a read replica misconfiguration), and a hash is useless to an attacker without the original token. family_id is what makes replay detection possible: every token born from the same original login shares one family_id through the whole rotation chain.
DROP TABLE refresh_tokens;
Dropping the table also drops idx_refresh_tokens_family and idx_refresh_tokens_user, since both indexes belong to it.
Why Reusing an Old Refresh Token Revokes the Whole Family
Every time a refresh token is exchanged for a new access token, the refresh token itself is rotated: the old row is marked with replaced_by pointing at a brand-new row, and only the new token is returned to the client. This single rule — a refresh token is single-use — is what turns token theft from silent to detectable.
package token
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"time"
"github.com/google/uuid"
)
var ErrReplayDetected = errors.New("refresh token reuse detected; family revoked")
const refreshTokenTTL = 30 * 24 * time.Hour
type RefreshStore interface {
Insert(ctx context.Context, r RefreshRecord) error
RotateAtomic(
ctx context.Context,
currentHash string,
nextID uuid.UUID,
nextHash string,
nextExpiresAt time.Time,
) (RefreshRecord, error)
}
type RefreshRecord struct {
ID uuid.UUID
FamilyID uuid.UUID
UserID uuid.UUID
OrgID uuid.UUID
TokenHash string
ReplacedBy *uuid.UUID
RevokedAt *time.Time
ExpiresAt time.Time
}
func newOpaqueToken() (raw, hash string, err error) {
buf := make([]byte, 32)
if _, err = rand.Read(buf); err != nil {
return "", "", err
}
raw = base64.RawURLEncoding.EncodeToString(buf)
sum := sha256.Sum256([]byte(raw))
hash = hex.EncodeToString(sum[:])
return raw, hash, nil
}
// IssueFamily creates the first refresh token of a brand-new family, at login.
func IssueFamily(ctx context.Context, store RefreshStore, userID, orgID uuid.UUID) (raw string, err error) {
raw, hash, err := newOpaqueToken()
if err != nil {
return "", err
}
familyID := uuid.New()
rec := RefreshRecord{
ID: uuid.New(), FamilyID: familyID, UserID: userID, OrgID: orgID,
TokenHash: hash, ExpiresAt: time.Now().Add(refreshTokenTTL),
}
if err := store.Insert(ctx, rec); err != nil {
return "", err
}
return raw, nil
}
// Rotate exchanges rawToken for a new refresh token, or detects replay and
// revokes the entire family if rawToken had already been rotated once before.
func Rotate(ctx context.Context, store RefreshStore, rawToken string) (newRaw string, rec RefreshRecord, err error) {
sum := sha256.Sum256([]byte(rawToken))
hash := hex.EncodeToString(sum[:])
newRawToken, newHash, err := newOpaqueToken()
if err != nil {
return "", RefreshRecord{}, err
}
next, err := store.RotateAtomic(
ctx, hash, uuid.New(), newHash, time.Now().Add(refreshTokenTTL),
)
if err != nil {
return "", RefreshRecord{}, err
}
return newRawToken, next, nil
}The service no longer performs read-insert-update as three independent calls. RotateAtomic owns the row lock, replay decision, successor insert, and replaced_by update as one transaction, so two concurrent exchanges cannot both win.
package token
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
type PostgresRefreshStore struct {
pool *pgxpool.Pool
}
func (s *PostgresRefreshStore) RotateAtomic(
ctx context.Context,
currentHash string,
nextID uuid.UUID,
nextHash string,
nextExpiresAt time.Time,
) (RefreshRecord, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return RefreshRecord{}, err
}
defer tx.Rollback(ctx)
var current RefreshRecord
err = tx.QueryRow(ctx, `
SELECT id, family_id, user_id, org_id, replaced_by, revoked_at, expires_at
FROM refresh_tokens
WHERE token_hash = $1
FOR UPDATE`, currentHash,
).Scan(
¤t.ID, ¤t.FamilyID, ¤t.UserID, ¤t.OrgID,
¤t.ReplacedBy, ¤t.RevokedAt, ¤t.ExpiresAt,
)
if err != nil {
return RefreshRecord{}, err
}
if current.RevokedAt != nil || current.ReplacedBy != nil {
if _, err := tx.Exec(ctx,
`UPDATE refresh_tokens SET revoked_at = now()
WHERE family_id = $1 AND revoked_at IS NULL`, current.FamilyID,
); err != nil {
return RefreshRecord{}, err
}
if err := tx.Commit(ctx); err != nil {
return RefreshRecord{}, err
}
return RefreshRecord{}, ErrReplayDetected
}
if time.Now().After(current.ExpiresAt) {
return RefreshRecord{}, errors.New("refresh token expired")
}
next := RefreshRecord{
ID: nextID, FamilyID: current.FamilyID, UserID: current.UserID,
OrgID: current.OrgID, TokenHash: nextHash, ExpiresAt: nextExpiresAt,
}
if _, err := tx.Exec(ctx, `
INSERT INTO refresh_tokens
(id, family_id, user_id, org_id, token_hash, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
next.ID, next.FamilyID, next.UserID, next.OrgID, next.TokenHash, next.ExpiresAt,
); err != nil {
return RefreshRecord{}, err
}
if _, err := tx.Exec(ctx,
`UPDATE refresh_tokens SET replaced_by = $1 WHERE id = $2`,
next.ID, current.ID,
); err != nil {
return RefreshRecord{}, err
}
return next, tx.Commit(ctx)
}FOR UPDATE serializes contenders on the presented token row. The loser wakes after the winner commits, observes replaced_by, revokes the family in its own transaction, and returns ErrReplayDetected.
The Refresh Endpoint's Cookie Contract
package httpapi
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
)
func setRefreshCookie(c *gin.Context, rawToken string) {
http.SetCookie(c.Writer, &http.Cookie{
Name: "hoa_refresh",
Value: rawToken,
Path: "/api/v1/auth",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
Expires: time.Now().Add(30 * 24 * time.Hour),
})
}
func clearRefreshCookie(c *gin.Context) {
http.SetCookie(c.Writer, &http.Cookie{
Name: "hoa_refresh", Value: "", Path: "/api/v1/auth",
HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode,
MaxAge: -1,
})
}Path is scoped to /api/v1/auth rather than the whole origin — the browser will not even attach this cookie to a request for, say, /api/v1/users, shrinking the set of endpoints that ever see the raw refresh token to exactly the ones that need it. Taking *gin.Context rather than http.ResponseWriter costs nothing here: c.Writer satisfies http.ResponseWriter directly, so the standard library's own http.SetCookie works unchanged — Gin's own c.SetCookie helper trades away the Expires and SameSite fields this cookie's contract depends on, so the stdlib call is the more precise choice, not a workaround.
Proving Replay Detection Actually Revokes
Applied exercise
Handle a network retry that looks exactly like replay
A mobile client's refresh request succeeds on the server, but a flaky connection drops the response before the client sees the new token. The client's retry logic resubmits the original (now-rotated) refresh token, and RevokeFamily fires — logging out a legitimate user, indistinguishable so far from an actual attack.
- Propose a grace-period design (e.g., a short window during which the immediately-prior token in a family is still accepted and returns the same next token rather than revoking) and state its tradeoff against pure replay detection.
- Decide what should happen if two different rotated-but-unconfirmed requests for the same old token arrive concurrently — should both receive the same new token, or does that reopen the theft window?
- State how this grace period interacts with revoked family detection: does a client's second retry during the grace window ever get treated as an attacker?
Deliverable
A written grace-period design with explicit tradeoffs against pure single-use rotation.
Completion checks
- The grace period is bounded and short (seconds, not minutes) — the exercise should not eliminate replay detection, only tolerate immediate network retries.
- The concurrency question is answered explicitly, not left implicit.
Why does presenting an already-rotated refresh token revoke the entire token family instead of just rejecting that one request?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.