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 ​