Migration Guide: v2 to v3
July 31, 2026 · View on GitHub
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.
Table of Contents
Overview
What's Changed
| 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 |
Why Upgrade?
- ✅ 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
Breaking Changes
1. Pure Options Pattern
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...
)
2. Custom Claims Generic
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
})
3. Context Key Change
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
}
4. Type Naming
URL abbreviation fixed:
v2:
type ExclusionUrlHandler func(r *http.Request) bool
v3:
type ExclusionURLHandler func(r *http.Request) bool
5. Structured Error Responses
v3 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
6. TokenExtractor Signature Change
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.
Step-by-Step Migration
1. Update Dependencies
Update your go.mod:
go get github.com/auth0/go-jwt-middleware/v3
Update 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"
)
2. Update Validator
Basic Validator
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"),
)
Validator with Options
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),
)
Multiple Issuers/Audiences
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"}),
)
3. Update JWKS Provider
Simple Provider
v2:
provider, err := jwks.NewProvider(issuerURL)
v3:
provider, err := jwks.NewProvider(
jwks.WithIssuerURL(issuerURL),
)
Caching Provider
v2:
provider, err := jwks.NewCachingProvider(
issuerURL,
5*time.Minute, // cache TTL
)
v3:
provider, err := jwks.NewCachingProvider(
jwks.WithIssuerURL(issuerURL),
jwks.WithCacheTTL(5*time.Minute),
)
Custom JWKS URI
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),
)
Signing keys without an alg member
In v2, go-jose derived the signing algorithm from the token's JWS protected
header, so a JWKS whose keys omitted the optional alg member (which RFC 7517
§4.4 allows) verified without any extra configuration. v3 uses jwx, which derives
the algorithm from the key type when a key has no alg, and the middleware
enables this automatically. You do not need to change anything: the algorithm you
pass to WithAlgorithm/WithAlgorithms is still enforced against the token
header before any key is selected, so an alg-less JWKS verifies exactly as it did
under v2.
4. Update Middleware
Basic Middleware
v2:
middleware := jwtmiddleware.New(jwtValidator.ValidateToken)
v3:
v3Middleware, err := v3.New(
v3.WithValidator(v3Validator),
)
if err != nil {
log.Fatal(err)
}
Middleware with Options
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)
}
Token Extractors
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
}
5. Update Claims Access
Handler Claims Access
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)
}
Custom Claims Access
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)
API Comparison
Complete Migration Example
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)
}
New Features
1. Structured Logging
v3 adds optional logging support:
import "log/slog"
logger := slog.Default()
middleware, err := jwtmiddleware.New(
jwtmiddleware.WithValidator(jwtValidator),
jwtmiddleware.WithLogger(logger),
)
2. Structured Error Responses
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.
3. More Algorithms
v3 supports 14 algorithms (v2 had 10):
New in v3:
EdDSA(Ed25519)ES256K(ECDSA with secp256k1)PS256,PS384,PS512(RSA-PSS)
4. HasClaims Helper
Check if claims exist without retrieving them:
if jwtmiddleware.HasClaims(r.Context()) {
// Claims are present
}
5. URL Exclusions
Easily exclude specific URLs from JWT validation:
middleware, err := jwtmiddleware.New(
jwtmiddleware.WithValidator(jwtValidator),
jwtmiddleware.WithExclusionUrls([]string{
"/health",
"/metrics",
}),
)
6. DPoP (Demonstrating Proof-of-Possession)
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.
FAQ
Q: Can I use v2 and v3 side by side during migration?
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"
)
Q: Do I need to change my tokens?
A: No. JWT tokens are standard-compliant and work with both versions.
Q: Will v3 break my existing middleware?
A: Only if you upgrade the import path. Keep using /v2 until you're ready to migrate.
Q: What's the performance difference?
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
Q: Can I still use the old context key?
A: No, ContextKey{} is no longer exported in v3. You must use the generic GetClaims[T]() helper function for type-safe claims retrieval.
Q: Are all v2 features available in v3?
A: Yes, and more! All v2 features are available in v3 with improved APIs.
Q: How do I test my migration?
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))
Q: Where can I get help?
A:
Ready to migrate? Start with the Getting Started guide and check out the examples for working code!