Skip to content

Token Refresh & Rotation

CyberGo JWT uses a two-tier token design: short-lived access tokens for API authentication, and long-lived refresh tokens for obtaining new access tokens after they expire. This design balances security with user experience.

Two-Tier Token Model

Token TypeIssuance MethodDefault TTLPurpose
Access tokenCreate15 minutesAPI authentication, frequent validation
Refresh tokenCreateRefresh7 daysExchange for new access tokens, infrequent use

Token type is marked via the token_type claim (access / refresh). The Refresh method rejects access tokens, preventing them from being used to obtain new tokens.

TTL Configuration

go
cfg := jwt.DefaultConfig()
cfg.SecretKey = "hmac-key-that-has-at-least-32-bytes!"
cfg.AccessTokenTTL = 15 * time.Minute    // Access token lifetime
cfg.RefreshTokenTTL = 7 * 24 * time.Hour // Refresh token lifetime (must be > AccessTokenTTL)

Constraint

Config.Validate() requires RefreshTokenTTL > AccessTokenTTL, otherwise it returns ErrInvalidConfig.

Basic Refresh Flow

1. Issue Token Pair

go
package main

import (
    "fmt"
    "time"

    "github.com/cybergodev/jwt"
)

func main() {
    cfg := jwt.DefaultConfig()
    cfg.SecretKey = "hmac-key-that-has-at-least-32-bytes!"
    cfg.AccessTokenTTL = 15 * time.Minute
    cfg.RefreshTokenTTL = 7 * 24 * time.Hour

    processor, err := jwt.New(cfg)
    if err != nil {
        panic(err)
    }
    defer processor.Close()

    claims := &jwt.Claims{UserID: "user123", Username: "alice"}

    // Access token (short-lived)
    accessToken, err := processor.Create(claims)
    if err != nil {
        panic(err)
    }

    // Refresh token (long-lived)
    refreshToken, err := processor.CreateRefresh(claims)
    if err != nil {
        panic(err)
    }

    fmt.Println("Access Token:", accessToken)
    fmt.Println("Refresh Token:", refreshToken)
}

2. Refresh to Get a New Access Token

When the access token expires, use the refresh token to obtain a new one:

go
// refreshToken was previously issued via CreateRefresh
newAccessToken, err := processor.Refresh(refreshToken)
if err != nil {
    switch {
    case errors.Is(err, jwt.ErrTokenExpired):
        // Refresh token expired, user needs to re-authenticate
    case errors.Is(err, jwt.ErrTokenRevoked):
        // Refresh token has been revoked
    case errors.Is(err, jwt.ErrTokenTypeMismatch):
        // An access token was passed instead of a refresh token
    default:
        // Other error
    }
    return
}
fmt.Println("New Access Token:", newAccessToken)

Refresh fully validates the refresh token: signature, expiration, issuer, audience, and blacklist status.

Custom Claims Refresh

When using custom Claims types, use RefreshInto to populate the parsed result into a custom struct:

go
type MyClaims struct {
    UserID string `json:"user_id"`
    Role   string `json:"role"`
    jwt.RegisteredClaims
}

func (c *MyClaims) GetRegisteredClaims() *jwt.RegisteredClaims {
    return &c.RegisteredClaims
}

func (c *MyClaims) Validate() error {
    if c.UserID == "" {
        return errors.New("user_id is required")
    }
    return nil
}
go
// Issue a refresh token with custom Claims
refreshToken, err := processor.CreateRefresh(&MyClaims{UserID: "123", Role: "admin"})

// Refresh into custom struct
result := &MyClaims{}
newToken, err := processor.RefreshInto(refreshToken, result)

Rotation Strategies

Reuse Mode (Default)

By default, Refresh does not revoke the original refresh token. The original token remains valid until it expires or is explicitly revoked, and can be used multiple times:

go
// First refresh
token1, err := processor.Refresh(refreshToken)
if err != nil {
    panic(err)
}

// The same refreshToken is still valid and can be used again
token2, err := processor.Refresh(refreshToken)
if err != nil {
    panic(err)
}

Use cases: Mobile apps, single-device login. Users don't need to re-authenticate frequently; the refresh token can be reused within its TTL.

One-Time Rotation

For higher-security scenarios, revoke the old refresh token immediately after each refresh to achieve one-time use:

go
// Refresh and immediately revoke the old token
newAccessToken, err := processor.Refresh(refreshToken)
if err != nil {
    panic(err)
}

// Revoke the old refresh token so it cannot be used again
if err := processor.Revoke(refreshToken); err != nil {
    panic(err)
}

Use cases: Web applications, high-security systems. The old token becomes invalid immediately after each refresh, reducing the risk of token leakage.

Strategy Comparison

DimensionReuse ModeOne-Time Rotation
SecurityLower (leaked tokens can be reused)Higher (leaked tokens are single-use only)
User experienceBetter (no frequent re-authentication)Average (refresh failure requires re-login)
ImplementationNo extra codeRequires calling Revoke
Blacklist pressureLowHigher (each refresh adds an entry)

Refresh Token Leak Detection

In one-time rotation mode, if an attacker uses a revoked refresh token, Refresh returns ErrTokenRevoked. The application layer can use this to detect token leakage and force re-authentication.

Type Safety

CyberGo JWT distinguishes token types via the token_type claim. Refresh and RefreshInto reject access tokens:

go
// Attempting to refresh with an access token is rejected
_, err := processor.Refresh(accessToken)
// err wraps ErrTokenTypeMismatch: expected refresh token, got access token

This prevents access tokens from being used to obtain new tokens, ensuring type isolation in the two-tier model.

Tokens without a token_type claim (issued by older versions) are accepted for backward compatibility.

Security Notes

  • Refresh does not auto-revoke: The original refresh token remains valid after Refresh. Call Revoke manually for one-time rotation.
  • Claims are not deeply validated: Refresh validates standard JWT fields (signature, expiration, issuer, audience, blacklist) and basic structure (UserID or Username must be present), but does not re-check field length limits or injection patterns, trusting they were validated at creation.
  • Signature consistency: New access tokens use the same signing algorithm and key as the refresh token. Cross-algorithm refresh is not supported.

Next Steps