Skip to content
ecdsa.com

Verify a JWT with JWKS · Go

How to verify a JWT with a JWKS in Go

To verify a JWT against a JWKS in Go, keyfunc (built on jwkset) turns the issuer's key set into a jwt.Keyfunc for golang-jwt/jwt v5, which picks the key by kid; a parser configured with the expected algorithm, issuer and audience does the rest. The run serves a real JWKS over HTTPS inside the container and checks five tokens.

Tested with github.com/golang-jwt/jwt/v5 v5.3.1 (Go 1.27.3) and github.com/MicahParks/keyfunc/v3 v3.8.2 (with github.com/MicahParks/jwkset v0.11.3).

verifier.go — keyfunc for the keys, a strict jwt.Parser for the claims
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
}
main.go — check every token in tokens.txt
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

  1. keyfunc.NewDefaultCtx downloads the JWKS, refreshes it every hour in a goroutine that ends with the context, and refetches it when a kid is unknown — at most once per 5 minutes.
  2. jwt.NewParser with WithValidMethods, WithIssuer, WithAudience and WithExpirationRequired fixes what a valid token is; build it once and reuse it.
  3. ParseWithClaims(token, claims, jwks.Keyfunc) asks Keyfunc for the key named by the header's kid, verifies the signature, then validates the claims.
  4. Errors wrap sentinel values, so errors.Is(err, jwt.ErrTokenExpired) or jwt.ErrTokenInvalidAudience tell the cases apart.

Gotchas

  • golang-jwt checks aud and iss only when asked: without WithAudience, the token issued for api://billing is accepted by the orders API. The same goes for exp — a token without one passes unless WithExpirationRequired() is set.
  • WithValidMethods matters 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 NewDefaultCtx but logs (rather than returns) a failed first fetch; a wrong URL shows up as key not found on every token. Check the logs, or fetch once yourself at startup.
  • An unknown kid reports token is unverifiable: error while executing keyfunc: key not found "2025-01" — it is a key-selection problem, not a bad signature.

Related recipes