Skip to content

Testing & Clock Injection

Inject custom clocks via the ClockProvider interface to precisely control time in tests.

ClockProvider Interface

go
type ClockProvider interface {
    Now() time.Time
}

The library provides two implementations:

TypeDescription
SystemClockDefault, uses system time
FixedClockFixed time, for testing

FixedClock

FixedClock always returns the time specified at construction:

go
fixedTime := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)

cfg := jwt.DefaultConfig()
cfg.SecretKey = "hmac-key-that-has-at-least-32-bytes!"
cfg.Clock = jwt.FixedClock{T: fixedTime}

Testing Token Expiration

go
func TestTokenExpiry(t *testing.T) {
    // Set fixed time
    now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)

    cfg := jwt.DefaultConfig()
    cfg.SecretKey = "hmac-key-that-has-at-least-32-bytes!"
    cfg.AccessTokenTTL = 15 * time.Minute
    cfg.Clock = jwt.FixedClock{T: now}

    processor, err := jwt.New(cfg)
    require.NoError(t, err)
    defer processor.Close()

    // Issue token at now
    claims := &jwt.Claims{UserID: "user123"}
    token, err := processor.Create(claims)
    require.NoError(t, err)

    // Validate at current time → success
    _, valid, err := processor.Validate(token)
    require.NoError(t, err)
    assert.True(t, valid)

    // Simulate time past expiration → use new Processor
    expiredCfg := cfg
    expiredCfg.Clock = jwt.FixedClock{T: now.Add(16 * time.Minute)}
    expiredProcessor, err := jwt.New(expiredCfg)
    require.NoError(t, err)
    defer expiredProcessor.Close()

    _, _, err = expiredProcessor.Validate(token)
    assert.True(t, errors.Is(err, jwt.ErrTokenExpired))
}

Testing Clock Skew

ClockSkew provides a tolerance window for expiration (exp) and not-before (nbf) validation. By setting the skew, you can verify whether a token is still accepted for a short period after its strict expiration time:

go
func TestClockSkew(t *testing.T) {
    now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)

    // Issue token: exp = now + 1h
    issueCfg := jwt.DefaultConfig()
    issueCfg.SecretKey = "hmac-key-that-has-at-least-32-bytes!"
    issueCfg.AccessTokenTTL = time.Hour
    issueCfg.Clock = jwt.FixedClock{T: now}

    issueProc, err := jwt.New(issueCfg)
    require.NoError(t, err)
    defer issueProc.Close()

    token, err := issueProc.Create(&jwt.Claims{UserID: "user123"})
    require.NoError(t, err)

    const skew = 30 * time.Second

    // exp + 10s still within the 30s skew window → valid
    withinCfg := jwt.DefaultConfig()
    withinCfg.SecretKey = "hmac-key-that-has-at-least-32-bytes!"
    withinCfg.ClockSkew = skew
    withinCfg.Clock = jwt.FixedClock{T: now.Add(time.Hour + 10*time.Second)}
    withinProc, err := jwt.New(withinCfg)
    require.NoError(t, err)
    defer withinProc.Close()

    _, valid, err := withinProc.Validate(token)
    require.NoError(t, err)
    assert.True(t, valid)

    // exp + 40s beyond the 30s skew window → expired
    beyondCfg := jwt.DefaultConfig()
    beyondCfg.SecretKey = "hmac-key-that-has-at-least-32-bytes!"
    beyondCfg.ClockSkew = skew
    beyondCfg.Clock = jwt.FixedClock{T: now.Add(time.Hour + 40*time.Second)}
    beyondProc, err := jwt.New(beyondCfg)
    require.NoError(t, err)
    defer beyondProc.Close()

    _, _, err = beyondProc.Validate(token)
    assert.True(t, errors.Is(err, jwt.ErrTokenExpired))
}

FixedClock and Rate Limiting

When rate limiting is enabled, the built-in RateLimiter's clock is propagated from Config.Clock — so when using FixedClock, the rate limiter also uses the same fixed time and will not refill tokens as real time passes. This makes rate limiting tests fully predictable. See Testing Rate Limiting.

