--- name: jwx-guide-v4 description: Guide for developing Go applications with github.com/lestrrat-go/jwx v4 — parse/sign JWTs, work with JWS/JWE/JWK, pick algorithms, and avoid the common footguns. For developers using jwx, not for developing the library itself. --- # jwx-guide-v4 This skill helps you assist Go developers who are **using** `github.com/lestrrat-go/jwx/v4` in their own projects. It is scoped to **v4** only. There is no equivalent skill for v3 or v2; for those, work from the version's own `docs/` directory and pkg.go.dev. Do not apply v4 rules to a v3 or v2 codebase — the `jwa` identifiers are constants there, not functions, and the `jwk` and error APIs differ. ## Where to look things up You will not have this repository checked out. To verify an API claim before answering, use these sources, in order of preference: 1. **Go module cache** — if the user's project depends on jwx, the source is on disk: ```bash echo "$(go env GOMODCACHE)/github.com/lestrrat-go/jwx/v4@$(go list -m -f '{{.Version}}' github.com/lestrrat-go/jwx/v4)" ``` This directory contains the full source tree including `docs/`, `jwt/`, `jws/`, `jwe/`, `jwk/`, `jwa/`. 2. **pkg.go.dev** — canonical API reference for any exported symbol: - `https://pkg.go.dev/github.com/lestrrat-go/jwx/v4` - `https://pkg.go.dev/github.com/lestrrat-go/jwx/v4/jwt` (etc. per subpackage) 3. **GitHub** — narrative documentation lives at the repo: - `https://github.com/lestrrat-go/jwx/blob/develop/v4/docs/01-jwt.md` - `https://github.com/lestrrat-go/jwx/blob/develop/v4/docs/02-jws.md` - `https://github.com/lestrrat-go/jwx/blob/develop/v4/docs/03-jwe.md` - `https://github.com/lestrrat-go/jwx/blob/develop/v4/docs/04-jwk.md` - `https://github.com/lestrrat-go/jwx/blob/develop/v4/docs/10-extensions.md` - `https://github.com/lestrrat-go/jwx/blob/develop/v4/docs/99-faq.md` 4. **Examples repo** — runnable usage patterns. The repo's `README.md` is a topical index that maps "what do I want to do" → "which `*_example_test.go` file." Fetch the README first when looking for an example by topic; then fetch the linked file: - `https://github.com/jwx-go/examples/blob/develop/v4/README.md` When the user's question goes beyond the patterns in this skill (custom claim types, JWE recipients with per-recipient headers, custom key providers, base64 backend swap, performance tuning), fetch from these sources rather than guessing. ## Prerequisites - **Go 1.26+** is required. v4 uses generics features and stdlib additions (`errors.AsType[T]`) that are new in 1.26. - **`GOEXPERIMENT=jsonv2`** must be set on Go 1.26 for every `go build`/`go test`/`go run`, because v4 depends on `encoding/json/v2`. Without it → builds fail with `build constraints exclude all Go files`. NEVER set it on Go 1.27+ → `encoding/json/v2` is in the standard library there, and naming the experiment rebuilds the standard library under a non-default configuration. Sub-package map: | Package | Role | |---------|------| | `jwa` | Algorithm identifiers as **functions**: `jwa.RS256()`, `jwa.ES256()`, `jwa.HS256()`, `jwa.A256GCM()`, `jwa.RSA_OAEP_256()`, `jwa.EdDSAEd25519()`, etc. | | `jwk` | JSON Web Keys: parsing, generating, import/export between `jwk.Key` and `crypto.*` keys, key sets. | | `jws` | Sign and verify arbitrary payloads (compact or JSON serialization). | | `jwe` | Encrypt and decrypt arbitrary payloads. | | `jwt` | JWT tokens — claims, signing, verification + validation. Wraps `jws`. | | `jwt/openid` | OpenID Connect ID-token-flavored claims. | ## Critical rules (read first) 1. **`jwt.Parse` verifies AND validates by default.** A bare `jwt.Parse(data)` errors because no key was supplied. To intentionally skip both, use `jwt.ParseInsecure`. To verify but skip claim validation, pass `jwt.WithValidate(false)`. This is deliberately asymmetric vs. `jws.Parse`/`jwe.Parse` (which only parse). Do not "correct" it. 2. **Always pin the algorithm on the verify side.** `jwt.WithKey(jwa.RS256(), key)`, `jws.WithKey(jwa.ES256(), key)`. Never trust the `alg` from the incoming header alone. 3. **Never use `jwt.ParseInsecure` for tokens received from the network.** It is for testing or for extracting claims from a token whose origin is already trusted by other means. 4. **`jwa` algorithms are functions in v4, not constants.** Write `jwa.RS256()`, not `jwa.RS256`. This trips up users migrating from v2/v3. 5. **`kid` matching is enforced when verifying with a JWK Set.** If a token omits `kid` and the set contains exactly one key, use `jwt.WithKeySet(set, jws.WithUseDefault(true))`. Use `jws.WithRequireKid(false)` only when verification must consider multiple keys without matching `kid` values. 6. **`jku` (key URL in the JWS header) is attacker-controlled.** Use `jwt.WithVerifyAuto` only with a `jwkfetch.Client` configured with a `jwkfetch.NewMapWhitelist()` of allowed URLs. 7. **HMAC keys are `[]byte`, not `string`.** Pass `[]byte("secret")`, or better, a `jwk.Key` imported from those bytes. 8. **`jwk.Import` and `jwk.Export` require explicit type parameters.** `jwk.Import[jwk.Key](raw)`, `jwk.Export[*rsa.PublicKey](key)`. Their type argument is not inferable from the call, so bare `jwk.Import(raw)` does **not** compile. 9. **`jwk.ParseKey` is not generic.** `jwk.ParseKey(data)` returns `(jwk.Key, error)`. Use `jwk.ParseKeyAs[jwk.RSAPublicKey](data)` when a concrete JWK type is required. ## Verifying a JWT (the 90% case) ```go import ( "github.com/lestrrat-go/jwx/v4/jwa" "github.com/lestrrat-go/jwx/v4/jwt" ) tok, err := jwt.Parse(raw, jwt.WithKey(jwa.RS256(), publicKey)) if err != nil { // signature failed, claim validation failed, or parse failed return err } // tok is verified + validated; safe to read claims ``` `publicKey` may be: - a `*rsa.PublicKey` / `*ecdsa.PublicKey` / `ed25519.PublicKey` from `crypto/*` - `[]byte` for HMAC algorithms - a `jwk.Key` To validate against expected claim values, pass `jwt.WithIssuer(...)`, `jwt.WithAudience(...)`, `jwt.WithSubject(...)`, and `jwt.WithJwtID(...)`. Validation uses exact timestamps by default. Configure clock skew tolerance with `jwt.WithAcceptableSkew(30 * time.Second)`. ### Verifying with a JWK Set (kid-based key selection) ```go set, err := jwk.Parse(jwksBytes) if err != nil { return err } tok, err := jwt.Parse(raw, jwt.WithKeySet(set)) ``` If the JWS header has a `kid`, the matching key is selected from the set. The algorithm comes from each key's `alg` field. If a token omits `kid` and the set contains exactly one key, use `jwt.WithKeySet(set, jws.WithUseDefault(true))`. Use `jws.WithRequireKid(false)` only when every key in the set should be considered without matching `kid` values. **A key with no `alg` field is skipped, not guessed at.** Inference from the key type is opt-in via `jwt.WithKeySet(set, jws.WithInferAlgorithmFromKey(true))`, and it is a fallback, not a default. When the protected header has an `alg`, inference tries only that algorithm against compatible keys. When the header omits `alg`, inference tries every compatible algorithm; combined with `jws.WithRequireKid(false)`, verification can perform `N_keys × N_algs_per_keytype` attempts. Keep JWKS inputs bounded. The right fix is almost always to add `alg` to the keys in the JWKS. If verification against a JWKS finds no usable key, check for missing `alg` fields first. `jwk.Parse` retains an unparseable JWKS entry as a `jwk.UnsupportedKey` by default, so one unknown key type does not make the whole set fail. Use `jwk.IsUnsupportedKey` when inspecting entries. Pass `jwk.WithStrictKeySetParsing(true)` when every entry must parse or the whole operation must fail. ### Verifying via JWKS endpoint HTTP fetching is **not** in the core `jwk` package in v4. It lives in a companion module: ```go import "github.com/jwx-go/jwkfetch/v4" client := jwkfetch.NewClient() set, err := client.Fetch(ctx, "https://issuer.example/jwks.json") // then: jwt.Parse(raw, jwt.WithKeySet(set)) ``` For repeated fetches with background refresh, use `jwkfetch.NewCache`. For `jku`-driven verification, build a `jwkfetch.Client` with a `jwkfetch.NewMapWhitelist()` of allowed URLs and pass it to `jwt.WithVerifyAuto(client)`. ## Signing a JWT ```go tok, err := jwt.NewBuilder(). Issuer("https://issuer.example"). Audience([]string{"https://api.example"}). Subject("user-123"). IssuedAt(time.Now()). Expiration(time.Now().Add(15 * time.Minute)). Claim("scope", "read:things"). Build() if err != nil { return err } signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256(), privateKey)) ``` `privateKey` may be a `*rsa.PrivateKey`/`*ecdsa.PrivateKey`/`ed25519.PrivateKey`/HMAC `[]byte`, or a `jwk.Key`. Match algorithm to key type: | Family | Use when | |--------|----------| | `HS256` / `HS384` / `HS512` | Shared-secret (HMAC). Same secret signs and verifies. | | `RS256` / `RS384` / `RS512` | RSA, widest interop. | | `PS256` / `PS384` / `PS512` | RSA-PSS, prefer over `RS*` for new systems. | | `ES256` / `ES384` / `ES512` | ECDSA, smaller signatures than RSA. | | `Ed25519` (`jwa.EdDSAEd25519()`) | Ed25519, fastest verify; preferred for new systems where supported. | | `EdDSA` (`jwa.EdDSA()`) | The pre-RFC-9864 polymorphic identifier. **Deprecated.** Use it only to interoperate with a producer or consumer that still emits or expects `alg: EdDSA`. | | `none` | **Never.** jwx refuses by default. | ## JWK basics `jwk.Import` and `jwk.Export` require the type parameter. The parsers do not: `jwk.ParseKey` and `jwk.Parse` are non-generic, and `jwk.ParseKeyAs[T]` is the typed variant. ```go // Parse a single JWK (returns jwk.Key): key, err := jwk.ParseKey(jwkBytes) // Parse a single JWK with a concrete type (fails if not that type): rsaKey, err := jwk.ParseKeyAs[jwk.RSAPublicKey](jwkBytes) // Parse a JWK Set (returns jwk.Set, not generic): set, err := jwk.Parse(jwksBytes) // Wrap an existing crypto.* key as a jwk.Key: key, err := jwk.Import[jwk.Key](rsaPrivKey) // Or with a concrete jwk type: typed, err := jwk.Import[jwk.RSAPrivateKey](rsaPrivKey) // Export a jwk.Key back to a crypto.* key: raw, err := jwk.Export[*rsa.PublicKey](key) ``` `jwk.Import[jwk.Key]` is the default. Use a concrete type parameter (`jwk.RSAPrivateKey`, `jwk.ECDSAPublicKey`, `jwk.SymmetricKey`, `jwk.OKPPublicKey`, etc.) only when you want compile-time guarantees and acceptance of failure when the input is anything else. ### Generating keys ```go import ( "crypto/rand" "crypto/rsa" "github.com/lestrrat-go/jwx/v4/jwa" "github.com/lestrrat-go/jwx/v4/jwk" ) raw, _ := rsa.GenerateKey(rand.Reader, 2048) key, _ := jwk.Import[jwk.Key](raw) key.Set(jwk.KeyIDKey, "2025-q1") key.Set(jwk.AlgorithmKey, jwa.RS256()) pubKey, _ := jwk.PublicKeyOf(key) ``` `jwk.KeyIDKey` and `jwk.AlgorithmKey` are string constants for the standard JWK fields `kid` and `alg`. ## JWS (signing arbitrary payloads) ```go sig, err := jws.Sign(payload, jws.WithKey(jwa.ES256(), privateKey)) payload, err := jws.Verify(sig, jws.WithKey(jwa.ES256(), publicKey)) ``` `jws.Parse` only parses the structure — it does **not** verify. Use `jws.Verify` (which returns the verified payload) for verification. For RFC 7518 ECDSA signing or verification, pass `jws.WithStrictECDSA(true)` to reject ES256/P-256, ES384/P-384, and ES512/P-521 curve mismatches. The check is opt-in. Pass it through JWT signing as `jwt.WithSignOption(jws.WithStrictECDSA(true))`, or JWT parsing as `jwt.WithVerifyOption(jws.WithStrictECDSA(true))`. This also applies to keys selected from a JWKS. `jws.VerifyCompactFast` does not accept options; use `jws.Verify` for strict ECDSA verification. ### The protected `alg` must match the verifying algorithm exactly `jws.Verify` rejects a message whose protected header advertises one algorithm while it is verified under another. The comparison is plain string equality with no aliasing, and it applies to every key source (`jws.WithKey`, `jws.WithKeySet`, `jws.WithVerifyAuto`, custom `jws.WithKeyProvider`). The check only fires when the protected header actually carries an `alg`. The practical consequence involves EdDSA. Per RFC 9864, `EdDSA`, `Ed25519` and `Ed448` are three distinct `alg` values, so a token whose header says `alg: Ed25519` does **not** verify under `jws.WithKey(jwa.EdDSA(), key)`, and vice versa. Match the identifier the producer actually emitted. `jws.WithSkipAlgorithmMatch(true)` bypasses the check. It exists for interop with non-conforming producers, and it weakens a real safety guard, so treat it the way you treat `jws.WithRequireKid(false)`. ## JWE (encrypting payloads) ```go enc, err := jwe.Encrypt( payload, jwe.WithKey(jwa.RSA_OAEP_256(), recipientPublicKey), jwe.WithContentEncryption(jwa.A256GCM()), ) plain, err := jwe.Decrypt(enc, jwe.WithKey(jwa.RSA_OAEP_256(), recipientPrivateKey)) ``` `jwe.WithKey(alg, key)` is the same on both encrypt and decrypt sides — this symmetry is intentional. ### Nested JWT (sign, then encrypt) Use `jwt.NewSerializer()` to sign a token and then encrypt the result. The steps run in the order they are called, so `Sign(...)` followed by `Encrypt(...)` produces a JWE whose payload is the signed JWT. ```go nested, err := jwt.NewSerializer(). Sign(jwt.WithKey(jwa.RS256(), signerPrivateKey)). Encrypt( jwt.WithKey(jwa.RSA_OAEP_256(), recipientPublicKey), jwt.WithEncryptOption(jwe.WithContentEncryption(jwa.A256GCM())), ). Serialize(tok) ``` - Pass `jwe` options to the encrypt step with `jwt.WithEncryptOption`, and `jws` options to the sign step with `jwt.WithSignOption`. The content encryption defaults to `A256GCM` when `jwe.WithContentEncryption` is not given. - `jwt.Parse` does not decrypt. On the receiving side, decrypt the JWE first, then verify and validate the inner JWT: ```go signed, err := jwe.Decrypt(nested, jwe.WithKey(jwa.RSA_OAEP_256(), recipientPrivateKey)) if err != nil { return err } tok, err := jwt.Parse(signed, jwt.WithKey(jwa.RS256(), signerPublicKey)) ``` - `Serialize` returns the JWE in compact form as `[]byte`, and `jwe.Decrypt` returns the inner signed JWT as `[]byte`. - Decrypting only proves the message was encrypted to the recipient. The `jwt.Parse` step is what proves who signed the token, so NEVER skip it or replace it with `jwt.ParseInsecure` after decrypting. ## Companion modules Beyond the core `github.com/lestrrat-go/jwx/v4` module, the project ships companion modules under `github.com/jwx-go`. The agent should know **what's available and when to reach for each one** — depth lives in each module's godoc. Algorithm and HPKE modules register themselves in `init()` and panic at import time if registration fails. Import a module by name when calling its algorithm constructors, such as `es256k.ES256K()`. Use a blank import only when registration is the sole reason for the import. The one case that used to panic in normal use no longer does: see the ML-DSA note below. ### Signature algorithms (extension) | Module | What it enables | When to use | |--------|-----------------|-------------| | `github.com/jwx-go/mldsa/v4` | ML-DSA-44/65/87 (FIPS 204 post-quantum) | Forward-looking post-quantum signing. AKP key type, `"alg"` field required on keys. | | `github.com/jwx-go/ed448/v4` | `Ed448`, via `ed448.EdDSAEd448()` | When Ed25519 isn't strong enough or interop requires Ed448. | | `github.com/jwx-go/es256k/v4` | ES256K (secp256k1) | Web3/crypto ecosystem interop. Uses ECDSA with the secp256k1 curve. | | `github.com/jwx-go/compsig/v4` | ML-DSA composite signatures (PQ + classical) per draft-ietf-jose-pq-composite-sigs | **Experimental, draft-spec.** Hybrid signing during PQ transition. | ### Key agreement / encryption (extension) | Module | What it enables | When to use | |--------|-----------------|-------------| | `github.com/jwx-go/x448/v4` | X448 ECDH-ES, HPKE with DHKEM(X448), incl. HPKE-5-KE and HPKE-6-KE | Stronger ECDH than X25519 when required. | | `github.com/jwx-go/mlkem/v4` | ML-KEM-768/1024 (post-quantum KEM) per draft-ietf-jose-pqc-kem | **Experimental, draft-spec.** Post-quantum key encapsulation. | | `github.com/jwx-go/reddy-pqchpke/v4` | Hybrid PQ HPKE per draft-reddy-cose-jose-pqc-hybrid-hpke | **Highly experimental, pre-WG-adoption.** PQ + classical hybrid. | ### Tooling and backends | Module | What it does | When to use | |--------|--------------|-------------| | `github.com/jwx-go/jwkfetch/v4` | HTTP JWK Set retrieval — `Client` (one-shot) and `Cache` (background-refreshed, backed by `httprc`) | **Always**, whenever you fetch JWKS over HTTP. Core `jwk` has no HTTP fetch implementation; this is the entry point. | | `github.com/jwx-go/jwxfilter/v4` | Filter and introspection helpers for `jwt.Token`, `jws.Headers`, `jwe.Headers`, `jwk.Key`, and `openid.Token` | Selecting or redacting fields on a token, header, or key. Extracted from core in v4, so a user porting v3 filter code needs this module. | | `github.com/jwx-go/asmbase64/v4` | Assembly-optimized base64 backend (via `segmentio/asm`) | High-throughput JWS verify/decode paths where base64 is hot. Drop-in import. | | `github.com/jwx-go/jwxmigrate` | Machine-readable v3→v4 migration rules and automated checking | A user porting an app from jwx/v3 to jwx/v4. | | `github.com/jwx-go/examples` | Runnable usage patterns covering JWT/JWS/JWE/JWK/extensions; `README.md` is a topical index by package and sub-topic | Pointing the user at canonical example code — fetch the README first to find the right file by topic, then fetch the linked test file. Also importable via `go.work` in local development. | ### Cross-references - Canonical extension docs (always-current list, with examples): `https://github.com/lestrrat-go/jwx/blob/develop/v4/docs/10-extensions.md` - Each companion's godoc: `https://pkg.go.dev/github.com/jwx-go//v4` ### What to do when the user asks about post-quantum or non-default algorithms Default jwx supports the common RFC 7518 algorithms (RS*, PS*, ES*, HS*, EdDSA, A*GCM, RSA-OAEP-*, etc.) out of the box. Non-default algorithm modules must be added to `go.mod` and imported so their `init()` functions run. Use a named import when calling the module's algorithm constructors. A missing import commonly causes `algorithm not registered` errors for ES256K, Ed448, ML-DSA, ML-KEM, and X448. Tooling modules such as `jwkfetch`, `jwxfilter`, `jwxmigrate`, and `examples` are ordinary APIs or repositories, not algorithm registrars. ML-DSA is the one exception, and it depends on the toolchain. From Go 1.27 on, `crypto/mldsa` is in the standard library, so jwx registers `jwa.MLDSA44()`/`MLDSA65()`/`MLDSA87()` natively and no companion module or side-effect import is needed. On Go 1.26 the algorithms are not registered at all, and `github.com/jwx-go/mldsa/v4` is still required. Keeping the extension imported on Go 1.27 is harmless. From `jwx-go/mldsa` v4.0.5 on it detects jwx's native registration and bridges `filippo.io/mldsa` keys onto it instead of registering the algorithms a second time, so code mid-migration keeps working. Only versions before v4.0.5 panic at startup on that combination, because the duplicate registration is rejected. If a user hits that panic, tell them to upgrade the extension, not to drop the import. Canonical owner: `docs/10-extensions.md`, section "Which implementation you get". ## Errors Most JWT errors and selected JWS/JWE/JWK errors are struct types with named fields. Use `errors.Is` with a zero-value struct to test a struct error's kind, or Go 1.26's `errors.AsType[T]` to recover its fields. Other package-level errors remain sentinel functions such as `jws.VerificationError()`, `jwe.DecryptError()`, and `jwk.ParseError()`. ```go import "errors" tok, err := jwt.Parse(raw, jwt.WithKey(jwa.RS256(), key)) if err != nil { if errors.Is(err, jwt.TokenExpiredError{}) { // exp claim failed } if e, ok := errors.AsType[jwt.InvalidAudienceError](err); ok { log.Printf("audience mismatch: %+v", e) } } ``` Common types: - `jwt`: `TokenExpiredError`, `TokenNotYetValidError`, `InvalidIssuerError`, `InvalidAudienceError`, `MissingRequiredClaimError`, `ValidationError`, `ParseError` - `jws`: `VerificationError()` factory (signature verification failed) - `jwe`: `DecryptError()` factory, `AlgorithmMismatchError` - `jwk`: `KeyTypeMismatchError`, `ImportError()`, `ParseError()` Do not match errors by string contents — messages may change. ## Token field access Standard claims have dedicated typed accessors (returning `(value, ok)`): ```go exp, ok := tok.Expiration() // time.Time iss, ok := tok.Issuer() // string aud, ok := tok.Audience() // []string sub, ok := tok.Subject() // string nbf, ok := tok.NotBefore() iat, ok := tok.IssuedAt() jti, ok := tok.JwtID() ``` For private claims, use `tok.Field(name)` which returns `(any, bool)`. For type-safe access, use `jwt.Get[T](tok, name)`. ## Common mistakes to flag in user code When reviewing or writing jwx-using code, watch for these: 1. `jwt.Parse(data)` with no key option — errors out; if the intent was to read claims without verifying, that's a security bug unless the source is already trusted, in which case use `jwt.ParseInsecure`. 2. `jwa.RS256` instead of `jwa.RS256()` — these are functions in v4. 3. `jwk.Import(raw)` or `jwk.Export(key)` without the type parameter — won't compile. 4. `jwk.ParseKey[jwk.RSAPublicKey](data)` — `ParseKey` is not generic; the typed parser is `jwk.ParseKeyAs[T]`. 5. Type-asserting a `jwk.Key` to a `crypto.*` type. Use `jwk.Export[*rsa.PublicKey](key)` instead. 6. Hardcoding the alg from the JWS header (or token contents) to pick a verifier — always pin the expected algorithm on the verify side. 7. Reusing a single `jwt.Builder` across goroutines — builders aren't safe to share. 8. Verifying by passing a *private* key — works but leaks intent. Use the public key on the verify side. 9. Manually building JWS compact strings via concatenation — always go through `jws.Sign`/`jwt.Sign`. 10. `tok.Get("exp")` — the method is `tok.Field("exp")` (or just `tok.Expiration()`). 11. Disabling kid matching as a "make it work" shortcut. Understand why the kid doesn't match before adding `jws.WithRequireKid(false)`. ## What NOT to suggest - Disabling signature verification to "make it work." - Forking or vendoring jwx to add an algorithm — recommend the extension modules listed above. - Decoding the JWT envelope with `encoding/json` directly — always go through `jwt.Parse` / `jws.Verify`. - Recommending a different JWT library — out of scope for this skill.