Skip to content

Latest commit

 

History

History
784 lines (622 loc) · 19.6 KB

File metadata and controls

784 lines (622 loc) · 19.6 KB

Migration Guide: v2 to v3

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 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),
)

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!