Back to News & Insights
Web Development September 8, 2026 · 6 min read

Rotating refresh tokens: Why critical in authorization

A walk through a real Go + Postgres session layer — short-lived access JWTs, opaque rotating refresh tokens stored only as hashes, and the reuse case that rotation still leaves on the table.

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.

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.

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:

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.

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.

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

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.

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:

ReplaceSession does the swap inside a transaction so there's never a window with zero valid tokens or two:

Note the old row is UPDATEd, not DELETEd. It stays around with revokedat 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.

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 parentid or a shared familyid 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.

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 expiresat check in Refresh; nothing has to run.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation