Repository: lestrrat-go/jwx

Issue:
Feature Request: Matchable errors for jwt.Token.Get
# Feature Request: Matchable errors for `jwt.Token.Get`

## Abstract

In `lestrrat-go/jwx/jwt`, methods like `Token.Get(name, dst)` return generic errors with messages such as:

```go
return fmt.Errorf("field %q not found", name)
return fmt.Errorf("failed to assign value to dst: %w", err)
```

This makes it hard for users to differentiate between a missing claim, a type conversion failure, or other errors. The proposal is to introduce matchable errors (sentinels or typed errors) for clearer, idiomatic handling.

## Proposed solution

Introduce exported sentinel errors:

```go
var (
    ErrFieldNotFound = errors.New("field not found")
    ErrAssignFailed  = errors.New("failed to assign value")
)
```

Update `Get` to wrap them:

```go
if t.claims[name] == nil {
    return fmt.Errorf("%w: %q", ErrFieldNotFound, name)
}
if err := blackmagic.AssignIfCompatible(dst, t.claims[name]); err != nil {
    return fmt.Errorf("%w: %w", ErrAssignFailed, err)
}
```

Alternatively, use typed errors:

```go
type FieldNotFoundError struct {
    Field string
}
func (e FieldNotFoundError) Error() string {
    return fmt.Sprintf("field %q not found", e.Field)
}
```

Example usage:

```go
var fnf jwt.FieldNotFoundError
if errors.As(err, &fnf) {
    log.Printf("missing claim: %s", fnf.Field)
}
```

Hints from issue comments:
@lestrrat: @MB175 Is it imperative that you can access the field name from the error object? I'm trying my hardest not to introduce user-visible API on these objects, and would rather have you use them as an opaque errors. Your final example would just be:

```go
err := tok.Get(....)
if errors.Is(err, jwt.ClaimNotFoundError()) {
   log.Printf("missing claim: %s", err) // `missing claim: field "..." not found`
}
```@lestrrat: @MB175 Also, when you say "Get _and similar_ methods" which methods are you talking about?@lestrrat: @MB175 I made a PR. Please take a look if this is what you wanted.@MB175: @lestrrat thanks a lot for the super quick implementation, Get is enough for my use case!

I’m curious though, earlier you mentioned you wanted to avoid exposing user-visible API on the error objects and keep them opaque. What made you decide to add the errors in the end? @lestrrat: @MB175 By opaque, I meant that you (the user) can't access anything in the error. So for my library something like `os.PathError` is a no-no, because I'd have to expose (1) its concrete type, and (2) its exported fields. The current implementation adds errors, yes, but you can't do anything (like access its fields) with them other than treat them as generic error objects@MB175: @lestrrat ah okay that makes total sense. I’m happy with the new implementation you did, since it lets me reliably distinguish between an assign error and a missing field. Thanks a lot for the super quick addition.

Issue can be closed.@lestrrat: FYI I'll merge this soon, but please note that it won't be released for at least a few days, if not longer, as I tend to find something that needs to be fixed only after the dust settles. If you are in a hurry, please use the commit ID to pin the dependency.

Solution patch:
diff --git a/Changes b/Changes
index 5abe76ca..bfcbcda3 100644
--- a/Changes
+++ b/Changes
@@ -4,6 +4,14 @@ 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.9 UNRELEASED
+  * [jwt] `(jwt.Token).Get` methods now return specific types of errors depending
+    on if a) the specified claim was not present, or b) the specified claim could
+    not be assigned to the destination variable.
+
+    You can distinguish these by using `errors.Is` against `jwt.ClaimNotFoundError()`
+    or `jwt.ClaimAssignmentFailedError()`
+
 v3.0.8 27 Jun 2025
   * [jwe/jwebb] (EXPERIMENTAL) Add low-level functions for JWE operations.
   * [jws/jwsbb] (EXPERIMENTAL/BREAKS COMPATIBILITY) Add io.Reader parameter
diff --git a/jwt/BUILD.bazel b/jwt/BUILD.bazel
index 3711b318..bd0df584 100644
--- a/jwt/BUILD.bazel
+++ b/jwt/BUILD.bazel
@@ -34,6 +34,7 @@ go_library(
         "//jws",
         "//jws/jwsbb",
         "//jwt/internal/types",
+        "//jwt/internal/errors",
         "@com_github_lestrrat_go_blackmagic//:blackmagic",
         "@com_github_lestrrat_go_option_v2//:option",
     ],
diff --git a/jwt/errors.go b/jwt/errors.go
index 8762e650..c5b529c1 100644
--- a/jwt/errors.go
+++ b/jwt/errors.go
@@ -1,213 +1,95 @@
 package jwt
 
 import (
-	"errors"
-	"fmt"
+	jwterrs "github.com/lestrrat-go/jwx/v3/jwt/internal/errors"
 )
 
-var errUnknownPayloadType = errors.New(`unknown payload type (payload is not JWT?)`)
+// ClaimNotFoundError returns the opaque error value that is returned when
+// `jwt.Get` fails to find the requested claim.
+//
+// This value should only be used for comparison using `errors.Is()`.
+func ClaimNotFoundError() error {
+	return jwterrs.ErrClaimNotFound
+}
+
+// ClaimAssignmentFailedError returns the opaque error value that is returned
+// when `jwt.Get` fails to assign the value to the destination. For example,
+// this can happen when the value is a string, but you passed a &int as the
+// destination.
+//
+// This value should only be used for comparison using `errors.Is()`.
+func ClaimAssignmentFailedError() error {
+	return jwterrs.ErrClaimAssignmentFailed
+}
 
 // UnknownPayloadTypeError returns the opaque error value that is returned when
 // `jwt.Parse` fails due to not being able to deduce the format of
-// the incoming buffer
+// the incoming buffer.
+//
+// This value should only be used for comparison using `errors.Is()`.
 func UnknownPayloadTypeError() error {
-	return errUnknownPayloadType
-}
-
-type parseError struct {
-	error
+	return jwterrs.ErrUnknownPayloadType
 }
 
-var errDefaultParseError = parseerr(`jwt.Parse`, `unknown error`)
-
-// ParseError returns an error that can be passed to `errors.Is` to check if the error is a parse error.
+// ParseError returns the opaque error that is returned from jwt.Parse when
+// the input is not a valid JWT.
+//
+// This value should only be used for comparison using `errors.Is()`.
 func ParseError() error {
-	return errDefaultParseError
+	return jwterrs.ErrParse
 }
 
-func (e parseError) Unwrap() error {
-	return e.error
-}
-
-func (parseError) Is(err error) bool {
-	_, ok := err.(parseError)
-	return ok
-}
-
-func parseerr(prefix string, f string, args ...any) error {
-	return parseError{fmt.Errorf(prefix+": "+f, args...)}
-}
-
-type validationError struct {
-	error
-}
-
-var errDefaultValidateError = validateerr(`unknown error`)
-
+// ValidateError returns the immutable error used for validation errors
+//
+// This value should only be used for comparison using `errors.Is()`.
 func ValidateError() error {
-	return errDefaultValidateError
-}
-
-func (validationError) Is(err error) bool {
-	_, ok := err.(validationError)
-	return ok
-}
-
-func (err validationError) Unwrap() error {
-	return err.error
-}
-
-func validateerr(f string, args ...any) error {
-	return validationError{fmt.Errorf(`jwt.Validate: `+f, args...)}
-}
-
-type invalidIssuerError struct {
-	error
+	return jwterrs.ErrValidateDefault
 }
 
-func (err invalidIssuerError) Is(target error) bool {
-	_, ok := target.(invalidIssuerError)
-	return ok
-}
-
-func (err invalidIssuerError) Unwrap() error {
-	return err.error
-}
-
-func issuererr(f string, args ...any) error {
-	return invalidIssuerError{fmt.Errorf(`"iss" not satisfied: `+f, args...)}
-}
-
-var errDefaultInvalidIssuer = invalidIssuerError{errors.New(`"iss" not satisfied`)}
-
 // InvalidIssuerError returns the immutable error used when `iss` claim
 // is not satisfied
 //
-// The return value should only be used for comparison using `errors.Is()`
+// This value should only be used for comparison using `errors.Is()`.
 func InvalidIssuerError() error {
-	return errDefaultInvalidIssuer
-}
-
-type tokenExpiredError struct {
-	error
+	return jwterrs.ErrInvalidIssuerDefault
 }
 
-func (err tokenExpiredError) Is(target error) bool {
-	_, ok := target.(tokenExpiredError)
-	return ok
-}
-
-func (err tokenExpiredError) Unwrap() error {
-	return err.error
-}
-
-var errDefaultTokenExpired = tokenExpiredError{errors.New(`"exp" not satisfied: token is expired`)}
-
 // TokenExpiredError returns the immutable error used when `exp` claim
 // is not satisfied.
 //
-// The return value should only be used for comparison using `errors.Is()`
+// This value should only be used for comparison using `errors.Is()`.
 func TokenExpiredError() error {
-	return errDefaultTokenExpired
-}
-
-type invalidIssuedAtError struct {
-	error
-}
-
-func (err invalidIssuedAtError) Is(target error) bool {
-	_, ok := target.(invalidIssuedAtError)
-	return ok
+	return jwterrs.ErrTokenExpiredDefault
 }
 
-func (err invalidIssuedAtError) Unwrap() error {
-	return err.error
-}
-
-var errDefaultInvalidIssuedAt = invalidIssuedAtError{errors.New(`"iat" not satisfied`)}
-
 // InvalidIssuedAtError returns the immutable error used when `iat` claim
 // is not satisfied
 //
-// The return value should only be used for comparison using `errors.Is()`
+// This value should only be used for comparison using `errors.Is()`.
 func InvalidIssuedAtError() error {
-	return errDefaultInvalidIssuedAt
-}
-
-type tokenNotYetValidError struct {
-	error
-}
-
-func (err tokenNotYetValidError) Is(target error) bool {
-	_, ok := target.(tokenNotYetValidError)
-	return ok
-}
-
-func (err tokenNotYetValidError) Unwrap() error {
-	return err.error
+	return jwterrs.ErrInvalidIssuedAtDefault
 }
 
-var errDefaultTokenNotYetValid = tokenNotYetValidError{errors.New(`"nbf" not satisfied: token is not yet valid`)}
-
 // TokenNotYetValidError returns the immutable error used when `nbf` claim
 // is not satisfied
 //
-// The return value should only be used for comparison using `errors.Is()`
+// This value should only be used for comparison using `errors.Is()`.
 func TokenNotYetValidError() error {
-	return errDefaultTokenNotYetValid
-}
-
-type invalidAudienceError struct {
-	error
-}
-
-func (err invalidAudienceError) Is(target error) bool {
-	_, ok := target.(invalidAudienceError)
-	return ok
-}
-
-func (err invalidAudienceError) Unwrap() error {
-	return err.error
-}
-
-func auderr(f string, args ...any) error {
-	return invalidAudienceError{fmt.Errorf(`"aud" not satisfied: `+f, args...)}
+	return jwterrs.ErrTokenNotYetValidDefault
 }
 
-var errDefaultInvalidAudience = invalidAudienceError{errors.New(`"aud" not satisfied`)}
-
 // InvalidAudienceError returns the immutable error used when `aud` claim
 // is not satisfied
 //
-// The return value should only be used for comparison using `errors.Is()`
+// This value should only be used for comparison using `errors.Is()`.
 func InvalidAudienceError() error {
-	return errDefaultInvalidAudience
-}
-
-type missingRequiredClaimError struct {
-	error
-
-	claim string
-}
-
-func (err *missingRequiredClaimError) Is(target error) bool {
-	err1, ok := target.(*missingRequiredClaimError)
-	if !ok {
-		return false
-	}
-	return err1 == errDefaultMissingRequiredClaim || err1.claim == err.claim
-}
-
-var errDefaultMissingRequiredClaim = &missingRequiredClaimError{error: errors.New(`required claim is missing`)}
-
-func errMissingRequiredClaim(name string) error {
-	return &missingRequiredClaimError{claim: name, error: fmt.Errorf(`required claim "%s" is missing`, name)}
+	return jwterrs.ErrInvalidAudienceDefault
 }
 
 // MissingRequiredClaimError returns the immutable error used when the claim
 // specified by `jwt.IsRequired()` is not present.
 //
-// The return value should only be used for comparison using `errors.Is()`
+// This value should only be used for comparison using `errors.Is()`.
 func MissingRequiredClaimError() error {
-	return errDefaultMissingRequiredClaim
+	return jwterrs.ErrMissingRequiredClaimDefault
 }
