Every "add JWT auth to your API" tutorial stops at the same place: issue a token, check the signature, done. Nobody covers what happens six weeks later, when a support ticket comes in and you can't answer a very simple question — if a refresh token gets stolen, how would you even know?
GRAB, a Go REST API boilerplate I maintain, answers that with token rotation and reuse detection (full reference in the Authentication docs). This post walks through how that actually works, with the real implementation. And because I'd rather show engineering honestly than pretend it was flawless from day one, I'm including the part that isn't as flattering: a hardcoded fallback secret that shipped in two tagged releases before a code review caught it.
The naive version, and why it's a liability
The common pattern looks like this: issue a short-lived access token and a long-lived refresh token. When the access token expires, the client trades the refresh token for a new one. Simple — until you ask what happens if that refresh token leaks. A log line, an XSS payload, a MITM'd request on a coffee shop network — refresh tokens are long-lived and typically stored client-side, so they're a real target.
With the naive version, a stolen refresh token is indistinguishable from the legitimate one. It works until it expires — which, for a refresh token, could be days or weeks. There is no built-in way to tell "the real user" and "whoever stole their token" apart, and no signal that anything went wrong.
The answer: rotation + reuse detection
GRAB's refresh tokens are single-use. Every time one is redeemed, it's marked used and a brand-new refresh token is issued in its place — that's rotation. The part that actually matters is what happens if the same token is redeemed twice: the server treats it as proof of compromise and kills every token descended from it, not just the one that got reused.
That requires tracking lineage, which is what token_family is for:
CREATE TABLE IF NOT EXISTS refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(64) NOT NULL,
token_family UUID NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used_at TIMESTAMP WITH TIME ZONE,
revoked_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
Every refresh token belongs to a token_family — a UUID assigned at login that every rotated descendant inherits. used_at marks a token as spent. revoked_at marks it (and by extension, sometimes its whole family) as dead. Three walks through that table tell the whole story:
1. Login. A user authenticates and gets an access token (15 minutes) plus a refresh token — call it Token A — in a new family, F1.
2. Normal refresh. The client redeems Token A. The server marks it used and issues Token B, still in family F1. Token A is now permanently spent — it can never be redeemed again, even if it's still technically unexpired.
3. Reuse. Say Token A leaked before step 2 — a browser extension read it out of storage, whatever the vector. An attacker redeems it. But Token A is already marked used_at. The server doesn't quietly reject this and move on — it treats a second redemption of a spent token as a signal that the family is compromised, and revokes every token in F1. That includes Token B, which the legitimate client is currently holding and has never misused. The real user gets logged out and has to sign in again — an inconvenience, but a strictly better outcome than an attacker holding a live session indefinitely.
Here's the actual logic, from internal/auth/service.go:
func (s *service) RefreshAccessToken(ctx context.Context, refreshToken string) (*TokenPair, error) {
tokenHash := HashToken(refreshToken)
storedToken, err := s.refreshTokenRepo.FindByTokenHash(ctx, tokenHash)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrInvalidToken
}
return nil, fmt.Errorf("failed to find refresh token: %w", err)
}
if storedToken.RevokedAt != nil {
return nil, ErrTokenRevoked
}
if time.Now().After(storedToken.ExpiresAt) {
return nil, ErrExpiredToken
}
// A token that's already been used is being replayed. Treat the whole
// family as compromised, not just this one token.
if storedToken.UsedAt != nil {
if err := s.refreshTokenRepo.RevokeTokenFamily(ctx, storedToken.TokenFamily); err != nil {
return nil, fmt.Errorf("failed to revoke token family: %w", err)
}
return nil, ErrTokenReuse
}
if err := s.refreshTokenRepo.MarkAsUsed(ctx, storedToken.ID); err != nil {
return nil, fmt.Errorf("failed to mark token as used: %w", err)
}
// ...issue a new access token + new refresh token in the same family...
}
A few details worth calling out, because they're the kind of thing that's easy to get subtly wrong:
-
Tokens are never stored raw.
refresh_tokens.token_hashholdssha256(token), not the token itself —HashToken. A dump of that table is useless to an attacker without the original random value. -
MarkAsUsedis a conditional update, not a read-then-write. It'sUPDATE ... WHERE id = ? AND used_at IS NULL, checked byRowsAffected. Two concurrent requests racing to redeem the same token can't both win — only one update succeeds, which matters because "redeem" is exactly the kind of operation attackers try to race. -
The refresh token itself is 256 bits of
crypto/rand, base64-encoded — not a JWT. There's no reason to make it self-describing; it's a bearer credential for a database lookup, and an opaque random value is a smaller attack surface than a second signed token format.
The other way in: a secret that didn't need stealing
Rotation and reuse detection answer the question in the title — but they only cover theft. There's a second way into an account that requires stealing nothing from the user at all: forging the token outright. Access tokens are HMAC-signed JWTs, which means their entire security rests on the signing secret staying secret. In v1.1.0 and v2.0.0, NewService had this:
jwtSecret := cfg.Secret
if jwtSecret == "" {
jwtSecret = "default-secret-change-in-production"
}
The intent was reasonable enough — don't crash a fresh clone that hasn't set JWT_SECRET yet. The effect was much worse: if a deployment's config validation was ever bypassed or misconfigured, the service would silently sign tokens with a secret sitting in the repo's Git history, in plaintext, for anyone to read. No stolen token required — just the secret every clone of this repo already has.
A later review caught it, and the fix (#107) is intentionally boring: fail loudly instead of falling back.
func ValidateConfig(cfg *config.JWTConfig) error {
if cfg == nil {
return fmt.Errorf("fatal: JWT configuration is nil")
}
if cfg.Secret == "" {
return fmt.Errorf("fatal: JWT_SECRET is not set. Generate one with: make generate-jwt-secret")
}
if len(cfg.Secret) < 32 {
return fmt.Errorf("fatal: JWT_SECRET must be at least 32 characters (current: %d)", len(cfg.Secret))
}
return nil
}
NewService now panics on an invalid config instead of constructing a service that would sign tokens no attacker even needs to steal. The lesson generalizes past this one repo: a "helpful" default for a security-critical secret isn't helpful, it's a silent single point of failure. If a value has to be secret, the only acceptable behavior for "it's missing" is to refuse to start — never to substitute something that works.
The 32-character minimum and the make generate-jwt-secret command are documented in the Security Guide, along with the rest of GRAB's production hardening checklist.
Trying it yourself
GRAB is Docker-first — no local Go/Postgres setup needed:
git clone https://github.com/vahiiiid/go-rest-api-boilerplate.git
cd go-rest-api-boilerplate
make up
curl -X POST localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"demo@example.com","password":"password123"}'
The response includes both tokens; POST /api/v1/auth/refresh rotates them, and redeeming the same refresh token twice is a two-line way to watch reuse detection fire for yourself.
None of what's above lives in a handler somewhere convenient — the whole token-family flow sits entirely in the service layer, exactly where GRAB's own architecture rules say business logic belongs:
The same layering runs through the rest of the boilerplate — RBAC, rate limiting, health checks, centralized error handling, 89%+ test coverage — so if you're starting a Go API from scratch, the repo's on GitHub.
So — what actually happens when a refresh token gets stolen, on an API that planned for it? Not much. The token gets redeemed once by whoever gets to it first, a new one takes its place in the same family, and the moment anyone — attacker or legitimate client — tries to reuse the old one, that whole family dies with it. Worst case, a real user gets logged out and has to sign back in. Best case, an attacker with a stale token gets nothing at all. Either way, nobody holds a working session indefinitely. That's the answer most APIs don't have.
If reuse detection or the config-validation pattern is useful, a star on the repo helps other people find it — and issues/PRs are welcome if you spot something else worth hardening.



Top comments (0)