CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project Overview

LibJWT is a C library implementing RFC 7515-7519 for JSON Web Tokens (JWT), JSON Web Keys (JWK), and JWK Sets (JWKS). It supports multiple cryptographic backends (OpenSSL, GnuTLS, MBedTLS) and JSON backends (Jansson, json-c).

Issue Workflow

Follow this pipeline for every piece of work:

  1. Issue — Work must map to a GitHub issue. If none exists, create one first describing the task.
  2. Branchgit checkout -b <issue#>-<short-summary> (e.g. 157-crit-header). Never work on master.
  3. Plan — Plan the implementation, then post the plan as a comment on the issue.
  4. Implement — Do the work, including tests for all new code (see Testing) and coverage (no new uncovered lines). Verify under both JSON backends when JSON handling is touched.
  5. PR — Open a PR (closes #<issue>), then watch CI to completion (gh pr checks <#> --watch or the GitHub check-runs API) and fix any failures.

Commit messages: git commit -s (Signed-off-by). Don't commit/push unless asked.

Release Process

A release is a distinct workflow from the per-issue pipeline above: it needs no issue (the release PR is the artifact). Mirror the most recent release commit/PR/tag (git show v3.5.0, PR #304) when in doubt.

  1. Version + SONAMEcmake/LibJWTVersions.cmake is the single source of truth (there is no NEWS/changelog file). Bump LIBJWT_VERSION_SET (e.g. 3 4 0 -> 3 5 0) and the libtool SONAME triple LIBJWT_SO_CRA (current:revision:age). The triple is symbol-counted, not feature-counted: per the libtool rules, build the library at the prior tag and at HEAD, nm each shared library, and diff the exported jwt*/jwk*/jwe* symbol set.

    • No symbols added/removed/changed (only source changed) -> revision++ (current/age untouched). New jwt_alg_t/jwk_key_type_t enumerators and #cmakedefine macros are not linker symbols, so an enum-only addition is still revision-only — v3.5.0 added JWT_ALG_ML_DSA_* / JWK_KEY_TYPE_AKP + LIBJWT_HAVE_ML_DSA yet went 17:0:3 -> 17:1:3.
    • Symbols added, none removed/changed -> current++, revision=0, age++ (v3.4.0: 38 new JWE/JWK symbols, 16:7:2 -> 17:0:3).
    • Any symbol removed or changed -> current++, revision=0, age=0 (SONAME-major break). SONAME major = current - age, and has stayed 14 across all of 3.x.

    The same symbol/enum/macro diff also drives the Doxygen @since tags in include/jwt.h — maintain them in this same review (the baseline @since 3.0.0..3.5.0 pass was PR #305). Anything new at the release — a function, typedef/enum/struct, enum value, documented macro, or *_freep/*_auto_t helper — must carry @since X.Y.Z for the version being released. Note the asymmetry with the triple: new enum values and #cmakedefine macros are not linker symbols (so they don‘t move current/age) but they still need @since — e.g. v3.5.0’s JWT_ALG_ML_DSA_* / JWK_KEY_TYPE_AKP are @since 3.5.0 despite the revision-only bump.

  2. Branch + commitrelease-X.Y.Z off master; edit only LibJWTVersions.cmake (plus any now-stale doc note, as v3.4.0 dropped the “v3 overhaul” warning). git commit -s titled Release vX.Y.Z with a body documenting the SONAME-bump rationale. Smoke-test locally (make check).

  3. PR + mergegh pr create --base master --title "Release vX.Y.Z" (no closes); watch CI to green, then gh pr merge --merge --delete-branch (merge commit; release branches are deleted after merge).

  4. Tag — annotated, on the merge commit, message = the bare version: git tag -a vX.Y.Z -m "vX.Y.Z" <merge-commit> && git push origin vX.Y.Z (tagger is git config user.* = Ben Collins <bcollins@libjwt.io>).

  5. Source tarball — built manually, NOT via CPack (despite cmake/CPackConfig.cmake). Recipe: every git-tracked file at the tag minus .github/ and .gitignore (the only “repo-related artifacts” stripped; .git/ isn't tracked), rooted under libjwt-X.Y.Z/. CLAUDE.md and images/ ARE included.

    git archive --format=tar --prefix=libjwt-X.Y.Z/ vX.Y.Z \
      -- . ':(exclude).github' ':(exclude).gitignore' | xz -9 -c > libjwt-X.Y.Z.tar.xz
    
  6. GitHub Release — one published Release per version, marked Latest, with the tarball attached: gh release create vX.Y.Z --title "vX.Y.Z" --notes-file NOTES --latest --verify-tag libjwt-X.Y.Z.tar.xz. Notes use emoji section headers and end with **Full Changelog**: https://github.com/benmcollins/libjwt/compare/vPREV...vX.Y.Z.

Tagging and publishing the GitHub Release are the maintainer's call and require CI green first.

Build Commands

# Standard build
mkdir build && cd build
cmake ..
make

# Build with options
cmake -DWITH_GNUTLS=ON -DWITH_MBEDTLS=ON -DWITH_LIBCURL=ON -DWITH_JSON_C=ON ..

# Switching backends in an existing build/ resets unspecified flags (e.g.
# ENABLE_COVERAGE -> OFF). Re-pass all flags and `make clean` after switching,
# since stale .gcno/.gcda from the prior backend linger.

# Only `/build/**` is gitignored. Name throwaway build trees `build/...` or
# remove them before `git add -A` — a stray `build-foo/` will otherwise be
# staged and committed.

# Run all tests
make check

# Run a single test (from build directory)
ctest -R jwt_builder -V

# Run tests with valgrind memory checking
ctest -T memcheck

# Code coverage (must configure with ENABLE_COVERAGE)
cmake -DENABLE_COVERAGE=YES ..
make check-code-coverage

All new code MUST be covered by tests cases. Do not commit ANY code
changes without running coverage first, and ensure there are no new
missing lines covered.

# NOTE: `make check-code-coverage` always exits non-zero — its final genhtml
# step fails ("no valid records"). The lcov capture still succeeds; read
# per-line coverage from build/check-code-coverage.capture (lcov DA: records).
# In that file, jwt-common.c appears ~4x (builder/checker x lib/test-harness);
# the real library coverage is the jwt.dir record, not the per-filename union.
#
# Code that is #ifdef'd out (e.g. an off-by-default feature) is invisible to
# gcov, so it never counts as uncovered. Enabling such a feature in a
# coverage/Codecov job makes its lines visible — only do that if a test
# actually exercises them, or codecov/patch will drop. Genuinely unreachable
# defensive branches use // LCOV_EXCL_LINE or // LCOV_EXCL_START/STOP.

# Build documentation (requires Doxygen >= 1.9.8)
# Docs are built automatically if Doxygen is found

Doc encoding. Doxygen processes include/jwt.h (public-header doc comments) and doxygen/mainpage.dox. DOXYGEN_INPUT_ENCODING in cmake/LibJWTDoxyfile.cmake is UTF-8 to match the UTF-8 sources, so non-ASCII (em-dashes, curly quotes, symbols) renders correctly in the generated HTML. It was previously ISO-8859-1, which decoded the UTF-8 bytes as Latin-1 and produced mojibake (a UTF-8 em-dash showed up as an a-prefixed box-character blob); do not revert it.

Key CMake Options

OptionDefaultDescription
WITH_JSON_COFFUse json-c instead of Jansson
WITH_OPENSSLONEnable OpenSSL backend
WITH_GNUTLSauto-detectEnable GnuTLS backend
WITH_MBEDTLSOFFEnable MBedTLS backend
WITH_LIBCURLOFFEnable libcurl for remote JWKS
WITH_TESTSONBuild test suite
WITH_KCAPI_MDOFFLinux Kernel Crypto API for HMAC
ENABLE_COVERAGEOFFLCOV code coverage
EXCLUDE_DEPRECATEDOFFExclude deprecated API
WITH_ML_DSAOFFExperimental ML-DSA (FIPS 204/RFC 9964); needs OpenSSL >= 3.5 or GnuTLS >= 3.8.10

Architecture

Abstraction Layers

The library has two key abstraction interfaces that allow swapping implementations:

  1. Crypto backend abstraction (jwt-crypto-ops.c, jwt-private.h:jwt_crypto_ops): Each crypto provider (OpenSSL, GnuTLS, MBedTLS) implements the jwt_crypto_ops struct with function pointers for sign/verify, JWK parsing (process_*), and native-key→JWK conversion (key2jwk_params, assembled by the common jwk-export.c). All three backends are optional and interchangeable; the build requires at least one but works with any combination (default WITH_OPENSSL=ON). A GnuTLS-only build (no OpenSSL) requires GnuTLS >= 3.8.4, since older GnuTLS has no native JWK/JWE path and falls back to OpenSSL. The MbedTLS backend targets the MbedTLS 3.6.x LTS API (mbedcrypto>=3.6.0); MbedTLS 4.x will not build — its PSA rewrite drops the legacy mbedtls_pk_* API the backend uses — so pin 3.6.x (the CI image builds it from source for this reason). The active provider is selected at runtime via jwt_set_crypto_ops(); the default is the first compiled backend (OpenSSL > GnuTLS > MBedTLS).

  2. JSON backend abstraction (jwt-json-ops.h): Jansson and json-c each implement the same JSON operations interface. Selected at compile time via WITH_JSON_C.

    • json-c asserts (aborts) where Jansson returns gracefully: e.g. json_object_array_length()/_get_idx() abort on a non-array (Jansson returns 0/NULL). Type-check in the json-c wrapper. Test any JSON handling under BOTH backends.
    • jwt_json_obj_set/jwt_json_arr_append STEAL the value reference in both backends — never free what you set/append.

Experimental & version-gated features (ML-DSA)

ML-DSA (FIPS 204 / RFC 9964 post-quantum signatures ML-DSA-44/65/87, JWK kty="AKP") is the template for an optional, off-by-default, version-gated feature:

  • Gated behind WITH_ML_DSA (default OFF). CMake sets LIBJWT_HAVE_ML_DSA only when a capable backend is present (OpenSSL >= 3.5 or GnuTLS >= 3.8.10). That macro is emitted into the public jwt_export.h via #cmakedefine in include/jwt_export.h.in, so the same macro gates library and downstream code.
  • The jwt_alg_t / jwk_key_type_t enum values exist unconditionally (ABI stability); only recognition (jwt_str_alg), dispatch, the jwt_alg_required_kty anti-confusion gate, and backend code are gated. The CLI tools' -l loops skip algs whose jwt_alg_str() returns NULL so a compiled-out alg isn't listed.
  • Each backend's ML-DSA code carries its OWN version guard in addition to the macro — #if defined(LIBJWT_HAVE_ML_DSA) && OPENSSL_VERSION_NUMBER >= 0x30500000L (OpenSSL) / && GNUTLS_VERSION_NUMBER >= 0x03080a (GnuTLS) — so a multi-backend build with one capable and one too-old backend still compiles.
  • AKP keys: pub = encoded public key, priv = the 32-byte FIPS-204 seed (not the expanded key), alg REQUIRED, no crv. The variant is pinned to the key at sign time to prevent algorithm confusion. A private key with no seed (expanded-only) cannot be a private AKP JWK; both backends downgrade it to a public export rather than emit a broken key.
  • GnuTLS needs a build with a PQC provider (--with-leancrypto); a stock GnuTLS returns -106 at runtime. There is no raw ML-DSA import, so gnutls/jwk-parse.c hand-builds SubjectPublicKeyInfo / seed-PKCS#8 DER and imports that; gnutls_x509_privkey_export2_pkcs8(..., GNUTLS_PKCS_MLDSA_SEED, ...) SEGFAULTS on a seedless key, so export probes for the seed via a plain PKCS#8 export first.

Dynamic (runtime) version gating. When the defect is in the shared crypto library rather than libjwt, gate on the RUNTIME version so a library upgrade fixes it without rebuilding libjwt. Use a hybrid: a build-time #if GNUTLS_VERSION_NUMBER < 0xMMmmpp (keeps the code absent — and coverage-invisible — when built against a fixed version) wrapping a runtime gnutls_check_version("M.m.p") check that decides per call. gnutls/jwk-parse.c (gnutls_process_eddsa) rejects two GnuTLS < 3.8.13 OKP defects this way: (1) gnutls_privkey_import_ecc_raw() SEGFAULTS deriving the public key for a seed-only OKP private JWK — d present, no x — for any curve (Ed25519/Ed448/X25519/X448); (2) X25519/X448 ECDH-ES derive yields nothing. Keys that carry x, all public keys, and the PEM/DER path (whose JWK export always includes x) are unaffected, as are OpenSSL and MbedTLS. A library must error, not crash, on a key it can't handle.

Source Organization

  • libjwt/ — Core library source
    • jwt.c — Main JWT creation, parsing, and lifecycle
    • jwt-common.c — Shared builder/checker logic (compiled twice with -DJWT_BUILDER and -DJWT_CHECKER preprocessor flags via custom CMake commands)
    • jwt-setget.c — Claims get/set operations using jwt_value_t
    • jwt-verify.c — JWT signature verification
    • jwt-encode.c — JWT encoding/signing
    • jwks.c — JWK/JWKS parsing and management
    • jwt-private.h — Internal structures (jwt, jwk_set, jwk_item, jwt_crypto_ops)
    • openssl/, gnutls/, mbedtls/ — Crypto backend implementations
    • jansson/, json-c/ — JSON backend implementations
  • include/jwt.h — Complete public API (the only public header)
  • tools/ — CLI utilities: jwt-generate, jwt-verify, jwk2key, key2jwk
  • tests/ — Check framework unit tests and BATS integration tests

Key Patterns

  • Memory management: Custom allocators (jwt_malloc, __jwt_freemem) with GCC __attribute__((cleanup())) auto-cleanup via jwt_auto_t, jwt_builder_auto_t, jwt_checker_auto_t, jwt_json_auto_t typedefs.
  • Error handling: Objects carry error flag and error_msg[256] buffer. Use jwt_write_error() macro for consistent error reporting.
  • Builder/Checker pattern: jwt_builder_t creates tokens, jwt_checker_t verifies them. Both share jwt_common struct internals. Support callback-based claim generation/verification.
  • Linked list: Internal ll.h provides a doubly-linked list for JWK sets.

Testing

Tests use the Check C unit testing framework. Each test file defines a libjwt_suite() function and uses the JWT_TEST_MAIN() macro.

  • Tests iterate over all compiled crypto providers using the jwt_test_ops[] array and SET_OPS() macro via Check's loop tests (_i index variable).
  • Test keys are in tests/keys/ — referenced via the KEYDIR compile-time macro.
  • A constant timestamp TS_CONST (1475980545L) is used for reproducible time-based tests.
  • BATS tests in tests/jwt-cli.bats cover the CLI tools.
  • For features that depend on the runtime (not just compile-time) capability of a backend, probe at runtime and skip incapable backends rather than hardcoding which provider supports what — e.g. mldsa_supported() in jwt_mldsa.c loads a known AKP key under the active ops, so the success tests run on OpenSSL and a PQC-enabled GnuTLS and skip a GnuTLS without leancrypto (and MbedTLS). gnutls_okp_jwk_broken() in jwt_tests.h is a second example — it mirrors the library‘s hybrid build/runtime gate (gnutls_check_version("3.8.13")) to skip the OKP keys an old GnuTLS can’t load, so the suite passes on GnuTLS 3.8.9 and 3.8.13 alike.

Continuous Integration

.github/workflows/build-and-test.yml. Codecov is informational only (never fails CI), and the in-CI coverage step runs check-code-coverage under || true and just uploads the .info — it does not fail on a coverage regression. The “no new uncovered lines in libjwt/+tools/” rule is a developer discipline you enforce locally before committing (see Build Commands / Issue Workflow), not an automatic CI gate. Still, do NOT add compiled-but-unexercised code to a coverage job (see the gcov/#ifdef note under Build Commands) — it lowers codecov/patch.

  • CI base imagebuild-linux-combos runs inside a custom image, ghcr.io/benmcollins/libjwt/gnutls-leancrypto-mbedtls, built from .github/docker/Dockerfile and pushed by .github/workflows/ci-image.yml (GHCR via the built-in GITHUB_TOKEN; the package must be public so container: jobs and fork PRs can pull it). It is debian:forky with GnuTLS built --with-leancrypto (so the ML-DSA and Ed448/X25519/X448 success paths are real — forky's apt GnuTLS lacks both) and the latest MbedTLS 3.6.x LTS, both from source; everything else (and every build-dep) is native Debian apt. See .github/docker/README.md. Respin by bumping the Dockerfile ARG pins and re-running the workflow. A CI change that references the image must land only after the image is pushed and made public.
  • build-linux-combos runs the seven backend combinations in that image. The renamed all row (all three backends + WITH_ML_DSA=ON) is the only row that collects coverage → Codecov and runs ctest -T memcheck; on the leancrypto image its ML-DSA/Ed448/X-curve lines are exercised, not just compiled. The other rows just build + ctest.
  • build-linux is a vendor-compatibility matrix: Ubuntu 22.04/24.04/26.04 and Debian stable/oldstable, each built with both JSON backends (jansson + json-c) — OpenSSL only. GnuTLS is OFF on every row: the GnuTLS these distros ship is either below the gnutls>=3.8.8 CMake floor or <= 3.8.12 (which hits the GnuTLS < 3.8.13 OKP defects — see the version-gated note), so none can fully exercise it. json-c is excluded on Ubuntu 22.04 (jammy ships json-c 0.15 < the 0.16 floor). Debian has no hosted runner, so stable/oldstable run as container:; ubuntu-26.04 is a preview runner (continue-on-error).
  • build-linux-mbedtls and build-linux-json-c were removed — folded into the all row and the compat matrix respectively.
  • For same-repo PRs the workflow runs from the PR branch‘s copy of the YAML; for forks it uses the base branch’s. paths-ignore skips runs whose changes are entirely docs/.github/images — so a .github-only push won't trigger it (use workflow_dispatch).

Compiler Flags

The project compiles with -Wall -Werror -Wextra -Wunused. All warnings are errors.