diff --git a/jwt/internal/errors/BUILD.bazel b/jwt/internal/errors/BUILD.bazel
new file mode 100644
index 00000000..4ec0cd78
--- /dev/null
+++ b/jwt/internal/errors/BUILD.bazel
@@ -0,0 +1,16 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "errors",
+    srcs = [
+        "errors.go",
+    ],
+    importpath = "github.com/lestrrat-go/jwx/v3/jwt/internal/errors",
+    visibility = ["//jwt:__subpackages__"],
+)
+
+alias(
+    name = "go_default_library",
+    actual = ":errors",
+    visibility = ["//jwt:__subpackages__"],
+)
\ No newline at end of file
diff --git a/jwt/internal/errors/errors.go b/jwt/internal/errors/errors.go
new file mode 100644
index 00000000..a1dca0d5
--- /dev/null
+++ b/jwt/internal/errors/errors.go
@@ -0,0 +1,183 @@
+// Package errors exist to store errors for jwt and openid packages.
+//
+// It's internal because we don't want to expose _anything_ about these errors
+// so users absolutely cannot do anything other than use them as opaque errors.
+package errors
+
+import (
+	"errors"
+	"fmt"
+)
+
+var (
+	ErrClaimNotFound               = ClaimNotFoundError{}
+	ErrClaimAssignmentFailed       = ClaimAssignmentFailedError{Err: errors.New(`claim assignment failed`)}
+	ErrUnknownPayloadType          = errors.New(`unknown payload type (payload is not JWT?)`)
+	ErrParse                       = ParseError{error: errors.New(`jwt.Parse: unknown error`)}
+	ErrValidateDefault             = ValidationError{errors.New(`unknown error`)}
+	ErrInvalidIssuerDefault        = InvalidIssuerError{errors.New(`"iss" not satisfied`)}
+	ErrTokenExpiredDefault         = TokenExpiredError{errors.New(`"exp" not satisfied: token is expired`)}
+	ErrInvalidIssuedAtDefault      = InvalidIssuedAtError{errors.New(`"iat" not satisfied`)}
+	ErrTokenNotYetValidDefault     = TokenNotYetValidError{errors.New(`"nbf" not satisfied: token is not yet valid`)}
+	ErrInvalidAudienceDefault      = InvalidAudienceError{errors.New(`"aud" not satisfied`)}
+	ErrMissingRequiredClaimDefault = &MissingRequiredClaimError{error: errors.New(`required claim is missing`)}
+)
+
+type ClaimNotFoundError struct {
+	Name string
+}
+
+func (e ClaimNotFoundError) Error() string {
+	// This error message uses "field" instead of "claim" for backwards compatibility,
+	// but it shuold really be "claim" since it refers to a JWT claim.
+	return fmt.Sprintf(`field "%s" not found`, e.Name)
+}
+
+func (e ClaimNotFoundError) Is(target error) bool {
+	_, ok := target.(ClaimNotFoundError)
+	return ok
+}
+
+type ClaimAssignmentFailedError struct {
+	Err error
+}
+
+func (e ClaimAssignmentFailedError) Error() string {
+	// This error message probably should be tweaked, but it is this way
+	// for backwards compatibility.
+	return fmt.Sprintf(`failed to assign value to dst: %s`, e.Err.Error())
+}
+
+func (e ClaimAssignmentFailedError) Unwrap() error {
+	return e.Err
+}
+
+func (e ClaimAssignmentFailedError) Is(target error) bool {
+	_, ok := target.(ClaimAssignmentFailedError)
+	return ok
+}
+
+type ParseError struct {
+	error
+}
+
+func (e ParseError) Unwrap() error {
+	return e.error
+}
+
+func (ParseError) Is(err error) bool {
+	_, ok := err.(ParseError)
+	return ok
+}
+
+func ParseErrorf(prefix, f string, args ...any) error {
+	return ParseError{fmt.Errorf(prefix+": "+f, args...)}
+}
+
+type ValidationError struct {
+	error
+}
+
+func (ValidationError) Is(err error) bool {
+	_, ok := err.(ValidationError)
+	return ok
+}
+
+func (err ValidationError) Unwrap() error {
+	return err.error
+}
+
+func ValidateErrorf(f string, args ...any) error {
+	return ValidationError{fmt.Errorf(`jwt.Validate: `+f, args...)}
+}
+
+type InvalidIssuerError struct {
+	error
+}
+
+func (err InvalidIssuerError) Is(target error) bool {
+	_, ok := target.(InvalidIssuerError)
+	return ok
+}
+
+func (err InvalidIssuerError) Unwrap() error {
+	return err.error
+}
+
+func IssuerErrorf(f string, args ...any) error {
+	return InvalidIssuerError{fmt.Errorf(`"iss" not satisfied: `+f, args...)}
+}
+
+type TokenExpiredError struct {
+	error
+}
+
+func (err TokenExpiredError) Is(target error) bool {
+	_, ok := target.(TokenExpiredError)
+	return ok
+}
+
+func (err TokenExpiredError) Unwrap() error {
+	return err.error
+}
+
+type InvalidIssuedAtError struct {
+	error
+}
+
+func (err InvalidIssuedAtError) Is(target error) bool {
+	_, ok := target.(InvalidIssuedAtError)
+	return ok
+}
+
+func (err InvalidIssuedAtError) Unwrap() error {
+	return err.error
+}
+
+type TokenNotYetValidError struct {
+	error
+}
+
+func (err TokenNotYetValidError) Is(target error) bool {
+	_, ok := target.(TokenNotYetValidError)
+	return ok
+}
+
+func (err TokenNotYetValidError) Unwrap() error {
+	return err.error
+}
+
+type InvalidAudienceError struct {
+	error
+}
+
+func (err InvalidAudienceError) Is(target error) bool {
+	_, ok := target.(InvalidAudienceError)
+	return ok
+}
+
+func (err InvalidAudienceError) Unwrap() error {
+	return err.error
+}
+
+func AudienceErrorf(f string, args ...any) error {
+	return InvalidAudienceError{fmt.Errorf(`"aud" not satisfied: `+f, args...)}
+}
+
+type MissingRequiredClaimError struct {
+	error
+
+	claim string
+}
+
+func (err *MissingRequiredClaimError) Is(target error) bool {
+	err1, ok := target.(*MissingRequiredClaimError)
+	if !ok {
+		return false
+	}
+	return err1 == ErrMissingRequiredClaimDefault || err1.claim == err.claim
+}
+
+func MissingRequiredClaimErrorf(name string) error {
+	return &MissingRequiredClaimError{claim: name, error: fmt.Errorf(`required claim "%s" is missing`, name)}
+}
diff --git a/jwt/jwt.go b/jwt/jwt.go
index 799e51c5..43e38298 100644
--- a/jwt/jwt.go
+++ b/jwt/jwt.go
@@ -15,6 +15,7 @@ import (
 	"github.com/lestrrat-go/jwx/v3/internal/json"
 	"github.com/lestrrat-go/jwx/v3/jwa"
 	"github.com/lestrrat-go/jwx/v3/jws"
+	jwterrs "github.com/lestrrat-go/jwx/v3/jwt/internal/errors"
 	"github.com/lestrrat-go/jwx/v3/jwt/internal/types"
 )
 
