This guide helps you migrate from go-jwt-middleware v2 to v3. While v3 introduces significant improvements, the migration is straightforward and can be done incrementally.
| Area | v2 | v3 |
|---|---|---|
| API Style | Mixed (positional + options) | Pure options pattern |
| JWT Library | square/go-jose v2 | lestrrat-go/jwx v3 |
| Claims Access | Type assertion | Generics (type-safe) |
| Architecture | Monolithic | Core-Adapter pattern |
| Context Key | ContextKey{} struct |
Unexported contextKey int |
| Type Names | ExclusionUrlHandler |
ExclusionURLHandler |
| TokenExtractor | Returns string |
Returns ExtractedToken |
| DPoP Support | Not available | Full RFC 9449 support |
- ✅ Better Performance: lestrrat-go/jwx v3 is faster and more efficient
- ✅ More Algorithms: Support for EdDSA, ES256K, and all modern algorithms
- ✅ Type Safety: Generics eliminate type assertion errors at compile time
- ✅ Better IDE Support: Self-documenting options with autocomplete
- ✅ Enhanced Security: CVE mitigations, RFC 6750 compliance, and DPoP support
- ✅ Modern Go: Built for Go 1.24+ with modern patterns
All constructors now use pure options pattern:
v2:
validator.New(keyFunc, algorithm, issuer, audience, options...)
jwtmiddleware.New(validator.ValidateToken, options...)
jwks.NewProvider(issuerURL, options...)v3:
validator.New(
validator.WithKeyFunc(keyFunc),
validator.WithAlgorithm(algorithm),
validator.WithIssuer(issuer),
validator.WithAudience(audience),
// all other options...
)
jwtmiddleware.New(
jwtmiddleware.WithValidator(validator),
// all other options...
)
jwks.NewCachingProvider(
jwks.WithIssuerURL(issuerURL),
// all other options...
)Custom claims are now type-safe with generics:
v2:
validator.WithCustomClaims(func() validator.CustomClaims {
return &MyCustomClaims{} // Returns interface
})v3:
validator.WithCustomClaims(func() *MyCustomClaims {
return &MyCustomClaims{} // Returns concrete type
})The context key is now unexported for safety:
v2:
claims := r.Context().Value(jwtmiddleware.ContextKey{}).(*validator.ValidatedClaims)v3:
// You MUST use GetClaims - the context key is no longer exported
claims, err := jwtmiddleware.GetClaims[*validator.ValidatedClaims](r.Context())
if err != nil {
// Handle error
}URL abbreviation fixed:
v2:
type ExclusionUrlHandler func(r *http.Request) boolv3:
type ExclusionURLHandler func(r *http.Request) boolv3 returns specific HTTP status codes and error bodies for each validation failure instead of a generic 401 for everything.
v2: All validation failures returned the same response:
HTTP 401 Unauthorized
{"error":"invalid_token","error_description":"JWT is invalid"}
v3: Each failure type returns a specific response:
| Failure | HTTP Status | error |
error_code |
|---|---|---|---|
| Malformed token | 401 | invalid_token |
token_malformed |
| Invalid algorithm | 401 | invalid_token |
invalid_algorithm |
| Invalid signature | 401 | invalid_token |
invalid_signature |
| Expired token | 401 | invalid_token |
token_expired |
| Not yet valid | 401 | invalid_token |
token_not_yet_valid |
| Invalid issuer | 401 | invalid_token |
invalid_issuer |
| Invalid audience | 401 | invalid_token |
invalid_audience |
| Invalid claims | 401 | invalid_token |
invalid_claims |
| JWKS fetch failed | 401 | invalid_token |
jwks_fetch_failed |
| JWKS key not found | 401 | invalid_token |
jwks_key_not_found |
Custom error handlers can now inspect specific error codes without importing core:
func myErrorHandler(w http.ResponseWriter, r *http.Request, err error) {
var validationErr *jwtmiddleware.ValidationError
if errors.As(err, &validationErr) {
switch validationErr.Code {
case jwtmiddleware.ErrorCodeTokenExpired:
// handle expired token
case jwtmiddleware.ErrorCodeInvalidIssuer:
// handle untrusted issuer
default:
// handle other validation errors
}
return
}
if errors.Is(err, jwtmiddleware.ErrJWTMissing) {
// handle missing token
return
}
// handle unexpected errors
}Available error code constants on the jwtmiddleware package:
ErrorCodeTokenMalformed— token could not be parsedErrorCodeTokenExpired— tokenexpclaim is in the pastErrorCodeTokenNotYetValid— tokennbf/iatclaim is in the futureErrorCodeInvalidSignature— signature verification failedErrorCodeInvalidAlgorithm— token uses a disallowed algorithmErrorCodeInvalidIssuer— token issuer is not trustedErrorCodeInvalidAudience— token audience does not matchErrorCodeInvalidClaims— custom claims validation failedErrorCodeJWKSFetchFailed— failed to fetch signing keysErrorCodeJWKSKeyNotFound— no matching key found in JWKS
TokenExtractor now returns ExtractedToken (with scheme) instead of string:
v2:
type TokenExtractor func(r *http.Request) (string, error)v3:
type ExtractedToken struct {
Token string
Scheme AuthScheme // AuthSchemeBearer, AuthSchemeDPoP, or AuthSchemeUnknown
}
type TokenExtractor func(r *http.Request) (ExtractedToken, error)Note: Built-in extractors (CookieTokenExtractor, ParameterTokenExtractor, MultiTokenExtractor) work unchanged. Only custom extractors need updating.
Update your go.mod:
go get github.com/auth0/go-jwt-middleware/v3Update imports in your code:
v2:
import (
"github.com/auth0/go-jwt-middleware/v2"
"github.com/auth0/go-jwt-middleware/v2/validator"
"github.com/auth0/go-jwt-middleware/v2/jwks"
)v3:
import (
"github.com/auth0/go-jwt-middleware/v3"
"github.com/auth0/go-jwt-middleware/v3/validator"
"github.com/auth0/go-jwt-middleware/v3/jwks"
)v2:
jwtValidator, err := validator.New(
keyFunc,
validator.RS256,
"https://issuer.example.com/",
[]string{"my-api"},
)v3:
jwtValidator, err := validator.New(
validator.WithKeyFunc(keyFunc),
validator.WithAlgorithm(validator.RS256),
validator.WithIssuer("https://issuer.example.com/"),
validator.WithAudience("my-api"),
)v2:
jwtValidator, err := validator.New(
keyFunc,
validator.RS256,
"https://issuer.example.com/",
[]string{"my-api"},
validator.WithCustomClaims(func() validator.CustomClaims {
return &CustomClaimsExample{}
}),
validator.WithAllowedClockSkew(30*time.Second),
)v3:
jwtValidator, err := validator.New(
validator.WithKeyFunc(keyFunc),
validator.WithAlgorithm(validator.RS256),
validator.WithIssuer("https://issuer.example.com/"),
validator.WithAudience("my-api"),
validator.WithCustomClaims(func() *CustomClaimsExample {
return &CustomClaimsExample{} // No interface cast needed!
}),
validator.WithAllowedClockSkew(30*time.Second),
)v2:
jwtValidator, err := validator.New(
keyFunc,
validator.RS256,
"https://issuer1.example.com/", // First issuer
[]string{"api1", "api2"}, // Multiple audiences
validator.WithIssuer("https://issuer2.example.com/"), // Additional issuer
)v3:
jwtValidator, err := validator.New(
validator.WithKeyFunc(keyFunc),
validator.WithAlgorithm(validator.RS256),
validator.WithIssuers([]string{
"https://issuer1.example.com/",
"https://issuer2.example.com/",
}),
validator.WithAudiences([]string{"api1", "api2"}),
)v2:
provider, err := jwks.NewProvider(issuerURL)v3:
provider, err := jwks.NewProvider(
jwks.WithIssuerURL(issuerURL),
)v2:
provider, err := jwks.NewCachingProvider(
issuerURL,
5*time.Minute, // cache TTL
)v3:
provider, err := jwks.NewCachingProvider(
jwks.WithIssuerURL(issuerURL),
jwks.WithCacheTTL(5*time.Minute),
)v2:
provider, err := jwks.NewCachingProvider(
issuerURL,
5*time.Minute,
jwks.WithCustomJWKSURI(customURI),
)v3:
provider, err := jwks.NewCachingProvider(
jwks.WithIssuerURL(issuerURL),
jwks.WithCacheTTL(5*time.Minute),
jwks.WithCustomJWKSURI(customURI),
)v2:
middleware := jwtmiddleware.New(jwtValidator.ValidateToken)v3:
v3Middleware, err := v3.New(
v3.WithValidator(v3Validator),
)
if err != nil {
log.Fatal(err)
}v2:
middleware := jwtmiddleware.New(
jwtValidator.ValidateToken,
jwtmiddleware.WithCredentialsOptional(true),
jwtmiddleware.WithErrorHandler(customErrorHandler),
)v3:
middleware, err := jwtmiddleware.New(
jwtmiddleware.WithValidator(jwtValidator),
jwtmiddleware.WithCredentialsOptional(true),
jwtmiddleware.WithErrorHandler(customErrorHandler),
)
if err != nil {
log.Fatal(err)
}v3 Breaking Change: TokenExtractor now returns ExtractedToken instead of string:
v2:
// TokenExtractor returned string
type TokenExtractor func(r *http.Request) (string, error)v3:
// TokenExtractor returns ExtractedToken with both token and scheme
type ExtractedToken struct {
Token string
Scheme AuthScheme // bearer, dpop, or unknown
}
type TokenExtractor func(r *http.Request) (ExtractedToken, error)Built-in extractors work the same way:
// These all work unchanged - internal implementation updated
jwtmiddleware.CookieTokenExtractor("jwt")
jwtmiddleware.ParameterTokenExtractor("token")
jwtmiddleware.MultiTokenExtractor(extractors...)Custom extractors must be updated:
// v2
customExtractor := func(r *http.Request) (string, error) {
return r.Header.Get("X-Custom-Token"), nil
}
// v3
customExtractor := func(r *http.Request) (jwtmiddleware.ExtractedToken, error) {
return jwtmiddleware.ExtractedToken{
Token: r.Header.Get("X-Custom-Token"),
Scheme: jwtmiddleware.AuthSchemeUnknown, // or AuthSchemeBearer if you know
}, nil
}v2:
func handler(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(jwtmiddleware.ContextKey{}).(*validator.ValidatedClaims)
fmt.Fprintf(w, "Hello, %s", claims.RegisteredClaims.Subject)
}v3 (recommended - type-safe):
func handler(w http.ResponseWriter, r *http.Request) {
claims, err := jwtmiddleware.GetClaims[*validator.ValidatedClaims](r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
fmt.Fprintf(w, "Hello, %s", claims.RegisteredClaims.Subject)
}v2:
claims := r.Context().Value(jwtmiddleware.ContextKey{}).(*validator.ValidatedClaims)
customClaims := claims.CustomClaims.(*MyCustomClaims)v3:
claims, _ := jwtmiddleware.GetClaims[*validator.ValidatedClaims](r.Context())
customClaims := claims.CustomClaims.(*MyCustomClaims)
// Or use MustGetClaims if you're sure claims exist
claims := jwtmiddleware.MustGetClaims[*validator.ValidatedClaims](r.Context())
customClaims := claims.CustomClaims.(*MyCustomClaims)v2:
package main
import (
"context"
"log"
"net/http"
"net/url"
"time"
jwtmiddleware "github.com/auth0/go-jwt-middleware/v2"
"github.com/auth0/go-jwt-middleware/v2/jwks"
"github.com/auth0/go-jwt-middleware/v2/validator"
)
func main() {
issuerURL, _ := url.Parse("https://example.auth0.com/")
// JWKS Provider
provider, err := jwks.NewCachingProvider(issuerURL, 5*time.Minute)
if err != nil {
log.Fatal(err)
}
// Validator
jwtValidator, err := validator.New(
provider.KeyFunc,
validator.RS256,
issuerURL.String(),
[]string{"my-api"},
validator.WithCustomClaims(func() validator.CustomClaims {
return &CustomClaimsExample{}
}),
)
if err != nil {
log.Fatal(err)
}
// Middleware
middleware := jwtmiddleware.New(
jwtValidator.ValidateToken,
jwtmiddleware.WithCredentialsOptional(true),
)
// Handler
http.Handle("/api", middleware.CheckJWT(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(jwtmiddleware.ContextKey{}).(*validator.ValidatedClaims)
customClaims := claims.CustomClaims.(*CustomClaimsExample)
w.Write([]byte("Hello, " + claims.RegisteredClaims.Subject))
})))
http.ListenAndServe(":3000", nil)
}v3:
package main
import (
"context"
"log"
"net/http"
"net/url"
"time"
"github.com/auth0/go-jwt-middleware/v3"
"github.com/auth0/go-jwt-middleware/v3/jwks"
"github.com/auth0/go-jwt-middleware/v3/validator"
)
func main() {
issuerURL, _ := url.Parse("https://example.auth0.com/")
// JWKS Provider - now with options
provider, err := jwks.NewCachingProvider(
jwks.WithIssuerURL(issuerURL),
jwks.WithCacheTTL(5*time.Minute),
)
if err != nil {
log.Fatal(err)
}
// Validator - now with options
jwtValidator, err := validator.New(
validator.WithKeyFunc(provider.KeyFunc),
validator.WithAlgorithm(validator.RS256),
validator.WithIssuer(issuerURL.String()),
validator.WithAudience("my-api"),
validator.WithCustomClaims(func() *CustomClaimsExample {
return &CustomClaimsExample{} // Type-safe!
}),
)
if err != nil {
log.Fatal(err)
}
// Middleware - now returns error
middleware, err := jwtmiddleware.New(
jwtmiddleware.WithValidator(jwtValidator),
jwtmiddleware.WithCredentialsOptional(true),
)
if err != nil {
log.Fatal(err)
}
// Handler - now with type-safe claims
http.Handle("/api", middleware.CheckJWT(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, err := jwtmiddleware.GetClaims[*validator.ValidatedClaims](r.Context())
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
customClaims := claims.CustomClaims.(*CustomClaimsExample)
w.Write([]byte("Hello, " + claims.RegisteredClaims.Subject))
})))
http.ListenAndServe(":3000", nil)
}v3 adds optional logging support:
import "log/slog"
logger := slog.Default()
middleware, err := jwtmiddleware.New(
jwtmiddleware.WithValidator(jwtValidator),
jwtmiddleware.WithLogger(logger),
)v3 provides RFC 6750 compliant error responses with specific status codes, error types, and machine-readable error codes for each validation failure:
{
"error": "invalid_token",
"error_description": "The access token expired",
"error_code": "token_expired"
}With proper WWW-Authenticate headers:
WWW-Authenticate: Bearer realm="api", error="invalid_token", error_description="The access token expired"
Custom error handlers can inspect the specific failure using errors.As with *jwtmiddleware.ValidationError — no need to import the core package. See Structured Error Responses (Breaking) for the full error code reference.
v3 supports 14 algorithms (v2 had 10):
New in v3:
EdDSA(Ed25519)ES256K(ECDSA with secp256k1)PS256,PS384,PS512(RSA-PSS)
Check if claims exist without retrieving them:
if jwtmiddleware.HasClaims(r.Context()) {
// Claims are present
}Easily exclude specific URLs from JWT validation:
middleware, err := jwtmiddleware.New(
jwtmiddleware.WithValidator(jwtValidator),
jwtmiddleware.WithExclusionUrls([]string{
"/health",
"/metrics",
}),
)v3 adds full support for RFC 9449 DPoP, which provides proof-of-possession for access tokens:
// DPoP modes:
// - DPoPAllowed (default): Accept both Bearer and DPoP tokens
// - DPoPRequired: Only accept DPoP tokens
// - DPoPDisabled: Ignore DPoP, reject DPoP scheme
middleware, err := jwtmiddleware.New(
jwtmiddleware.WithValidator(jwtValidator),
jwtmiddleware.WithDPoPMode(jwtmiddleware.DPoPRequired),
)DPoP validates:
- Proof signature using asymmetric algorithms (RS256, ES256, etc.)
- HTTP method and URL binding (
htmandhtuclaims) - Token binding via thumbprint (
jktclaim in access token'scnf) - Access token hash (
athclaim) matching - Replay protection via
jtiandiatclaims
See the DPoP examples for complete working code.
A: Yes! The module paths are different (v2 vs v3), so you can import both:
import (
v2 "github.com/auth0/go-jwt-middleware/v2"
v3 "github.com/auth0/go-jwt-middleware/v3"
)A: No. JWT tokens are standard-compliant and work with both versions.
A: Only if you upgrade the import path. Keep using /v2 until you're ready to migrate.
A: v3 is generally faster due to lestrrat-go/jwx v3's optimizations:
- Token parsing: ~10-20% faster
- JWKS operations: ~15-25% faster
- Memory usage: ~10-15% lower
A: No, ContextKey{} is no longer exported in v3. You must use the generic GetClaims[T]() helper function for type-safe claims retrieval.
A: Yes, and more! All v2 features are available in v3 with improved APIs.
A: Start with a single route:
// Keep v2 for most routes
v2Middleware := v2.New(v2Validator.ValidateToken)
http.Handle("/api/v2/", v2Middleware.CheckJWT(v2Handler))
// Test v3 on one route
v3Middleware, _ := v3.New(v3.WithValidator(v3Validator))
http.Handle("/api/v3/", v3Middleware.CheckJWT(v3Handler))A:
Ready to migrate? Start with the Getting Started guide and check out the examples for working code!