package main import ( "context" "github.com/MicahParks/keyfunc/v3" "github.com/golang-jwt/jwt/v5") type Verifier struct { jwks keyfunc.Keyfunc parser *jwt.Parser} // NewVerifier fetches the JWKS once, refreshes it hourly in the background and// again when a token names an unknown kid (at most once per 5 minutes).func NewVerifier(ctx context.Context) (*Verifier, error) { jwks, err := keyfunc.NewDefaultCtx(ctx, []string{"https://issuer.example.com/.well-known/jwks.json"}) if err != nil { return nil, err } parser := jwt.NewParser( jwt.WithValidMethods([]string{"ES256"}), // never let the token choose the algorithm jwt.WithIssuer("https://issuer.example.com"), jwt.WithAudience("api://orders"), jwt.WithExpirationRequired(), ) return &Verifier{jwks: jwks, parser: parser}, nil} func (v *Verifier) Verify(token string) (*jwt.RegisteredClaims, error) { claims := &jwt.RegisteredClaims{} _, err := v.parser.ParseWithClaims(token, claims, v.jwks.Keyfunc) // Keyfunc picks the key by kid if err != nil { return nil, err } return claims, nil} package main import ( "bufio" "context" "fmt" "log" "os" "strings") func main() { verifier, err := NewVerifier(context.Background()) if err != nil { log.Fatal(err) } file, err := os.Open("tokens.txt") if err != nil { log.Fatal(err) } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { label, token, _ := strings.Cut(scanner.Text(), " ") claims, err := verifier.Verify(token) if err != nil { fmt.Printf("%s: rejected: %v\n", label, err) continue } fmt.Printf("%s: accepted, sub=%s\n", label, claims.Subject) }} How it works
keyfunc.NewDefaultCtxdownloads the JWKS, refreshes it every hour in a goroutine that ends with the context, and refetches it when akidis unknown — at most once per 5 minutes.jwt.NewParserwithWithValidMethods,WithIssuer,WithAudienceandWithExpirationRequiredfixes what a valid token is; build it once and reuse it.ParseWithClaims(token, claims, jwks.Keyfunc)asksKeyfuncfor the key named by the header'skid, verifies the signature, then validates the claims.- Errors wrap sentinel values, so
errors.Is(err, jwt.ErrTokenExpired)orjwt.ErrTokenInvalidAudiencetell the cases apart.
Gotchas
- golang-jwt checks
audandissonly when asked: withoutWithAudience, the token issued forapi://billingis accepted by the orders API. The same goes forexp— a token without one passes unlessWithExpirationRequired()is set. WithValidMethodsmatters even with a JWKS: it is what stops a token from naming a different algorithm than the one your service accepts.- keyfunc fetches the JWKS in
NewDefaultCtxbut logs (rather than returns) a failed first fetch; a wrong URL shows up askey not foundon every token. Check the logs, or fetch once yourself at startup. - An unknown
kidreportstoken is unverifiable: error while executing keyfunc: key not found "2025-01"— it is a key-selection problem, not a bad signature.