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

Areav2v3
API StyleMixed (positional + options)Pure options pattern
JWT Librarysquare/go-jose v2lestrrat-go/jwx v3
Claims AccessType assertionGenerics (type-safe)
ArchitectureMonolithicCore-Adapter pattern
Context KeyContextKey{} structUnexported contextKey int
Type NamesExclusionUrlHandlerExclusionURLHandler
TokenExtractorReturns stringReturns ExtractedToken
DPoP SupportNot availableFull 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:

FailureHTTP Statuserrorerror_code
Malformed token401invalid_tokentoken_malformed
Invalid algorithm401invalid_tokeninvalid_algorithm
Invalid signature401invalid_tokeninvalid_signature
Expired token401invalid_tokentoken_expired
Not yet valid401invalid_tokentoken_not_yet_valid
Invalid issuer401invalid_tokeninvalid_issuer
Invalid audience401invalid_tokeninvalid_audience
Invalid claims401invalid_tokeninvalid_claims
JWKS fetch failed401invalid_tokenjwks_fetch_failed
JWKS key not found401invalid_tokenjwks_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 parsed
  • ErrorCodeTokenExpired — token exp claim is in the past
  • ErrorCodeTokenNotYetValid — token nbf/iat claim is in the future
  • ErrorCodeInvalidSignature — signature verification failed
  • ErrorCodeInvalidAlgorithm — token uses a disallowed algorithm
  • ErrorCodeInvalidIssuer — token issuer is not trusted
  • ErrorCodeInvalidAudience — token audience does not match
  • ErrorCodeInvalidClaims — custom claims validation failed
  • ErrorCodeJWKSFetchFailed — failed to fetch signing keys
  • ErrorCodeJWKSKeyNotFound — 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 (htm and htu claims)
  • Token binding via thumbprint (jkt claim in access token's cnf)
  • Access token hash (ath claim) matching
  • Replay protection via jti and iat claims

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!