@@ -108,7 +109,7 @@ var registry = json.NewRegistry()
 func ParseString(s string, options ...ParseOption) (Token, error) {
 	tok, err := parseBytes([]byte(s), options...)
 	if err != nil {
-		return nil, parseerr(`jwt.ParseString`, `failed to parse string: %w`, err)
+		return nil, jwterrs.ParseErrorf(`jwt.ParseString`, `failed to parse string: %w`, err)
 	}
 	return tok, nil
 }
@@ -141,7 +142,7 @@ func ParseString(s string, options ...ParseOption) (Token, error) {
 func Parse(s []byte, options ...ParseOption) (Token, error) {
 	tok, err := parseBytes(s, options...)
 	if err != nil {
-		return nil, parseerr(`jwt.Parse`, `failed to parse token: %w`, err)
+		return nil, jwterrs.ParseErrorf(`jwt.Parse`, `failed to parse token: %w`, err)
 	}
 	return tok, nil
 }
@@ -156,14 +157,14 @@ func ParseInsecure(s []byte, options ...ParseOption) (Token, error) {
 	for _, option := range options {
 		switch option.Ident() {
 		case identVerify{}, identValidate{}:
-			return nil, parseerr(`jwt.ParseInsecure`, `jwt.WithVerify() and jwt.WithValidate() may not be specified`)
+			return nil, jwterrs.ParseErrorf(`jwt.ParseInsecure`, `jwt.WithVerify() and jwt.WithValidate() may not be specified`)
 		}
 	}
 
 	options = append(options, WithVerify(false), WithValidate(false))
 	tok, err := Parse(s, options...)
 	if err != nil {
-		return nil, parseerr(`jwt.ParseInsecure`, `failed to parse token: %w`, err)
+		return nil, jwterrs.ParseErrorf(`jwt.ParseInsecure`, `failed to parse token: %w`, err)
 	}
 	return tok, nil
 }
@@ -173,11 +174,11 @@ func ParseReader(src io.Reader, options ...ParseOption) (Token, error) {
 	// We're going to need the raw bytes regardless. Read it.
 	data, err := io.ReadAll(src)
 	if err != nil {
-		return nil, parseerr(`jwt.ParseReader`, `failed to read from token data source: %w`, err)
+		return nil, jwterrs.ParseErrorf(`jwt.ParseReader`, `failed to read from token data source: %w`, err)
 	}
 	tok, err := parseBytes(data, options...)
 	if err != nil {
-		return nil, parseerr(`jwt.ParseReader`, `failed to parse token: %w`, err)
+		return nil, jwterrs.ParseErrorf(`jwt.ParseReader`, `failed to parse token: %w`, err)
 	}
 	return tok, nil
 }
diff --git a/jwt/openid/BUILD.bazel b/jwt/openid/BUILD.bazel
index e643b8d5..02e03469 100644
--- a/jwt/openid/BUILD.bazel
+++ b/jwt/openid/BUILD.bazel
@@ -20,6 +20,7 @@ go_library(
         "//internal/tokens",
         "//jwt",
         "//jwt/internal/types",
+        "//jwt/internal/errors",
         "@com_github_lestrrat_go_blackmagic//:blackmagic",
     ],
 )
