Repository: lestrrat-go/jwx

Issue:
Rethink time truncation code for v3 
refs #763

this would be a blocker until we come to a conclusion

Discussion:
@github-actions: This issue is stale because it has been open 14 days with no activity. Remove stale label or comment or this will be closed in 7 days.@lestrrat: ideas:
* Keep old behavior as default, and
* Add some sort of new option, one global and one local
    * WithTimeTruncation(0) // no truncation
    * WithTimeTruncation(time.Second) // truncate up to a second@lestrrat: Ended up with
* Enable new behavior (no truncation) by default
* Allow global `jwt.Settings(jwt.WithTruncation(time.Second))` to restore old behavior

Solution patch:
diff --git a/Changes b/Changes
index df1cce61..8734f228 100644
--- a/Changes
+++ b/Changes
@@ -4,6 +4,12 @@ Changes
 v3 has many incompatibilities with v2. To see the full list of differences between
 v2 and v3, please read the Changes-v3.md file (https://github.com/lestrrat-go/jwx/blob/develop/v3/Changes-v3.md)
 
+v3.0.0-beta1
+  * [jwt] Token validation no longer truncates time based fields by default.
+    To restore old behavior, you can either change the global settings by
+    calling `jwt.Settings(jwt.WithTruncation(time.Second))`, or you can
+    change it by each invocation by using `jwt.Validate(..., jwt.WithTruncation(0))`
+
 v3.0.0-alpha3 13 Mar 2025
   * [jwk] Importing/Exporting from jwk.Key to ecdh.PrivateKey/ecdh.PublicKey should now work. Previously these keys were not properly
   recognized by the exporter/importer. Note that keys that use X25519 and
diff --git a/jwt/jwt.go b/jwt/jwt.go
index 3e14e9ef..5707c16c 100644
--- a/jwt/jwt.go
+++ b/jwt/jwt.go
@@ -9,6 +9,7 @@ import (
 	"fmt"
 	"io"
 	"sync/atomic"
+	"time"
 
 	"github.com/lestrrat-go/jwx/v3"
 	"github.com/lestrrat-go/jwx/v3/internal/json"
@@ -16,16 +17,20 @@ import (
 	"github.com/lestrrat-go/jwx/v3/jwt/internal/types"
 )
 
+var defaultTruncation atomic.Int64
+
 // Settings controls global settings that are specific to JWTs.
 func Settings(options ...GlobalOption) {
 	var flattenAudience bool
 	var parsePedantic bool
 	var parsePrecision = types.MaxPrecision + 1  // illegal value, so we can detect nothing was set
 	var formatPrecision = types.MaxPrecision + 1 // illegal value, so we can detect nothing was set
-
+	truncation := time.Duration(-1)
 	//nolint:forcetypeassert
 	for _, option := range options {
 		switch option.Ident() {
+		case identTruncation{}:
+			truncation = option.Value().(time.Duration)
 		case identFlattenAudience{}:
 			flattenAudience = option.Value().(bool)
 		case identNumericDateParsePedantic{}:
@@ -79,6 +84,10 @@ func Settings(options ...GlobalOption) {
 		}
 		defaultOptionsMu.Unlock()
 	}
+
+	if truncation >= 0 {
+		defaultTruncation.Store(int64(truncation))
+	}
 }
 
 var registry = json.NewRegistry()
@@ -503,3 +512,7 @@ type CustomDecodeFunc = json.CustomDecodeFunc
 func RegisterCustomField(name string, object interface{}) {
 	registry.Register(name, object)
 }
+
+func getDefaultTruncation() time.Duration {
+	return time.Duration(defaultTruncation.Load())
+}
diff --git a/jwt/options.yaml b/jwt/options.yaml
index 7902626c..5fc074a6 100644
--- a/jwt/options.yaml
+++ b/jwt/options.yaml
@@ -40,6 +40,14 @@ interfaces:
   - name: ReadFileOption
     comment: |
       ReadFileOption is a type of `Option` that can be passed to `jws.ReadFile`
+  - name: GlobalValidateOption
+    methods:
+      - globalOption
+      - parseOption
+      - readFileOption
+      - validateOption
+    comment: |
+      GlobalValidateOption describes an Option that can be passed to `jwt.Settings()` and `jwt.Validate()`
 options:
   - ident: AcceptableSkew
     interface: ValidateOption
@@ -48,14 +56,19 @@ options:
       WithAcceptableSkew specifies the duration in which exp, iat and nbf
       claims may differ by. This value should be positive
   - ident: Truncation
-    interface: ValidateOption
+    interface: GlobalValidateOption
     argument_type: time.Duration
     comment: |
       WithTruncation specifies the amount that should be used when
-      truncating time values used during time-based validation routines.
-      By default time values are truncated down to second accuracy.
-      If you want to use sub-second accuracy, you will need to set
-      this value to 0.
+      truncating time values used during time-based validation routines,
+      and by default this is disabled.
+
+      In v2 of this library, time values were truncated down to second accuracy, i.e.
+      1.0000001 seconds is truncated to 1 second. To restore this behavior, set
+      this value to `time.Second`
+
+      Since v3, this option can be passed to `jwt.Settings()` to set the truncation
+      value globally, as well as per invocation of `jwt.Validate()`
   - ident: Clock
     interface: ValidateOption
     argument_type: Clock
diff --git a/jwt/options_gen.go b/jwt/options_gen.go
index 94319e17..a8ed3ddc 100644
--- a/jwt/options_gen.go
+++ b/jwt/options_gen.go
@@ -39,6 +39,27 @@ type globalOption struct {
 
 func (*globalOption) globalOption() {}
 
+// GlobalValidateOption describes an Option that can be passed to `jwt.Settings()` and `jwt.Validate()`
+type GlobalValidateOption interface {
+	Option
+	globalOption()
+	parseOption()
+	readFileOption()
+	validateOption()
+}
+
+type globalValidateOption struct {
+	Option
+}
+
+func (*globalValidateOption) globalOption() {}
+
+func (*globalValidateOption) parseOption() {}
+
+func (*globalValidateOption) readFileOption() {}
+
+func (*globalValidateOption) validateOption() {}
+
 // ParseOption describes an Option that can be passed to `jwt.Parse()`.
 // ParseOption also implements ReadFileOption, therefore it may be
 // safely pass them to `jwt.ReadFile()`
@@ -388,12 +409,17 @@ func WithToken(v Token) ParseOption {
 }
 
 // WithTruncation specifies the amount that should be used when
-// truncating time values used during time-based validation routines.
-// By default time values are truncated down to second accuracy.
-// If you want to use sub-second accuracy, you will need to set
-// this value to 0.
-func WithTruncation(v time.Duration) ValidateOption {
-	return &validateOption{option.New(identTruncation{}, v)}
+// truncating time values used during time-based validation routines,
+// and by default this is disabled.
+//
+// In v2 of this library, time values were truncated down to second accuracy, i.e.
+// 1.0000001 seconds is truncated to 1 second. To restore this behavior, set
+// this value to `time.Second`
+//
+// Since v3, this option can be passed to `jwt.Settings()` to set the truncation
+// value globally, as well as per invocation of `jwt.Validate()`
+func WithTruncation(v time.Duration) GlobalValidateOption {
+	return &globalValidateOption{option.New(identTruncation{}, v)}
 }
 
 // WithValidate is passed to `Parse()` method to denote that the
diff --git a/jwt/validate.go b/jwt/validate.go
index 970867f2..2087baf7 100644
--- a/jwt/validate.go
+++ b/jwt/validate.go
@@ -49,7 +49,7 @@ func timeClaim(t Token, clock Clock, c string) time.Time {
 // that can control the behavior of this method.
 func Validate(t Token, options ...ValidateOption) error {
 	ctx := context.Background()
-	trunc := time.Second
+	trunc := getDefaultTruncation()
 
 	var clock Clock = ClockFunc(time.Now)
 	var skew time.Duration



Test patch:
diff --git a/jwt/validate_test.go b/jwt/validate_test.go
index d2f6f21c..afd391b6 100644
--- a/jwt/validate_test.go
+++ b/jwt/validate_test.go
@@ -11,8 +11,415 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func TestGH010(t *testing.T) {
+func TestTimeValidation(t *testing.T) {
 	t.Parallel()
+	// This test is _almost_ identical to TestGH010, but we are now
+	// testing with the default time truncation settings.
+
+	testcases := []struct {
+		ClaimName  string
+		ClaimValue string
+		OptionFunc func(string) jwt.ValidateOption
+		BuildFunc  func(v string) (jwt.Token, error)
+	}{
+		{
+			ClaimName:  jwt.JwtIDKey,
+			ClaimValue: `my-sepcial-key`,
+			OptionFunc: jwt.WithJwtID,
+			BuildFunc: func(v string) (jwt.Token, error) {
+				return jwt.NewBuilder().
+					JwtID(v).
+					Build()
+			},
+		},
+		{
+			ClaimName:  jwt.SubjectKey,
+			ClaimValue: `very important subject`,
+			OptionFunc: jwt.WithSubject,
+			BuildFunc: func(v string) (jwt.Token, error) {
+				return jwt.NewBuilder().
+					Subject(v).
+					Build()
+			},
+		},
+	}
+	for _, tc := range testcases {
+		t.Run(tc.ClaimName, func(t *testing.T) {
+			t.Parallel()
+			t1, err := tc.BuildFunc(tc.ClaimValue)
+			require.NoError(t, err, `jwt.NewBuilder should succeed`)
+
+			// This should succeed, because validation option (tc.OptionFunc)
+			// is not provided in the optional parameters
+			require.NoError(t, jwt.Validate(t1), "t1.Validate should succeed")
+
+			// This should succeed, because the option is provided with same value
+			require.NoError(t, jwt.Validate(t1, tc.OptionFunc(tc.ClaimValue)), "t1.Validate should succeed")
+			require.Error(t, jwt.Validate(t1, jwt.WithIssuer("poop")), "t1.Validate should fail")
+		})
+	}
+	t.Run(jwt.IssuerKey, func(t *testing.T) {
+		t.Parallel()
+		t1, err := jwt.NewBuilder().
+			Issuer("github.com/lestrrat-go/jwx/v3").
+			Build()
+		require.NoError(t, err, `jwt.NewBuilder should succeed`)
+
+		// This should succeed, because WithIssuer is not provided in the
+		// optional parameters
+		require.NoError(t, jwt.Validate(t1), "jwt.Validate should succeed")
+
+		// This should succeed, because WithIssuer is provided with same value
+		iss, ok := t1.Issuer()
+		require.True(t, ok, `t1.Issuer should succeed`)
+		require.NoError(t, jwt.Validate(t1, jwt.WithIssuer(iss)), "jwt.Validate should succeed")
+
+		err = jwt.Validate(t1, jwt.WithIssuer("poop"))
+
+		require.Error(t, err, "jwt.Validate should fail")
+		require.ErrorIs(t, err, jwt.InvalidIssuerError(), "error should be jwt.InvalidIssuerError")
+		require.ErrorIs(t, err, jwt.ValidateError(), "error should be a validation error")
+	})
+	t.Run(jwt.IssuedAtKey, func(t *testing.T) {
+		t.Parallel()
+		tm := time.Now()
+		t1, err := jwt.NewBuilder().
+			Claim(jwt.IssuedAtKey, tm).
+			Build()
+		require.NoError(t, err, `jwt.NewBuilder should succeed`)
+		testcases := []struct {
+			Name    string
+			Options []jwt.ValidateOption
+			Error   bool
+		}{
+			{
+				Name:  `clock is set to before iat`,
+				Error: true,
+				Options: []jwt.ValidateOption{
+					jwt.WithClock(jwt.ClockFunc(func() time.Time { return tm.Add(-1 * time.Hour) })),
+				},
+			},
+			{
+				// This works because the sub-second difference is rounded
+				Name:  `clock is set to some sub-seconds before iat`,
+				Error: true,
+				Options: []jwt.ValidateOption{
+					jwt.WithClock(jwt.ClockFunc(func() time.Time { return tm.Add(-1 * time.Millisecond) })),
+				},
+			},
+			{
+				Name:  `clock is set to some sub-seconds before iat (trunc = 0)`,
+				Error: true,
+				Options: []jwt.ValidateOption{
+					jwt.WithClock(jwt.ClockFunc(func() time.Time { return tm.Add(-1 * time.Millisecond) })),
+				},
+			},
+		}
+
+		for _, tc := range testcases {
+			t.Run(tc.Name, func(t *testing.T) {
+				err := jwt.Validate(t1, tc.Options...)
+				if !tc.Error {
+					require.NoError(t, err, `jwt.Validate should succeed`)
+					return
+				}
+
+				require.Error(t, err, `jwt.Validate should fail`)
+				require.ErrorIs(t, err, jwt.InvalidIssuedAtError(), `error should be jwt.ErrInvalidIssuedAt`)
+				require.NotErrorIs(t, err, jwt.TokenNotYetValidError(), `error should be not ErrNotYetValid`)
+				require.ErrorIs(t, err, jwt.ValidateError(), `error should be a validation error`)
+			})
+		}
+	})
+	t.Run(jwt.AudienceKey, func(t *testing.T) {
+		t.Parallel()
+		t1, err := jwt.NewBuilder().
+			Claim(jwt.AudienceKey, []string{"foo", "bar", "baz"}).
+			Build()
+		require.NoError(t, err, `jwt.NewBuilder should succeed`)
+
+		// This should succeed, because WithAudience is not provided in the
+		// optional parameters
+		t.Run("`aud` check disabled", func(t *testing.T) {
+			t.Parallel()
+			require.NoError(t, jwt.Validate(t1), `jwt.Validate should succeed`)
+		})
+
+		// This should succeed, because WithAudience is provided, and its
+		// value matches one of the audience values
+		t.Run("`aud` contains `baz`", func(t *testing.T) {
+			t.Parallel()
+			require.NoError(t, jwt.Validate(t1, jwt.WithAudience("baz")), "jwt.Validate should succeed")
+		})
+
+		t.Run("check `aud` contains `poop`", func(t *testing.T) {
+			t.Parallel()
+			err := jwt.Validate(t1, jwt.WithAudience("poop"))
+			require.Error(t, err, "token.Validate should fail")
+			require.ErrorIs(t, err, jwt.InvalidAudienceError(), `error should be ErrInvalidAudience`)
+			require.True(t, errors.Is(err, jwt.ValidateError()), `error should be a validation error`)
+		})
+	})
+	t.Run(jwt.SubjectKey, func(t *testing.T) {
+		t.Parallel()
+		t1, err := jwt.NewBuilder().
+			Claim(jwt.SubjectKey, "github.com/lestrrat-go/jwx/v3").
+			Build()
+		require.NoError(t, err, `jwt.NewBuilder should succeed`)
+
+		// This should succeed, because WithSubject is not provided in the
+		// optional parameters
+		require.NoError(t, jwt.Validate(t1), "token.Validate should succeed")
+
+		// This should succeed, because WithSubject is provided with same value
+		sub, ok := t1.Subject()
+		require.True(t, ok, `t1.Subject should succeed`)
+		require.NoError(t, jwt.Validate(t1, jwt.WithSubject(sub)), "token.Validate should succeed")
+		require.Error(t, jwt.Validate(t1, jwt.WithSubject("poop")), "token.Validate should fail")
+	})
+	t.Run(jwt.NotBeforeKey, func(t *testing.T) {
+		t.Parallel()
+
+		// NotBefore is set to future date
+		tm := time.Now().Add(72 * time.Hour)
+
+		t1, err := jwt.NewBuilder().
+			Claim(jwt.NotBeforeKey, tm).
+			Build()
+		require.NoError(t, err, `jwt.NewBuilder should succeed`)
+		testcases := []struct {
+			Name    string
+			Options []jwt.ValidateOption
+			Error   bool
+		}{
+			{ // This should fail, because nbf is the future
+				Name:  `'nbf' is less than current time`,
+				Error: true,
+			},
+			{ // This should succeed, because we have given reaaaaaaly big skew
+				Name: `skew is large enough`,
+				Options: []jwt.ValidateOption{
+					jwt.WithAcceptableSkew(73 * time.Hour),
+				},
+			},
+			{ // This should succeed, because we have given a time
+				// that is well enough into the future
+				Name: `clock is set to some time after in nbf`,
+				Options: []jwt.ValidateOption{
+					jwt.WithClock(jwt.ClockFunc(func() time.Time { return tm.Add(time.Hour) })),
+				},
+			},
+			{ // This should succeed, the time == NotBefore time
+				// Note, this could fail if you are returning a monotonic clock
+				// and we didn't do something about it
+				Name: `clock is set to the same time as nbf`,
+				Options: []jwt.ValidateOption{
+					jwt.WithClock(jwt.ClockFunc(func() time.Time { return tm })),
+				},
+			},
+			{
+				Name:  `clock is set to some sub-seconds before nbf`,
+				Error: true,
+				Options: []jwt.ValidateOption{
+					jwt.WithClock(jwt.ClockFunc(func() time.Time { return tm.Add(-1 * time.Millisecond) })),
+				},
+			},
+			{
+				Name:  `clock is set to some sub-seconds before nbf (but truncation = default)`,
+				Error: true,
+				Options: []jwt.ValidateOption{
+					jwt.WithClock(jwt.ClockFunc(func() time.Time { return tm.Add(-1 * time.Millisecond) })),
+				},
+			},
+			{
+				Name: `clock is set to some sub-seconds after nbf`,
+				Options: []jwt.ValidateOption{
+					jwt.WithClock(jwt.ClockFunc(func() time.Time { return tm.Add(time.Second - time.Millisecond) })),
+				},
+			},
+		}
+		for _, tc := range testcases {
+			t.Run(tc.Name, func(t *testing.T) {
+				err := jwt.Validate(t1, tc.Options...)
+				if !tc.Error {
+					require.NoError(t, err, "token.Validate should succeed")
+					return
+				}
+
+				require.Error(t, err, "token.Validate should fail")
+				require.ErrorIs(t, err, jwt.TokenNotYetValidError(), `error should be ErrTokenNotYetValid`)
+				require.NotErrorIs(t, err, jwt.TokenExpiredError(), `error should not be ErrTokenExpired`)
+				require.ErrorIs(t, err, jwt.ValidateError(), `error should be a validation error`)
+			})
+		}
+	})
+	t.Run(jwt.ExpirationKey, func(t *testing.T) {
+		t.Parallel()
+
+		tm := time.Now()
+		t1, err := jwt.NewBuilder().
+			// issuedAt = 1 Hr before current time
+			Claim(jwt.IssuedAtKey, tm.Add(-1*time.Hour)).
+			// valid for 2 minutes only from IssuedAt
+			Claim(jwt.ExpirationKey, tm).
+			Build()
+		require.NoError(t, err, `jwt.NewBuilder should succeed`)
+		testcases := []struct {
+			Name    string
+			Options []jwt.ValidateOption
+			Error   bool
+		}{
+			{
+				Name:  `clock is not modified (exp < now)`,
+				Error: true,
+			},
+			{
+				Name: `clock is set to some time before exp`,
+				Options: []jwt.ValidateOption{
+					jwt.WithClock(jwt.ClockFunc(func() time.Time { return tm.Add(-1 * time.Hour) })),
+				},
+			},
+			{ // This should fail, the time == Expiration.
+				// Note, this could fail if you are returning a monotonic clock
+				// and we didn't do something about it
+				Name:  `clock is set to same time as exp`,
+				Error: true,
+				Options: []jwt.ValidateOption{
+					jwt.WithClock(jwt.ClockFunc(func() time.Time { return tm })),
+				},
+			},
+			{
+				Name:  `clock is set to some sub-seconds after exp`,
+				Error: true,
+				Options: []jwt.ValidateOption{
+					jwt.WithClock(jwt.ClockFunc(func() time.Time { return tm.Add(time.Millisecond) })),
+					jwt.WithTruncation(0),
+				},
+			},
+			{
+				Name:  `clock is set to some sub-seconds after exp (but truncation = default)`,
+				Error: true,
+				Options: []jwt.ValidateOption{
+					jwt.WithClock(jwt.ClockFunc(func() time.Time { return tm.Add(time.Millisecond) })),
+				},
+			},
+			{
+				Name: `clock is set to some sub-seconds before exp`,
+				Options: []jwt.ValidateOption{
+					jwt.WithClock(jwt.ClockFunc(func() time.Time { return tm.Add(-1 * time.Millisecond) })),
+					jwt.WithTruncation(0),
+				},
+			},
+		}
+
+		for _, tc := range testcases {
+			t.Run(tc.Name, func(t *testing.T) {
+				err := jwt.Validate(t1, tc.Options...)
+				if !tc.Error {
+					require.NoError(t, err, `jwt.Validate should succeed`)
+					return
+				}
+
+				require.Error(t, err, `jwt.Validate should fail`)
+				require.NotErrorIs(t, err, jwt.TokenNotYetValidError(), `error should not be ErrTokenNotYetValid`)
+				require.ErrorIs(t, err, jwt.TokenExpiredError(), `error should be ErrTokenExpired`)
+				require.ErrorIs(t, err, jwt.ValidateError(), `error should be a validation error`)
+			})
+		}
+	})
+	t.Run("Unix zero times", func(t *testing.T) {
+		// See comments at ref: handling iat, nbf, and exp in v3
+		t.Parallel()
+		// tm := time.Unix(0, 0)
+		t1, err := jwt.NewBuilder().
+			//Claim(jwt.NotBeforeKey, tm).
+			//Claim(jwt.IssuedAtKey, tm).
+			//Claim(jwt.ExpirationKey, tm).
+			Build()
+		require.NoError(t, err, `jwt.NewBuilder should succeed`)
+
+		// This should pass because the unix zero times should be ignored
+		require.NoError(t, jwt.Validate(t1), "token.Validate should pass")
+	})
+	t.Run("Go zero times", func(t *testing.T) {
+		// ref: handling iat, nbf, and exp in v3
+		// Previously (v2) we used to treat the zero value as the same as
+		// the field not existing, but this is no longer true.
+		//
+		// This test/ used to pass in v2 even when we set exp to time.Time{},
+		// but it is no longer the case in v3. To emulate the previous
+		// behavior, we need to _NOT_ set the exp field at all
+		t.Parallel()
+		tm := time.Time{}
+		t1, err := jwt.NewBuilder().
+			Claim(jwt.NotBeforeKey, tm).
+			Claim(jwt.IssuedAtKey, tm).
+			// Claim(jwt.ExpirationKey, tm). // Omit this
+			Build()
+		require.NoError(t, err, `jwt.NewBuilder should succeed`)
+
+		// This should pass because the go zero times should be ignored
+		require.NoError(t, jwt.Validate(t1), "token.Validate should pass")
+	})
+	t.Run("Parse and validate", func(t *testing.T) {
+		t.Parallel()
+		tm := time.Now()
+		t1, err := jwt.NewBuilder().
+			// issuedAt = 1 Hr before current time
+			Claim(jwt.IssuedAtKey, tm.Add(-1*time.Hour)).
+			// valid for 2 minutes only from IssuedAt
+			Claim(jwt.ExpirationKey, tm.Add(-58*time.Minute)).
+			Build()
+		require.NoError(t, err, `jwt.NewBuilder should succeed`)
+
+		buf, err := json.Marshal(t1)
+		require.NoError(t, err, `json.Marshal should succeed`)
+
+		_, err = jwt.Parse(buf, jwt.WithVerify(false), jwt.WithValidate(true))
+		// This should fail, because exp is set in the past
+		require.Error(t, err, "jwt.Parse should fail")
+
+		_, err = jwt.Parse(buf, jwt.WithVerify(false), jwt.WithValidate(true), jwt.WithAcceptableSkew(time.Hour))
+		// This should succeed, because we have given big skew
+		// that is well enough to get us accepted
+		require.NoError(t, err, "jwt.Parse should succeed (1)")
+
+		// This should succeed, because we have given a time
+		// that is well enough into the past
+		clock := jwt.ClockFunc(func() time.Time {
+			return tm.Add(-59 * time.Minute)
+		})
+		_, err = jwt.Parse(buf, jwt.WithVerify(false), jwt.WithValidate(true), jwt.WithClock(clock))
+		require.NoError(t, err, "jwt.Parse should succeed (2)")
+	})
+	t.Run("any claim value", func(t *testing.T) {
+		t.Parallel()
+		t1, err := jwt.NewBuilder().
+			Claim("email", "email@example.com").
+			Build()
+		require.NoError(t, err, `jwt.NewBuilder should succeed`)
+
+		// This should succeed, because WithClaimValue("email", "xxx") is not provided in the
+		// optional parameters
+		require.NoError(t, jwt.Validate(t1), "t1.Validate should succeed")
+
+		// This should succeed, because WithClaimValue is provided with same value
+		require.NoError(t, jwt.Validate(t1, jwt.WithClaimValue("email", "email@example.com")), "t1.Validate should succeed")
+		require.Error(t, jwt.Validate(t1, jwt.WithClaimValue("email", "poop")), "t1.Validate should fail")
+		require.Error(t, jwt.Validate(t1, jwt.WithClaimValue("xxxx", "email@example.com")), "t1.Validate should fail")
+		require.Error(t, jwt.Validate(t1, jwt.WithClaimValue("xxxx", "")), "t1.Validate should fail")
+	})
+}
+
+//nolint:tparallel
+func TestGH010(t *testing.T) {
+	// This test relies on behavior that was present in v2, but is no longer
+	// present in v3. This requires setting a global value, so we cannot
+	// run this in parallel
+	jwt.Settings(jwt.WithTruncation(time.Second))
+	t.Cleanup(func() {
+		jwt.Settings(jwt.WithTruncation(0))
+	})
 
 	// Simple string claims
 	testcases := []struct {