package main import ( "fmt" "log" "os" "time" "github.com/golang-jwt/jwt/v5") func main() { pemBytes, err := os.ReadFile("key.pem") if err != nil { log.Fatal(err) } privateKey, err := jwt.ParseECPrivateKeyFromPEM(pemBytes) // PKCS#8 or SEC 1 ("EC PRIVATE KEY") if err != nil { log.Fatal(err) } now := time.Now() claims := jwt.RegisteredClaims{ Issuer: "https://issuer.example.com", Subject: "user-42", Audience: jwt.ClaimStrings{"api://orders"}, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)), } token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) token.Header["kid"] = "2026-10" signed, err := token.SignedString(privateKey) // *ecdsa.PrivateKey, not the PEM bytes if err != nil { log.Fatal(err) } fmt.Println(signed)} How it works
jwt.ParseECPrivateKeyFromPEMreads PKCS#8 and SEC 1 (EC PRIVATE KEY) blocks and returns*ecdsa.PrivateKey.jwt.RegisteredClaimscoversiss,sub,aud,iatandexp;jwt.NewNumericDatetruncates times to whole seconds.token.Header["kid"]adds the key id;NewWithClaimshas already setalgandtyp.SignedString(privateKey)returns the compact tokenheader.payload.signature.
Gotchas
SignedStringtakes the parsed key, not PEM bytes: passing[]bytereturnskey is of invalid type: ECDSA sign expects *ecdsa.PrivateKey.- golang-jwt writes a single audience as an array,
"aud":["api://orders"]. RFC 7519 allows both forms; if a strict consumer insists on a string, setjwt.MarshalSingleStringAsArray = false. - A P-384 key with
SigningMethodES256fails with the tersekey is invalid. UseSigningMethodES384for P-384 keys. - Custom claims belong in a struct that embeds
jwt.RegisteredClaims, not injwt.MapClaimswith hand-made time values: the struct keeps types and time precision consistent.