Testing Refresh Flow

go
func TestRefreshFlow(t *testing.T) {
    now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)

    cfg := jwt.DefaultConfig()
    cfg.SecretKey = "hmac-key-that-has-at-least-32-bytes!"
    cfg.Clock = jwt.FixedClock{T: now}

    processor, err := jwt.New(cfg)
    require.NoError(t, err)
    defer processor.Close()

    claims := &jwt.Claims{UserID: "user123"}
    refreshToken, err := processor.CreateRefresh(claims)
    require.NoError(t, err)

    // Refresh token to get new access token
    newToken, err := processor.Refresh(refreshToken)
    require.NoError(t, err)
    assert.NotEmpty(t, newToken)
}

Testing Custom Claims

go
func TestCustomClaims(t *testing.T) {
    cfg := jwt.DefaultConfig()
    cfg.SecretKey = "hmac-key-that-has-at-least-32-bytes!"

    processor, err := jwt.New(cfg)
    require.NoError(t, err)
    defer processor.Close()

    claims := &MyClaims{
        UserID: "user123",
        Email:  "[email protected]",
    }

    token, err := processor.Create(claims)
    require.NoError(t, err)

    result := &MyClaims{}
    parsed, valid, err := processor.ValidateInto(token, result)
    require.NoError(t, err)
    assert.True(t, valid)

    myResult := parsed.(*MyClaims)
    assert.Equal(t, "user123", myResult.UserID)
    assert.Equal(t, "[email protected]", myResult.Email)
}

Testing Input Validation

Claims fields undergo multi-layer validation during Create. Tests can verify that overlong strings, injection patterns, and control characters trigger a ValidationError, and use errors.As to extract field-level info:

go
func TestInputValidation(t *testing.T) {
    cfg := jwt.DefaultConfig()
    cfg.SecretKey = "hmac-key-that-has-at-least-32-bytes!"

    processor, err := jwt.New(cfg)
    require.NoError(t, err)
    defer processor.Close()

    var ve *jwt.ValidationError

    // Overlong string triggers the length limit (cap 256 characters)
    _, err = processor.Create(&jwt.Claims{
        UserID: strings.Repeat("a", 300),
    })
    require.ErrorAs(t, err, &ve)
    assert.Equal(t, "UserID", ve.Field)
    assert.Contains(t, ve.Message, "maximum length")

    // XSS injection pattern detection
    _, err = processor.Create(&jwt.Claims{
        UserID: "<script>alert(1)</script>",
    })
    require.ErrorAs(t, err, &ve)
    assert.Equal(t, "UserID", ve.Field)
    assert.Equal(t, "suspicious pattern detected", ve.Message)

    // Control character filtering (null byte rejected)
    _, err = processor.Create(&jwt.Claims{
        UserID: "user\x00inject",
    })
    require.ErrorAs(t, err, &ve)
    assert.Equal(t, "UserID", ve.Field)
    assert.Equal(t, "invalid control character", ve.Message)
}

ValidationError Wrapping Level

In the Create path, ValidationError is wrapped by ErrInvalidClaims. Using errors.As(err, &ve) pierces the wrapping to extract the ValidationError, letting you read Field and Message for assertions. For the complete validation rules, see Configuration → Input Validation and Security Hardening.

Testing Error Handling

go
func TestRevokeToken(t *testing.T) {
    cfg := jwt.DefaultConfig()
    cfg.SecretKey = "hmac-key-that-has-at-least-32-bytes!"

    processor, err := jwt.New(cfg)
    require.NoError(t, err)
    defer processor.Close()

    claims := &jwt.Claims{UserID: "user123"}
    token, err := processor.Create(claims)
    require.NoError(t, err)

    // Revoke token
    err = processor.Revoke(token)
    require.NoError(t, err)

    // Validation should fail
    _, _, err = processor.Validate(token)
    assert.True(t, errors.Is(err, jwt.ErrTokenRevoked))
}