diff --git a/jwt/openid/token_gen.go b/jwt/openid/token_gen.go
index b84f522d..02150736 100644
--- a/jwt/openid/token_gen.go
+++ b/jwt/openid/token_gen.go
@@ -14,6 +14,7 @@ import (
 	"github.com/lestrrat-go/jwx/v3/internal/pool"
 	"github.com/lestrrat-go/jwx/v3/internal/tokens"
 	"github.com/lestrrat-go/jwx/v3/jwt"
+	jwterrs "github.com/lestrrat-go/jwx/v3/jwt/internal/errors"
 	"github.com/lestrrat-go/jwx/v3/jwt/internal/types"
 )
 
@@ -280,219 +281,219 @@ func (t *stdToken) Get(name string, dst any) error {
 	switch name {
 	case AddressKey:
 		if t.address == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, t.address); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case AudienceKey:
 		if t.audience == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, t.audience.Get()); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case BirthdateKey:
 		if t.birthdate == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, t.birthdate); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case EmailKey:
 		if t.email == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, *(t.email)); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case EmailVerifiedKey:
 		if t.emailVerified == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, *(t.emailVerified)); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case ExpirationKey:
 		if t.expiration == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, t.expiration.Get()); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case FamilyNameKey:
 		if t.familyName == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, *(t.familyName)); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case GenderKey:
 		if t.gender == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, *(t.gender)); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case GivenNameKey:
 		if t.givenName == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, *(t.givenName)); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case IssuedAtKey:
 		if t.issuedAt == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, t.issuedAt.Get()); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case IssuerKey:
 		if t.issuer == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, *(t.issuer)); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case JwtIDKey:
 		if t.jwtID == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, *(t.jwtID)); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case LocaleKey:
 		if t.locale == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, *(t.locale)); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case MiddleNameKey:
 		if t.middleName == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, *(t.middleName)); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case NameKey:
 		if t.name == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, *(t.name)); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case NicknameKey:
 		if t.nickname == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, *(t.nickname)); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case NotBeforeKey:
 		if t.notBefore == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, t.notBefore.Get()); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case PhoneNumberKey:
 		if t.phoneNumber == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, *(t.phoneNumber)); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case PhoneNumberVerifiedKey:
 		if t.phoneNumberVerified == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, *(t.phoneNumberVerified)); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case PictureKey:
 		if t.picture == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, *(t.picture)); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case PreferredUsernameKey:
 		if t.preferredUsername == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, *(t.preferredUsername)); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case ProfileKey:
 		if t.profile == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, *(t.profile)); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case SubjectKey:
 		if t.subject == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, *(t.subject)); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case UpdatedAtKey:
 		if t.updatedAt == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, t.updatedAt.Get()); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case WebsiteKey:
 		if t.website == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, *(t.website)); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case ZoneinfoKey:
 		if t.zoneinfo == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, *(t.zoneinfo)); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	default:
 		v, ok := t.privateClaims[name]
 		if !ok {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, v); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	}
