Bearer Tokens and Refresh Flows in Go APIs Explained
Build short-lived JWT access tokens and rotating refresh tokens in Go: pinned verification, hashed storage, reuse detection and a concurrent refresh trap.

An API token has two jobs that pull in opposite directions. It should be cheap to check, so that every request does not need a database round trip, and it should be easy to take away, so that a stolen one stops working. A single long-lived token cannot do both. A long-lived stateless token is cheap and cannot be revoked. A long-lived opaque token can be revoked and needs a lookup on every call.
Tested with Go 1.25.6, golang-jwt v5.3.1 and modernc.org/sqlite v1.50.0, macOS on Apple Silicon (arm64)
The standard answer, used by almost every OAuth 2 system, is two tokens. A short-lived access token is verified without a database. A long-lived refresh token is stored and checked, and is used only to get new access tokens. This article implements the pair in Go and then attacks it: replaying a refresh token, forging an access token, aiming one at the wrong API, refreshing from ten places at once. It extends the authentication article, which used a hand-rolled signed token to show the idea, and it is the foundation for an OAuth 2 server. Everything ran on Go 1.25.6 with golang-jwt/jwt v5.3.1 and modernc.org/sqlite v1.50.0.
The two tokens
The access token is a JWT, signed with HMAC-SHA256. It holds the subject, the scopes, an issuer, an audience and timestamps. Anyone with the key can verify it with no lookup. It lives for ten minutes. If it is stolen, the thief has at most ten minutes, and the server cannot shorten that: there is no list to remove it from. That limitation is the trade, and it is why the lifetime is short.
The refresh token is 32 random bytes, base64url encoded, opaque to the client. The server stores a hash of it (the raw token is never stored, as with API keys), along with a family id, the subject, the scopes, an expiry, and used_at and revoked_at columns. It lives for thirty days and works exactly once. Using it returns a new access token and a new refresh token, and marks the old one used. That is rotation.
The family id groups all the refresh tokens descended from one login. One device signing in starts one family, so revoking a family signs out that device without touching the user's other sessions.
package main
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"sync/atomic"
"time"
"github.com/golang-jwt/jwt/v5"
_ "modernc.org/sqlite"
)
const (
issuer = "https://api.example.com"
audience = "tasks-api"
accessTTL = 10 * time.Minute
refreshTTL = 30 * 24 * time.Hour
)
var (
ErrInvalidToken = errors.New("invalid token")
ErrReuse = errors.New("refresh token reuse detected: session revoked")
)
type Claims struct {
Scopes []string `json:"scp"`
jwt.RegisteredClaims
}
type Auth struct {
key []byte
db *sql.DB
now func() time.Time
}
var dbs atomic.Int64
func New(key []byte, now func() time.Time) *Auth {
db, err := sql.Open("sqlite", fmt.Sprintf("file:rt%d?mode=memory&cache=shared", dbs.Add(1)))
if err != nil {
panic(err)
}
db.SetMaxOpenConns(1)
if _, err := db.Exec(`CREATE TABLE refresh_tokens (
hash BLOB PRIMARY KEY, family TEXT NOT NULL, subject TEXT NOT NULL, scopes TEXT NOT NULL,
expires_at INTEGER NOT NULL, used_at INTEGER, revoked_at INTEGER)`); err != nil {
panic(err)
}
return &Auth{key, db, now}
}
// Access issues a short-lived signed JWT. Nothing is stored: that is the point, and the cost.
func (a *Auth) Access(sub string, scopes []string) (string, error) {
now := a.now()
t := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{scopes, jwt.RegisteredClaims{
Issuer: issuer, Audience: jwt.ClaimStrings{audience}, Subject: sub,
IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(accessTTL)),
}})
return t.SignedString(a.key)
}
// ParseAccess pins everything a verifier must pin: the algorithm, the issuer, the audience and the presence of an expiry.
func (a *Auth) ParseAccess(raw string) (*Claims, error) {
var c Claims
tok, err := jwt.ParseWithClaims(raw, &c, func(t *jwt.Token) (any, error) { return a.key, nil },
jwt.WithValidMethods([]string{"HS256"}),
jwt.WithIssuer(issuer),
jwt.WithAudience(audience),
jwt.WithExpirationRequired(),
jwt.WithTimeFunc(a.now),
jwt.WithLeeway(5*time.Second),
)
if err != nil || !tok.Valid {
return nil, fmt.Errorf("%w: %v", ErrInvalidToken, err)
}
return &c, nil
}
type Pair struct {
Access, Refresh string
ExpiresIn int
}
func random() string {
b := make([]byte, 32)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func hash(s string) []byte { h := sha256.Sum256([]byte(s)); return h[:] }
// Login starts a new family of refresh tokens: one per signed-in device.
func (a *Auth) Login(sub string, scopes []string) (Pair, error) {
return a.issue(random(), sub, scopes)
}
func (a *Auth) issue(family, sub string, scopes []string) (Pair, error) {
access, err := a.Access(sub, scopes)
if err != nil {
return Pair{}, err
}
refresh := random()
_, err = a.db.Exec(`INSERT INTO refresh_tokens (hash, family, subject, scopes, expires_at) VALUES (?, ?, ?, ?, ?)`,
hash(refresh), family, sub, join(scopes), a.now().Add(refreshTTL).Unix())
return Pair{access, refresh, int(accessTTL.Seconds())}, err
}
// Refresh exchanges a refresh token for a new pair and retires the old one. Presenting a token that was already
// exchanged means two parties hold it, so the whole family is revoked.
func (a *Auth) Refresh(old string) (Pair, error) {
tx, err := a.db.Begin()
if err != nil {
return Pair{}, err
}
defer tx.Rollback()
var family, sub, scopes string
var expires int64
var used, revoked sql.NullInt64
err = tx.QueryRow(`SELECT family, subject, scopes, expires_at, used_at, revoked_at FROM refresh_tokens WHERE hash = ?`, hash(old)).
Scan(&family, &sub, &scopes, &expires, &used, &revoked)
if errors.Is(err, sql.ErrNoRows) {
return Pair{}, ErrInvalidToken
} else if err != nil {
return Pair{}, err
}
now := a.now().Unix()
if used.Valid {
tx.Exec(`UPDATE refresh_tokens SET revoked_at = ? WHERE family = ? AND revoked_at IS NULL`, now, family)
tx.Commit()
return Pair{}, ErrReuse
}
if revoked.Valid || now >= expires {
return Pair{}, ErrInvalidToken
}
res, err := tx.Exec(`UPDATE refresh_tokens SET used_at = ? WHERE hash = ? AND used_at IS NULL`, now, hash(old))
if n, _ := res.RowsAffected(); err != nil || n != 1 {
return Pair{}, ErrInvalidToken
}
if err := tx.Commit(); err != nil {
return Pair{}, err
}
return a.issue(family, sub, split(scopes))
}
// Logout revokes the family the token belongs to.
func (a *Auth) Logout(refresh string) {
a.db.Exec(`UPDATE refresh_tokens SET revoked_at = ? WHERE family = (SELECT family FROM refresh_tokens WHERE hash = ?) AND revoked_at IS NULL`,
a.now().Unix(), hash(refresh))
}
func join(s []string) string {
out := ""
for i, x := range s {
if i > 0 {
out += " "
}
out += x
}
return out
}
func split(s string) []string {
var out []string
cur := ""
for _, r := range s {
if r == ' ' {
out = append(out, cur)
cur = ""
continue
}
cur += string(r)
}
if cur != "" {
out = append(out, cur)
}
return out
}Verifying the access token properly
ParseAccess is the part with the most ways to go wrong, and the library's options are how you close them. The call pins:
- The algorithm:
WithValidMethods([]string{"HS256"}). Without this, a verifier that trusts the token's ownalgheader can be tricked: a token that saysnonehas no signature, and a token that saysHS256when your key is an RSA public key can be signed with that public key as the HMAC secret. Both are real, documented attack classes. The server decides the algorithm; the token does not get a say. - The issuer and audience:
WithIssuerandWithAudience. A token issued for the billing API, with a valid signature from the same key, must not be accepted by the tasks API. The audience claim is how a shared key stays safe. - Expiry present:
WithExpirationRequired. By default a token with noexpclaim is valid forever. - The clock:
WithTimeFuncmakes time injectable for tests, andWithLeewayforgives five seconds of clock skew between servers.
What the run showed
package main
import (
"fmt"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
)
func main() {
clock := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
a := New([]byte("a-32-byte-secret-for-hmac-signing"), func() time.Time { return clock })
show := func(label string, tok string) {
c, err := a.ParseAccess(tok)
if err != nil {
fmt.Printf("%-36s rejected: %v\n", label, firstClause(err.Error()))
return
}
fmt.Printf("%-36s ok: sub=%s scopes=%v expires in %s\n", label, c.Subject, c.Scopes, c.ExpiresAt.Sub(clock))
}
p1, _ := a.Login("ada", []string{"tasks:write"})
fmt.Printf("access token: %d chars, expires_in=%d s\nrefresh token: %d chars, opaque\n\n", len(p1.Access), p1.ExpiresIn, len(p1.Refresh))
show("fresh access token", p1.Access)
clock = clock.Add(11 * time.Minute)
show("11 minutes later", p1.Access)
fmt.Println("\n== refresh rotation")
p2, err := a.Refresh(p1.Refresh)
fmt.Printf("refresh #1: err=%v new refresh differs=%v\n", err, p2.Refresh != p1.Refresh)
show("new access token", p2.Access)
_, err = a.Refresh(p1.Refresh)
fmt.Println("replaying the first refresh token:", err)
_, err = a.Refresh(p2.Refresh)
fmt.Println("then the newest one, same family: ", err)
fmt.Println("\n== forged and mis-aimed access tokens")
none := jwt.NewWithClaims(jwt.SigningMethodNone, Claims{[]string{"admin"}, jwt.RegisteredClaims{
Issuer: issuer, Audience: jwt.ClaimStrings{audience}, Subject: "mallory", ExpiresAt: jwt.NewNumericDate(clock.Add(time.Hour))}})
forged, _ := none.SignedString(jwt.UnsafeAllowNoneSignatureType)
show(`alg "none"`, forged)
other := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{nil, jwt.RegisteredClaims{
Issuer: issuer, Audience: jwt.ClaimStrings{audience}, Subject: "mallory", ExpiresAt: jwt.NewNumericDate(clock.Add(time.Hour))}})
wrongKey, _ := other.SignedString([]byte("some-other-secret-some-other-secre"))
show("signed with another key", wrongKey)
wrongAud := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{nil, jwt.RegisteredClaims{
Issuer: issuer, Audience: jwt.ClaimStrings{"billing-api"}, Subject: "ada", ExpiresAt: jwt.NewNumericDate(clock.Add(time.Hour))}})
s, _ := wrongAud.SignedString(a.key)
show("valid signature, wrong audience", s)
noExp := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{nil, jwt.RegisteredClaims{
Issuer: issuer, Audience: jwt.ClaimStrings{audience}, Subject: "ada"}})
s, _ = noExp.SignedString(a.key)
show("valid signature, no expiry", s)
fmt.Println("\n== ten concurrent refreshes of one token")
p3, _ := a.Login("grace", []string{"tasks:read"})
var wg sync.WaitGroup
var mu sync.Mutex
wins, reuse := 0, 0
var winner Pair
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
p, err := a.Refresh(p3.Refresh)
mu.Lock()
defer mu.Unlock()
if err == nil {
wins++
winner = p
} else {
reuse++
}
}()
}
wg.Wait()
fmt.Printf("succeeded: %d, rejected: %d\n", wins, reuse)
_, err = a.Refresh(winner.Refresh)
fmt.Println("the winner's new refresh token afterwards:", err, "(the losers looked like a replay)")
fmt.Println("\n== logout")
p4, _ := a.Login("linus", nil)
a.Logout(p4.Refresh)
_, err = a.Refresh(p4.Refresh)
fmt.Println("refresh after logout:", err)
}
func firstClause(s string) string {
if i := strings.Index(s, ": "); i >= 0 {
return s[i+2:]
}
return s
}access token: 265 chars, expires_in=600 s
refresh token: 43 chars, opaque
fresh access token ok: sub=ada scopes=[tasks:write] expires in 10m0s
11 minutes later rejected: token has invalid claims: token is expired
== refresh rotation
refresh #1: err=<nil> new refresh differs=true
new access token ok: sub=ada scopes=[tasks:write] expires in 10m0s
replaying the first refresh token: refresh token reuse detected: session revoked
then the newest one, same family: invalid token
== forged and mis-aimed access tokens
alg "none" rejected: token signature is invalid: signing method none is invalid
signed with another key rejected: token signature is invalid: signature is invalid
valid signature, wrong audience rejected: token has invalid claims: token has invalid audience
valid signature, no expiry rejected: token has invalid claims: token is missing required claim: exp claim is required
== ten concurrent refreshes of one token
succeeded: 1, rejected: 9
the winner's new refresh token afterwards: invalid token (the losers looked like a replay)
== logout
refresh after logout: invalid tokenA login returns a 265-character access token and a 43-character opaque refresh token. The access token verifies, with ten minutes left. Eleven minutes later it is token is expired, which is the whole revocation story for access tokens: wait.
Rotation. The first refresh succeeds and returns a different refresh token. Presenting the first refresh token again returns refresh token reuse detected: session revoked. Think about what that means: only two parties could hold a used token, the legitimate client that already exchanged it and someone who copied it. The server cannot tell which is which, so it ends the session. The next line shows that: the newest refresh token, which the legitimate client holds, now fails too, and the user has to log in again. That is the intended outcome; stealing a refresh token costs the thief the session as soon as either side uses it twice.
Forgeries. alg: none is refused (signing method none is invalid). A token signed with another key fails the signature. One with a valid signature and the wrong audience is refused, so a token from a different service sharing the key cannot be replayed here. One without an expiry is refused because exp is required. Each of those is accepted by a verifier that skipped the corresponding option.
Concurrency. Ten goroutines refresh the same token. Exactly one succeeded, nine were rejected, and the winner's new refresh token then also failed. The losers were indistinguishable from a replay, so the family was revoked, and the winner got caught in it. This is a real production problem: a mobile app that fires two requests at the moment the access token expires may refresh twice. The fixes belong to the client first (serialise refresh behind a single in-flight request and let the others wait for its result) and the server second (some systems accept a reused token for a few seconds and return the same successor, at the price of a weaker replay window). The code here is strict on purpose.
Logout. Logout revokes the family, and the refresh token is dead.
Tests
package main
import (
"errors"
"sync"
"sync/atomic"
"testing"
"time"
)
func fixture() (*Auth, *time.Time) {
clock := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
return New([]byte("a-32-byte-secret-for-hmac-signing"), func() time.Time { return clock }), &clock
}
func TestAccessTokenExpires(t *testing.T) {
a, clock := fixture()
tok, _ := a.Access("ada", nil)
if _, err := a.ParseAccess(tok); err != nil {
t.Fatal(err)
}
*clock = clock.Add(accessTTL + time.Minute)
if _, err := a.ParseAccess(tok); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("got %v", err)
}
}
func TestRefreshIsSingleUseAndReuseKillsTheFamily(t *testing.T) {
a, _ := fixture()
first, _ := a.Login("ada", nil)
second, err := a.Refresh(first.Refresh)
if err != nil {
t.Fatal(err)
}
if _, err := a.Refresh(first.Refresh); !errors.Is(err, ErrReuse) {
t.Fatalf("replay: %v", err)
}
if _, err := a.Refresh(second.Refresh); err == nil {
t.Fatal("the newest token still works after a replay was detected")
}
}
func TestOnlyOneConcurrentRefreshWins(t *testing.T) {
a, _ := fixture()
p, _ := a.Login("ada", nil)
var wins atomic.Int64
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := a.Refresh(p.Refresh); err == nil {
wins.Add(1)
}
}()
}
wg.Wait()
if wins.Load() != 1 {
t.Fatalf("%d refreshes succeeded", wins.Load())
}
}
func TestStoreHoldsOnlyHashes(t *testing.T) {
a, _ := fixture()
p, _ := a.Login("ada", nil)
var n int
a.db.QueryRow(`SELECT count(*) FROM refresh_tokens WHERE instr(hash, ?) > 0`, p.Refresh).Scan(&n)
if n != 0 {
t.Fatal("the refresh token is stored in the clear")
}
}=== RUN TestAccessTokenExpires
--- PASS: TestAccessTokenExpires (0.01s)
=== RUN TestRefreshIsSingleUseAndReuseKillsTheFamily
--- PASS: TestRefreshIsSingleUseAndReuseKillsTheFamily (0.01s)
=== RUN TestOnlyOneConcurrentRefreshWins
--- PASS: TestOnlyOneConcurrentRefreshWins (0.02s)
=== RUN TestStoreHoldsOnlyHashes
--- PASS: TestStoreHoldsOnlyHashes (0.00s)
PASS
ok example.test/lab 1.413sThe tests cover expiry with an injected clock, single use with reuse detection killing the family, exactly one winner out of twenty concurrent refreshes under the race detector, and the guarantee that the store holds only hashes. The single-winner property comes from the conditional update (WHERE used_at IS NULL) inside a transaction. Reading the row, checking used_at in Go and then writing would let two refreshes both pass the check.
Decisions to make
HS256 or an asymmetric algorithm. HMAC is simple, and every service that verifies tokens needs the secret, which means every service can also mint them. If several services verify tokens issued by one, sign with a private key (EdDSA or RS256) and distribute only the public key, ideally as a JWKS document so keys can rotate. Include a kid header so verifiers know which key to use.
Where the client keeps them. A mobile app uses the platform's secure storage. A browser should not put the refresh token in localStorage, where any script on the page can read it; keep it in an HttpOnly, Secure, SameSite cookie scoped to the refresh path, as in the session design from the authentication article, and keep the access token in memory.
What to put in the access token. Subject, scopes, issuer, audience, timestamps, and little else. A JWT is signed, not encrypted: anyone holding it can read the claims. Do not put personal data in it, and remember that changes (a removed role) do not take effect until the token expires.
Emergency revocation. When ten minutes is too long, keep a small denylist of token ids (jti) or of subjects with a "tokens issued before" timestamp, and check it per request. That reintroduces state, so reserve it for incidents.
Sizes and headers. The 265-character token travels on every request. It is small, but it is not free; keep the claim set small.
Refresh endpoint hygiene. Rate-limit it, log every use with the family id, and alert on reuse detection. A reuse event is the closest thing to a theft alarm this design has.
Use a library, with all the options. Rolling your own signed token is fine for a teaching article and a poor idea in production. The options shown here are the ones that matter; the library is documented at pkg.go.dev/github.com/golang-jwt/jwt/v5, and the formats and claims are defined in RFC 7519. The guidance on using refresh tokens and detecting their reuse is in the OAuth 2.0 security best current practice, RFC 9700.