Testing Rate Limiting

With rate limiting enabled, a Create that exceeds the quota returns ErrRateLimitExceeded. Combined with FixedClock, you can precisely control the token bucket so it does not refill, making tests fully predictable:

go
func TestRateLimit(t *testing.T) {
    now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)

    cfg := jwt.DefaultConfig()
    cfg.SecretKey = "hmac-key-that-has-at-least-32-bytes!"
    cfg.EnableRateLimit = true
    cfg.RateLimitRate = 3               // 3 per window
    cfg.RateLimitWindow = time.Minute
    cfg.Clock = jwt.FixedClock{T: now}  // Fixed time → bucket does not refill

    processor, err := jwt.New(cfg)
    require.NoError(t, err)
    defer processor.Close()

    // Same UserID shares the rate quota (falls back to UserID when Subject is empty)
    claims := &jwt.Claims{UserID: "user123"}

    // First 3 creations succeed
    for i := 0; i < 3; i++ {
        _, err := processor.Create(claims)
        require.NoError(t, err, "creation %d should succeed", i+1)
    }

    // 4th exceeds the quota
    _, err = processor.Create(claims)
    assert.True(t, errors.Is(err, jwt.ErrRateLimitExceeded))
}

Rate Limit Key

Rate limiting computes the key from the Subject claim; when Subject is empty, it falls back to UserID. Using the same UserID in tests ensures all requests share the quota. See Rate Limiting.

Testing Concurrency Safety

All Processor methods are goroutine-safe. Use sync.WaitGroup to run Create/Validate concurrently, verifying no panic and no data race:

go
func TestConcurrentSafety(t *testing.T) {
    cfg := jwt.DefaultConfig()
    cfg.SecretKey = "hmac-key-that-has-at-least-32-bytes!"

    processor, err := jwt.New(cfg)
    require.NoError(t, err)
    defer processor.Close()

    const goroutines = 50
    const opsPerGoroutine = 20

    var wg sync.WaitGroup
    var success atomic.Int64
    wg.Add(goroutines)

    for i := 0; i < goroutines; i++ {
        go func(id int) {
            defer wg.Done()
            for j := 0; j < opsPerGoroutine; j++ {
                claims := &jwt.Claims{
                    UserID: fmt.Sprintf("user-%d-%d", id, j),
                }
                token, err := processor.Create(claims)
                if err != nil {
                    continue
                }
                if _, valid, err := processor.Validate(token); err == nil && valid {
                    success.Add(1)
                }
            }
        }(i)
    }
    wg.Wait()

    assert.Equal(t, int64(goroutines*opsPerGoroutine), success.Load(),
        "concurrent create and validate should all succeed")
}

Race Detection

Run go test -race ./... to enable Go's race detector, which catches hidden data races in concurrent tests. This is the standard way to verify Processor's concurrency safety; a production code test suite should always pass under -race.

Best Practices

Table-Driven Testing Recommendations

Test ScenarioRecommended ApproachKey Assertion
Token expirationFixedClock to fix issue time, new Processor to simulate expiryerrors.Is(err, ErrTokenExpired)
Clock skewSet ClockSkew, verify window boundariesValid within exp + skew / expired beyond
Refresh flowRefresh immediately after creating refresh tokenReturned token is non-empty
Custom ClaimsValidateInto deserialize into target typeField values match
Input validationOverlong string / injection pattern / control charactererrors.As extracts ValidationError
Rate limitingSmall window + low rate + fixed clockOver-quota returns ErrRateLimitExceeded
Concurrency safetygoroutine + WaitGroup concurrent operationsNo panic, no data race
Token revocationRevoke then Validateerrors.Is(err, ErrTokenRevoked)

Core Principles

  • Use FixedClock to ensure tests are repeatable — independent of system time
  • Create a separate Processor for each test case to avoid state leakage
  • Use t.Cleanup() or defer to ensure Close() is called
  • Use errors.Is() / errors.As() for error checking instead of string matching
  • Always run concurrency tests with go test -race

Next Steps