← All posts
Security

JWT Refresh Token Rotation and Blacklisting in Go (with Redis)

By Sayan Mondal3 min read

A JWT is convenient because your API can verify it without a database lookup. That is also the problem: once issued, it stays valid until it expires, even if the user logs out or the token is stolen. Refresh token rotation, with a small Redis blacklist, is how you get the convenience of JWTs without paying for it in security.

I implemented this pattern, along with OTP login and Redis-backed rate limiting, in an open-source Go API gateway (source on GitHub). Here is how the pieces fit together.

The design in four rules

  1. Access tokens are short-lived JWTs. Five to fifteen minutes. Your API verifies the signature and expiry, nothing else.
  2. Refresh tokens are long-lived, random and opaque. Not JWTs. They mean nothing except as a key to look up in Redis.
  3. Refresh tokens rotate. Using one returns a new access and refresh token and invalidates the old refresh token immediately.
  4. Reuse is an alarm. If an already-used refresh token shows up again, revoke the whole family of tokens for that login.

Issuing and verifying access tokens

This uses golang-jwt v5. Put the user ID in the standard sub claim and give every token a unique jti, because that ID is what you blacklist later. The example uses HS256 to stay short. If other services need to verify tokens, prefer an asymmetric algorithm (EdDSA or RS256) so they never hold the signing secret.

access_token.gogo
package auth

import (
	"time"

	"github.com/golang-jwt/jwt/v5"
	"github.com/google/uuid"
)

const accessTTL = 10 * time.Minute

func IssueAccessToken(userID string, secret []byte) (string, error) {
	now := time.Now()
	claims := jwt.RegisteredClaims{
		Subject:   userID,
		ID:        uuid.NewString(), // jti, used for blacklisting
		IssuedAt:  jwt.NewNumericDate(now),
		ExpiresAt: jwt.NewNumericDate(now.Add(accessTTL)),
	}
	return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
}

func VerifyAccessToken(tokenStr string, secret []byte) (*jwt.RegisteredClaims, error) {
	claims := &jwt.RegisteredClaims{}
	_, err := jwt.ParseWithClaims(
		tokenStr,
		claims,
		func(*jwt.Token) (any, error) { return secret, nil },
		jwt.WithValidMethods([]string{"HS256"}), // never trust the token's own alg header
		jwt.WithExpirationRequired(),
	)
	if err != nil {
		return nil, err
	}
	return claims, nil
}

Storing refresh tokens safely

Generate refresh tokens from 32 bytes of cryptographic randomness and store only their SHA-256 hash in Redis, the same way you would treat a password. If Redis is ever dumped, the hashes cannot be used to sign in. Each entry maps the hash to the user and a family ID, which groups every token descended from one login.

refresh_token.gogo
package auth

import (
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
)

func NewRefreshToken() (raw string, err error) {
	b := make([]byte, 32)
	if _, err = rand.Read(b); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

func hashToken(raw string) string {
	sum := sha256.Sum256([]byte(raw))
	return hex.EncodeToString(sum[:])
}

Rotation with reuse detection

Consuming a refresh token means deleting it, so a second use finds nothing. That alone rotates. To detect reuse, also leave a short-lived marker for consumed tokens. If a token is unknown but its marker exists, someone is replaying an old token, and you revoke the family.

rotate.gogo
package auth

import (
	"context"
	"errors"
	"strings"
	"time"

	"github.com/redis/go-redis/v9"
)

const refreshTTL = 30 * 24 * time.Hour

var ErrInvalidToken = errors.New("invalid refresh token")

type Store struct{ rdb *redis.Client }

// Save records a new refresh token for a user's token family.
func (s *Store) Save(ctx context.Context, raw, userID, familyID string) error {
	return s.rdb.Set(ctx, "rt:"+hashToken(raw), userID+":"+familyID, refreshTTL).Err()
}

// Rotate consumes a presented refresh token. The caller then issues a new pair
// and calls Save again for the replacement token in the same family.
func (s *Store) Rotate(ctx context.Context, presented string) (userID, familyID string, err error) {
	h := hashToken(presented)

	val, err := s.rdb.GetDel(ctx, "rt:"+h).Result()
	if errors.Is(err, redis.Nil) {
		// Unknown token. If we've seen it before, it was replayed: revoke its family.
		if fam, e := s.rdb.Get(ctx, "used:"+h).Result(); e == nil {
			s.rdb.Set(ctx, "revoked:"+fam, 1, refreshTTL)
		}
		return "", "", ErrInvalidToken
	}
	if err != nil {
		return "", "", err
	}

	userID, familyID, _ = strings.Cut(val, ":")
	if s.rdb.Exists(ctx, "revoked:"+familyID).Val() == 1 {
		return "", "", ErrInvalidToken
	}

	s.rdb.Set(ctx, "used:"+h, familyID, refreshTTL) // marker for reuse detection
	return userID, familyID, nil
}

One honest caveat: the delete and the marker write are two separate Redis calls, so there is a tiny window between them. In production, combine them in a Lua script so they run atomically, the same technique used for atomic rate limiting.

Blacklisting access tokens for logout

Because access tokens are verified without a lookup, logging a user out does not invalidate the one already issued. To close that gap, store the token's jti in Redis until it would have expired anyway. The blacklist stays tiny because entries disappear on their own.

blacklist.gogo
func (s *Store) RevokeAccess(ctx context.Context, jti string, exp time.Time) error {
	ttl := time.Until(exp)
	if ttl <= 0 {
		return nil // already expired, nothing to do
	}
	return s.rdb.Set(ctx, "bl:"+jti, 1, ttl).Err()
}

func (s *Store) IsRevoked(ctx context.Context, jti string) (bool, error) {
	n, err := s.rdb.Exists(ctx, "bl:"+jti).Result()
	return n == 1, err
}

In your auth middleware, verify the signature and expiry first, then call IsRevoked. That adds one fast Redis lookup per request. If you don't need instant logout, skip the blacklist and rely on the short lifetime instead.

Where to keep the tokens in the browser

  • Refresh token: an httpOnly, Secure, SameSite cookie, scoped to the refresh endpoint. JavaScript never sees it.
  • Access token: keep it in memory. Fetch a new one with the refresh cookie when the page loads.
  • Avoid localStorage for long-lived tokens. Any XSS bug can read it.

Frequently asked questions

What is refresh token rotation?

Each time a refresh token is used, the server returns a new access token and a new refresh token, and the old refresh token stops working. A stolen token then has a very short useful life.

How does reuse detection work?

The server remembers refresh tokens it has already consumed. If one is presented again, either the client or an attacker has a stale copy, so the whole token family is revoked and the user must sign in again.

Do I need to blacklist access tokens?

Only if you need immediate logout or revocation. With short lifetimes you can skip it. When you do need it, store the token's ID in Redis until it would have expired anyway.

Where should I store tokens in the browser?

Keep the refresh token in an httpOnly, Secure, SameSite cookie so JavaScript cannot read it. Avoid localStorage for anything long-lived, since any XSS bug can steal it.

How long should tokens live?

A common starting point is 5 to 15 minutes for access tokens and 7 to 30 days for refresh tokens, adjusted for how sensitive your app is.