diff --git a/jwt/token_gen.go b/jwt/token_gen.go
index 28853431..2361ff56 100644
--- a/jwt/token_gen.go
+++ b/jwt/token_gen.go
@@ -13,6 +13,7 @@ import (
 	"github.com/lestrrat-go/jwx/v3/internal/json"
 	"github.com/lestrrat-go/jwx/v3/internal/pool"
 	"github.com/lestrrat-go/jwx/v3/internal/tokens"
+	jwterrs "github.com/lestrrat-go/jwx/v3/jwt/internal/errors"
 	"github.com/lestrrat-go/jwx/v3/jwt/internal/types"
 )
 
@@ -158,67 +159,67 @@ func (t *stdToken) Get(name string, dst any) error {
 	switch name {
 	case AudienceKey:
 		if t.audience == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, t.audience.Get()); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case ExpirationKey:
 		if t.expiration == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, t.expiration.Get()); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case IssuedAtKey:
 		if t.issuedAt == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, t.issuedAt.Get()); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case IssuerKey:
 		if t.issuer == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, *(t.issuer)); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case JwtIDKey:
 		if t.jwtID == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, *(t.jwtID)); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case NotBeforeKey:
 		if t.notBefore == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, t.notBefore.Get()); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	case SubjectKey:
 		if t.subject == nil {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, *(t.subject)); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	default:
 		v, ok := t.privateClaims[name]
 		if !ok {
-			return fmt.Errorf(`field %q not found`, name)
+			return jwterrs.ClaimNotFoundError{Name: name}
 		}
 		if err := blackmagic.AssignIfCompatible(dst, v); err != nil {
-			return fmt.Errorf(`failed to assign value to dst: %w`, err)
+			return jwterrs.ClaimAssignmentFailedError{Err: err}
 		}
 		return nil
 	}
