Skip to content
ecdsa.com
Go

go-jose

go-jose, the Go JOSE implementation used by projects such as Kubernetes, Vault and Dex: algorithm lists are mandatory when parsing since v4, signature checking and claims validation are two separate calls, and leeway defaults to one minute.
License
Apache-2.0
Checked version
4.1.6 ·
Source
github.com/go-jose/go-jose
Documentation
pkg.go.dev/github.com/go-jose/go-jose/v4

Compatibility at a glance

JOSE and JWT

JWS algorithms
HS256HS384HS512RS256RS384RS512PS256PS384PS512ES256ES384ES512EdDSA

github.com/go-jose/go-jose/v4 (the continuation of Square's go-jose) implements JWS, JWE, JWK and JWT, which is why it shows up wherever Go services exchange encrypted or signed tokens. 4.1.6 signed and verified the HS, RS, PS and ES families and EdDSA; ES256K, Ed25519 and Ed448 are not recognised algorithm names.

Version 4 made the algorithm list mandatory: jwt.ParseSigned(token, nil) fails with no signature algorithms specified, so a token can only be parsed against algorithms you chose. Unsigned tokens can be parsed if none is in that list, but Claims(key) never verifies them — the only way to read one is the explicitly named UnsafeClaimsWithoutVerification.

Verification is split in two, and that is the trap. token.Claims(key, &claims) checks the signature and fills the struct — it returned the claims of a token that had expired an hour earlier without any error. Expiry, not-before, issuer and audience are checked only by claims.Validate() with a jwt.Expected value or ValidateWithLeeway, and a token without exp passes even that, so check that Expiry is set, as the sample does. Validate allows jwt.DefaultLeeway, one minute, which the check confirmed.

Keys are typed. An HS256 token keyed with the PEM text failed against the *ecdsa.PublicKey with error in cryptographic primitive, but verified when the same PEM bytes were passed as the key — keep keys as parsed Go keys, not bytes. ES256 refuses a P-384 key with expected 256 bit key, got 384 bits instead. A DER-encoded ES256 signature fails with the same generic error in cryptographic primitive as a forgery; the JWT debugger shows the signature length.

For encrypted tokens, the JWE inspector shows the header and algorithms; for key sets, the JWKS checker shows what a provider publishes and the JWK converter moves keys between JWK and PEM.

Facts and the checks behind them

Nothing below is copied from documentation. The check content/libraries/verify/go-jose/run.sh ran go-jose 4.1.6 in the container image golang:1.27.3-trixie on October 20, 2026; the page must match what it printed, and each fact lists those lines.

  • JWS algorithms

    HS256HS384HS512RS256RS384RS512PS256PS384PS512ES256ES384ES512EdDSA

    HS, RS, PS and ES at 256/384/512 bits and EdDSA signed and verified; ES256K, Ed25519 and Ed448 fail with unknown/unsupported algorithm.

    Recorded by the check
    fact jws-algorithms = HS256, HS384, HS512, RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512, EdDSA
    evidence jws-algorithms: ES256K: go-jose/go-jose: unknown/unsupported algorithm
    evidence jws-algorithms: Ed25519: go-jose/go-jose: unknown/unsupported algorithm
    evidence jws-algorithms: Ed448: go-jose/go-jose: unknown/unsupported algorithm
  • ES256 signature encoding

    raw

    ES256 signatures are 64 bytes of raw r‖s.

    Recorded by the check
    fact jws-ecdsa-encoding = raw
  • DER signature inside a JWS

    rejected

    A DER-encoded ES256 signature fails with go-jose/go-jose: error in cryptographic primitive.

    Recorded by the check
    fact jws-der-signature = rejected
    evidence jws-der-signature: go-jose/go-jose: error in cryptographic primitive
  • Algorithm allow-list

    required

    jwt.ParseSigned requires the allowed algorithms; an empty list fails with no signature algorithms specified.

    Recorded by the check
    fact jwt-algorithm-allowlist = required
    evidence jwt-algorithm-allowlist: ParseSigned with an empty algorithm list: go-jose/go-jose: no signature algorithms specified
  • Unsigned tokens (alg none)

    rejected

    With none in the list an unsigned token parses, but Claims() fails for an EC key and for an HMAC secret; only UnsafeClaimsWithoutVerification reads it.

    Recorded by the check
    fact jwt-alg-none = rejected
    evidence jwt-alg-none: ParseSigned with none in the list: <nil>
    evidence jwt-alg-none: Claims(*ecdsa.PublicKey): go-jose/go-jose: error in cryptographic primitive
    evidence jwt-alg-none: Claims([]uint8): go-jose/go-jose: error in cryptographic primitive
    evidence jwt-alg-none: UnsafeClaimsWithoutVerification: <nil>, sub fact-check
  • Public key used as an HMAC secret

    rejected

    An HS256 token keyed with the PEM text failed against the *ecdsa.PublicKey; it verified only when the PEM bytes themselves were passed as the key.

    Recorded by the check
    fact jwt-hmac-public-key = rejected
    evidence jwt-hmac-public-key: Claims with the *ecdsa.PublicKey: go-jose/go-jose: error in cryptographic primitive
    evidence jwt-hmac-public-key: Claims with the PEM bytes as key: <nil>
  • Curve checked against alg

    enforced

    Signing ES256 with a P-384 key fails with expected 256 bit key, got 384 bits instead.

    Recorded by the check
    fact jwt-ecdsa-curve-check = enforced
    evidence jwt-ecdsa-curve-check: go-jose/go-jose: expected 256 bit key, got 384 bits instead
  • Claims validated while decoding

    no

    Claims() returned the claims of a token that had expired an hour earlier with no error; time and issuer checks happen only in Validate.

    Recorded by the check
    fact jwt-claims-validated-on-decode = no
    evidence jwt-claims-validated-on-decode: Claims() on a token expired an hour ago: <nil>
  • Default clock skew

    60s

    Validate allows jwt.DefaultLeeway, one minute: a token expired 57 seconds earlier passed and one expired 63 seconds earlier failed.

    Recorded by the check
    fact jwt-clock-skew = 60s
    evidence jwt-clock-skew: jwt.DefaultLeeway = 1m0s
  • Expiry claim required

    no

    A token without exp passed Validate; check that Expiry is non-nil yourself.

    Recorded by the check
    fact jwt-exp-required = no

Usage

Sign registered claims with ES256, parse with the algorithm list, check the signature and then validate the claims separately.
package main

import (
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"fmt"
	"time"

	jose "github.com/go-jose/go-jose/v4"
	"github.com/go-jose/go-jose/v4/jwt"
)

func main() {
	key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	signer, _ := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: key}, (&jose.SignerOptions{}).WithType("JWT"))
	raw, _ := jwt.Signed(signer).Claims(jwt.Claims{Subject: "user-42", Issuer: "https://auth.example.com", Expiry: jwt.NewNumericDate(time.Now().Add(10 * time.Minute))}).Serialize()

	// v4 requires the allowed algorithms when parsing.
	token, _ := jwt.ParseSigned(raw, []jose.SignatureAlgorithm{jose.ES256})
	claims := jwt.Claims{}
	sigErr := token.Claims(&key.PublicKey, &claims) // checks the signature only
	// Validate checks exp, nbf and iss (with jwt.DefaultLeeway of one minute).
	valErr := claims.Validate(jwt.Expected{Issuer: "https://auth.example.com", Time: time.Now()})
	fmt.Println("valid:", sigErr == nil && valErr == nil && claims.Expiry != nil, "sub:", claims.Subject)
}

The snippet harness runs every sample verbatim in the container image golang:1.27.3-trixie, and the run fails unless the output matches.

Command
GOFLAGS=-mod=mod go run .
Output must match (regular expression)
valid: true sub: user-42

Libraries