ecdsa.com

Verify an ES256 JWT · Go

How to verify an ES256 JWT in Go

Go verifies an ES256 JWT with the standard library alone: base64.RawURLEncoding decodes the unpadded JWS segments, and the raw r‖s signature maps directly onto ecdsa.Verify's two big.Int arguments — no DER conversion required.

Tested with Go 1.26 (standard library only).

Same recipe in:Node.jsPythonGoWebCrypto
verify_jwt.go — ES256 verification, stdlib only
package main
 
import (
"crypto/ecdsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"math/big"
"strings"
)
 
func verifyES256(token string, publicKeyPEM []byte) (map[string]any, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, errors.New("not a compact JWS token")
}
 
headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, err
}
var header struct {
Alg string `json:"alg"`
}
if err := json.Unmarshal(headerJSON, &header); err != nil {
return nil, err
}
if header.Alg != "ES256" {
return nil, fmt.Errorf("expected ES256, got %s", header.Alg)
}
 
block, _ := pem.Decode(publicKeyPEM)
pubAny, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, err
}
pub := pubAny.(*ecdsa.PublicKey)
 
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil || len(sig) != 64 {
return nil, errors.New("signature must be 64 raw bytes (r‖s)")
}
r := new(big.Int).SetBytes(sig[:32])
s := new(big.Int).SetBytes(sig[32:])
 
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if !ecdsa.Verify(pub, digest[:], r, s) {
return nil, errors.New("invalid signature")
}
 
claims := map[string]any{}
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, err
}
if err := json.Unmarshal(payloadJSON, &claims); err != nil {
return nil, err
}
return claims, nil // exp / iss / aud checks are still your job
}

How it works

  1. base64.RawURLEncoding is the exact JWS alphabet: URL-safe, no padding. The plain URLEncoding variant fails on real tokens.
  2. Pin alg to ES256 before touching the key — dispatching on the token's claim enables algorithm-confusion downgrades.
  3. The JWS signature is already the raw form Go wants: split at byte 32, load each half with SetBytes, hand both to ecdsa.Verify with the SHA-256 digest of header.payload.

Gotchas

  • The digest is over the base64url *text* joined with a dot — not over decoded JSON. Re-serializing the payload before hashing is the classic way to make every token invalid.
  • Enforce len(sig) == 64: a DER-encoded signature smuggled into a token (some broken issuers do this) would otherwise mis-split into nonsense big.Ints and fail confusingly rather than clearly.
  • ecdsa.Verify says nothing about claims — an expired or wrong-audience token with a good signature still returns true. Validate exp, iss and aud after the cryptographic check.

Related recipes