diff --git a/jwt/validate.go b/jwt/validate.go
index 9b716f76..dbc43edb 100644
--- a/jwt/validate.go
+++ b/jwt/validate.go
@@ -5,6 +5,8 @@ import (
 	"fmt"
 	"strconv"
 	"time"
+
+	jwterrs "github.com/lestrrat-go/jwx/v3/jwt/internal/errors"
 )
 
 type Clock interface {
@@ -115,14 +117,14 @@ func Validate(t Token, options ...ValidateOption) error {
 		validators = append(baseValidators, extraValidators...)
 	} else {
 		if len(extraValidators) == 0 {
-			return validateerr(`no validators specified: jwt.WithResetValidators(true) and no jwt.WithValidator() specified`)
+			return jwterrs.ValidateErrorf(`no validators specified: jwt.WithResetValidators(true) and no jwt.WithValidator() specified`)
 		}
 		validators = extraValidators
 	}
 
 	for _, v := range validators {
 		if err := v.Validate(ctx, t); err != nil {
-			return validateerr(`validation failed: %w`, err)
+			return jwterrs.ValidateErrorf(`validation failed: %w`, err)
 		}
 	}
 
@@ -356,7 +358,7 @@ func audienceClaimContainsString(value string) Validator {
 	return claimContainsString{
 		name:    AudienceKey,
 		value:   value,
-		makeErr: auderr,
+		makeErr: jwterrs.AudienceErrorf,
 	}
 }
 
@@ -395,7 +397,7 @@ func issuerClaimValueIs(value string) Validator {
 	return &claimValueIs{
 		name:    IssuerKey,
 		value:   value,
-		makeErr: issuererr,
+		makeErr: jwterrs.IssuerErrorf,
 	}
 }
 
@@ -410,7 +412,7 @@ type isRequired string
 func (ir isRequired) Validate(_ context.Context, t Token) error {
 	name := string(ir)
 	if !t.Has(name) {
-		return errMissingRequiredClaim(name)
+		return jwterrs.MissingRequiredClaimErrorf(name)
 	}
 	return nil
 }
diff --git a/tools/cmd/genjwt/main.go b/tools/cmd/genjwt/main.go
index c18465e7..2ff0c743 100644
--- a/tools/cmd/genjwt/main.go
+++ b/tools/cmd/genjwt/main.go
@@ -105,6 +105,13 @@ func generateToken(obj *codegen.Object) error {
 	o.L("// Code generated by tools/cmd/genjwt/main.go. DO NOT EDIT.")
 	o.LL("package %s", obj.String(`package`))
 
+	o.WriteImportPkgs(
+		codegen.ImportPkg{
+			Alias: `jwterrs`,
+			URL:   `github.com/lestrrat-go/jwx/v3/jwt/internal/errors`,
+		},
+	)
+
 	var fields = obj.Fields()
 
 	o.LL("const (")
@@ -252,7 +259,7 @@ func generateToken(obj *codegen.Object) error {
 	for _, f := range fields {
 		o.L("case %sKey:", f.Name(true))
 		o.L("if t.%s == nil {", f.Name(false))
-		o.L("return fmt.Errorf(`field %%q not found`, name)")
+		o.L("return jwterrs.ClaimNotFoundError{Name: name}")
 		o.L("}")
 		o.L("if err := blackmagic.AssignIfCompatible(dst, ")
 		if f.Bool(`hasGet`) {
@@ -265,17 +272,17 @@ func generateToken(obj *codegen.Object) error {
 			}
 		}
 		o.R("); err != nil {")
-		o.L("return fmt.Errorf(`failed to assign value to dst: %%w`, err)")
+		o.L("return jwterrs.ClaimAssignmentFailedError{Err: err}")
 		o.L("}")
 		o.L("return nil")
 	}
 	o.L("default:")
 	o.L("v, ok := t.privateClaims[name]")
 	o.L("if !ok {")
-	o.L("return fmt.Errorf(`field %%q not found`, name)")
+	o.L("return jwterrs.ClaimNotFoundError{Name: name}")
 	o.L("}")
 	o.L("if err := blackmagic.AssignIfCompatible(dst, v); err != nil {")
-	o.L("return fmt.Errorf(`failed to assign value to dst: %%w`, err)")
+	o.L("return jwterrs.ClaimAssignmentFailedError{Err: err}")
 	o.L("}")
 	o.L("return nil")
 	o.L("}") // end switch name



Test patch:
diff --git a/jwt/jwt_test.go b/jwt/jwt_test.go
index 8212333a..fa86b481 100644
--- a/jwt/jwt_test.go
+++ b/jwt/jwt_test.go
@@ -47,6 +47,28 @@ func TestOption(t *testing.T) {
 }
 */
 
+func TestToken_Get(t *testing.T) {
+	tok, _ := jwt.NewBuilder().
+		Issuer("github.com/lestrrat-go/jwx").
+		IssuedAt(time.Now().Round(0)).
+		Expiration(time.Now().Add(time.Hour * 24)).
+		Build()
+
+	for _, name := range []string{`aud`, `unknown`} {
+		var v any
+		err := tok.Get(name, v)
+		require.Error(t, err, `tok.Get should fail if value is not set`)
+		require.ErrorIs(t, err, jwt.ClaimNotFoundError(), `tok.Get should return ClaimNotFoundError if value is not set`)
+	}
+
+	for _, name := range []string{`iss`, `exp`} {
+		var i byte // a type that can't be assigned to from the fields
+		err := tok.Get(name, &i)
+		require.Error(t, err, `tok.Get should fail if value is not assignable`)
+		require.ErrorIs(t, err, jwt.ClaimAssignmentFailedError(), `tok.Get should return ClaimAssignmentFailedError if value is not assignable`)
+	}
+}
+
 func TestToken_Parse(t *testing.T) {
 	t.Parallel()