Short-lived access, long-lived refresh โ and a way to take both back.
4a
The access token is used constantly and dies fast. The refresh token is used rarely and lives for days.
/auth/refresh4b
Access token in memory, sent as a Bearer header. Refresh token in a cookie JavaScript cannot touch.
http.SetCookie(w, &http.Cookie{ Name: "refresh_token", Value: refresh, Path: "/auth/refresh", // sent nowhere else MaxAge: int(refreshTTL.Seconds()), HttpOnly: true, // invisible to JavaScript Secure: true, // HTTPS only SameSite: http.SameSiteStrictMode, // no cross-site })
Set-Cookie: refresh_token=64d16837914bb63a; Path=/auth/refresh; Max-Age=604800; HttpOnly; Secure; SameSite=Strict
localStorage.4c
Every refresh swaps the old token for a new one. If an old one ever comes back, someone copied it.
func (s Store) Rotate(ctx context.Context, old string) (string, error) { v, err := s.rdb.GetDel(ctx, "rt:"+old).Result() if err == redis.Nil { // not live: reused or bogus? fam, _ := s.rdb.Get(ctx, "used:"+old).Result() if fam != "" { s.revokeFamily(ctx, fam) return "", ErrReuse } return "", ErrInvalid } if err != nil { return "", err // Redis down: fail closed } user, fam, _ := strings.Cut(v, "|") s.rdb.Set(ctx, "used:"+old, fam, refreshTTL) return s.Issue(ctx, user, fam), nil } // logout: deny this access token until it expires s.rdb.Set(ctx, "deny:"+claims.ID, 1, time.Until(exp))
login โ refresh 64d16837โฆ refresh โ refresh 9848b325โฆ <nil> replay r1 โ refresh token reused: family revoked use r2 โ refresh token invalid jti-7f3a denied: true ttl: 9m0s 10m later denied: false # the denylist key expires by itself once the token would have
GETDEL is atomic โ two parallel refreshes cannot both win.POST /auth/login โ bcrypt check โ 15-min access JWT in the body + 7-day refresh in an HttpOnly cookie.POST /auth/refresh โ rotate the cookie, return a new access token; reuse โ 401 and revoke the family.POST /auth/logout โ delete the refresh key, deny the access token's jti.RequireAuth also rejects denied jtis.Recap
HttpOnly; Secure; SameSite=Strict cookie scoped to one path.jti for exactly its remaining lifetime.