Merge pull request #336 from benmcollins/317-app-profiles

jwt: application profiles + the primitives they need (#317)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 07c969d..7bd3a6f 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -430,6 +430,9 @@
 	# Cached remote JWKS source (TTL/ETag/cooldown)
 	list (APPEND UNIT_TESTS jwt_jwks_cache)
 
+	# Application profiles (#317): the small primitives + worked recipes
+	list (APPEND UNIT_TESTS jwt_hash jwt_require jwt_embedded_jwk jwt_profiles)
+
 	# ML-DSA (FIPS 204 / RFC 9964). The test is a no-op unless the build
 	# enabled WITH_ML_DSA against a capable backend.
 	list (APPEND UNIT_TESTS jwt_mldsa)
diff --git a/README.md b/README.md
index 0bd0fc4..3f095c5 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,8 @@
 ``cnf``  | :page_facing_up: [RFC-7800](https://datatracker.ietf.org/doc/html/rfc7800) | Proof-of-Possession (confirmation) claim helpers
 ``Unencoded Payload`` | :page_facing_up: [RFC-7797](https://datatracker.ietf.org/doc/html/rfc7797) | JWS unencoded (``b64=false``) and detached payloads
 ``BCP 225`` | :page_facing_up: [RFC-8725](https://datatracker.ietf.org/doc/html/rfc8725) | JWT Best Current Practices (``typ`` check, algorithm allowlist)
+``DPoP`` | :page_facing_up: [RFC-9449](https://datatracker.ietf.org/doc/html/rfc9449) | Proof-of-possession: embedded-JWK verify + ``ath`` token hash
+``Application Profiles`` | :page_facing_up: [RFC-9068](https://datatracker.ietf.org/doc/html/rfc9068) | ``at+jwt``, VAPID, PASSporT, OpenID4VCI, DPoP, mTLS, JAdES recipes
 
 > [!NOTE]
 > Throughout this documentation you will see links such as the ones
@@ -152,6 +154,19 @@
 so random `kid` values cannot amplify into a request flood. Only `http`/`https`
 URLs are accepted (an SSRF guard). Requires the `WITH_LIBCURL` build.
 
+#### Application Profiles
+
+Most real-world JWT specs are *application profiles* — an ordinary signed JWT
+with a particular `typ`, required claims, and key binding — not new crypto. The
+small primitives that complete them are: `jwt_checker_require()` (assert claims
+are present, RFC 9068), `jwt_checker_enable_embedded_jwk()` (verify a
+self-contained token against the header `jwk`, confirmed by thumbprint — DPoP and
+OpenID4VCI), and `jwt_token_hash()` / `jwt_token_hash_half()` (DPoP `ath`, OIDC
+`at_hash`/`c_hash`). The **Application Profiles** page of the docs has worked
+build-and-verify recipes for `at+jwt` (RFC 9068), VAPID, PASSporT, OpenID4VCI,
+DPoP (RFC 9449), mTLS (RFC 8705), and JAdES, each mirrored by a test in
+`tests/jwt_profiles.c`.
+
 #### JWE
 
 LibJWT supports JWE (RFC 7516) in both the Compact Serialization and the JSON
diff --git a/doxygen/mainpage.dox b/doxygen/mainpage.dox
index c2e99f7..6d296de 100644
--- a/doxygen/mainpage.dox
+++ b/doxygen/mainpage.dox
@@ -93,6 +93,10 @@
 
 @section docs \emoji :open_book: Docs and Source
 
+Guides: @ref examples "Usage Examples" walks the API from the simplest token up;
+@ref profiles "Application Profiles" composes the primitives into real-world
+recipes (at+jwt, VAPID, PASSporT, OpenID4VCI, DPoP, mTLS, JAdES).
+
 \emoji :link: [Current Docs](https://libjwt.io)
 
 \emoji :link: [Legacy Docs v2.1.1](https://libjwt.io/stable)
diff --git a/doxygen/profiles.dox b/doxygen/profiles.dox
new file mode 100644
index 0000000..cea50e8
--- /dev/null
+++ b/doxygen/profiles.dox
@@ -0,0 +1,187 @@
+@page profiles \emoji :id: Application Profiles
+
+Many real-world specifications are not new cryptography — they are **application
+profiles**: an ordinary signed JWT with a particular media type (@c "typ"), a
+required set of claims, and a key-binding convention. LibJWT's job is to expose
+the primitives; this page shows how to compose them into each profile. Every
+recipe below builds @em and verifies a token with the public API only, and is
+mirrored by a test in @c tests/jwt_profiles.c.
+
+The reusable pieces are:
+
+Primitive | Function(s) | Used by
+:-------- | :---------- | :------
+Media-type pinning | @ref jwt_checker_expect_typ, @ref jwt_builder_settyp | all `typ`-bearing profiles
+Algorithm allowlist | @ref jwt_checker_setalgs | all (blocks `alg` confusion)
+Required claims | @ref jwt_checker_require | at+jwt, VAPID, PASSporT
+Confirmation (`cnf`) | @ref jwt_builder_setcnf_jkt, @ref jwt_builder_setcnf, @ref jwt_get_cnf | DPoP, mTLS
+Embedded-key verify | @ref jwt_checker_enable_embedded_jwk | DPoP, OpenID4VCI
+Token hash | @ref jwt_token_hash | DPoP `ath`
+JWK Thumbprint | @ref jwks_item_thumbprint | DPoP, OpenID4VCI
+X.509 (`x5c`/`x5t#S256`) | @ref jwks_item_x5c, @ref jwks_item_x5t_s256 | PASSporT, JAdES, mTLS
+Detached payload | @ref jwt_builder_set_detached, @ref jwt_checker_verify_detached | JAdES
+
+@note These profiles layer on a signed JWT only. LibJWT is offline: it does not
+implement the HTTP/TLS exchanges of DPoP, mTLS, or OpenID4VCI — only the JWT
+touchpoints. Pair these with the BCP 240 / RFC 8725 hardening guidance.
+
+@tableofcontents
+
+@section prof_atjwt \emoji :ticket: OAuth 2.0 access tokens — `at+jwt` (RFC 9068)
+
+@rfc{9068} profiles a JWT access token: the header @c "typ" is @c "at+jwt" and a
+fixed set of claims (@c iss, @c exp, @c aud, @c sub, @c client_id, @c iat, @c jti)
+is **mandatory**. Pin the type and algorithm, and use @ref jwt_checker_require to
+assert the mandatory claims are present (LibJWT otherwise validates only the
+claims you ask it to compare).
+
+@code{.c}
+/* Issue */
+jwt_builder_settyp(builder, "at+jwt");
+jwt_builder_setkey(builder, JWT_ALG_ES256, signing_key);
+/* ...set iss/sub/aud/client_id/jti and an exp offset... */
+
+/* Verify */
+const char *must[] = { "iss","sub","aud","exp","iat","jti","client_id" };
+const jwt_alg_t algs[] = { JWT_ALG_ES256 };
+
+jwt_checker_expect_typ(checker, "at+jwt");
+jwt_checker_setalgs(checker, algs, 1);
+jwt_checker_require(checker, must, 7);
+jwt_checker_claim_set(checker, JWT_CLAIM_AUD, "https://rs.example");
+jwt_checker_setkey(checker, JWT_ALG_ES256, as_key);
+if (jwt_checker_verify(checker, token) == 0)
+    /* a well-formed RFC 9068 access token */;
+@endcode
+
+@section prof_vapid \emoji :inbox_tray: Web Push — VAPID (RFC 8292)
+
+@rfc{8292} (VAPID) is the most widely deployed plain JWS-over-P-256 profile on
+the web: an @c ES256 token whose @c aud is the push service origin, @c sub a
+contact URI, and @c exp at most 24 hours out. There is no special @c "typ"; the
+discipline is the fixed algorithm and the required claims.
+
+@code{.c}
+const char *must[] = { "aud", "exp", "sub" };
+const jwt_alg_t es256[] = { JWT_ALG_ES256 };
+
+jwt_checker_setalgs(checker, es256, 1);    /* ES256 only */
+jwt_checker_require(checker, must, 3);
+jwt_checker_setkey(checker, JWT_ALG_ES256, app_server_pubkey);
+jwt_checker_verify(checker, token);
+@endcode
+
+@section prof_passport \emoji :telephone_receiver: Caller ID — PASSporT (RFC 8225)
+
+@rfc{8225} PASSporT (the token behind STIR/SHAKEN) signs caller-identity claims
+(@c orig, @c dest, @c iat, @c attest) with @c "typ":"passport", typically ES256,
+and carries the signing certificate via @c "x5u" or @c "x5c". Validate the type,
+algorithm, and required claims here; read the certificate chain with
+@ref jwks_item_x5c (chain/trust validation against the SHAKEN CA is the caller's
+PKI policy — see @ref prof_jades for reading `x5c`).
+
+@code{.c}
+const char *must[] = { "iat", "orig", "dest" };
+const jwt_alg_t es256[] = { JWT_ALG_ES256 };
+
+jwt_checker_expect_typ(checker, "passport");
+jwt_checker_setalgs(checker, es256, 1);
+jwt_checker_require(checker, must, 3);
+jwt_checker_setkey(checker, JWT_ALG_ES256, shaken_leaf_key);
+jwt_checker_verify(checker, token);
+@endcode
+
+@section prof_oid4vci \emoji :credit_card: OpenID4VCI key proof
+
+An OpenID4VCI key proof (`"typ":"openid4vci-proof+jwt"`) is **self-contained**:
+the signing key travels in the protected-header @c "jwk" (@rfc{7515,4.1.3}) and
+the issuer binds the credential to that key. Because the header key is supplied
+by the presenter, it must be confirmed — here against the holder-key thumbprint
+the issuer recorded for the request — with @ref jwt_checker_enable_embedded_jwk,
+which refuses to trust an embedded key without a pin.
+
+@code{.c}
+/* jkt is the thumbprint the issuer bound the credential request to. */
+char *jkt = jwks_item_thumbprint(expected_holder_key, JWK_THUMBPRINT_SHA256);
+const jwt_alg_t algs[] = { JWT_ALG_ES256 };
+
+jwt_checker_expect_typ(checker, "openid4vci-proof+jwt");
+jwt_checker_setalgs(checker, algs, 1);
+jwt_checker_enable_embedded_jwk(checker, JWK_THUMBPRINT_SHA256, jkt);
+jwt_checker_verify(checker, proof);   /* key is the confirmed header "jwk" */
+free(jkt);
+@endcode
+
+@section prof_dpop \emoji :hand: Proof-of-possession — DPoP (RFC 9449)
+
+@rfc{9449} DPoP binds an access token to a client key. The access token carries a
+@c cnf.jkt (set with @ref jwt_builder_setcnf_jkt); each request is accompanied by
+a @c "dpop+jwt" proof that carries the client's public key in its header @c "jwk"
+and an @c "ath" claim hashing the access token. To verify a proof: pin the
+embedded key to the access token's @c cnf.jkt, then check that @c ath matches
+@ref jwt_token_hash of the presented access token (and that @c htm/@c htu match
+the request).
+
+@code{.c}
+/* The access token was bound to the client key: */
+jwt_builder_setcnf_jkt(at_builder, client_key);   /* -> cnf.jkt */
+
+/* The proof carries the client key in its header and hashes the AT: */
+char *ath = jwt_token_hash(access_token, JWK_THUMBPRINT_SHA256);
+/* ...set proof header "jwk", claims htm/htu/jti/ath, sign with client_key... */
+
+/* Verify the proof at the resource server: */
+char *jkt = jwt_get_cnf(verified_access_token, "jkt");   /* the binding */
+jwt_checker_expect_typ(checker, "dpop+jwt");
+jwt_checker_enable_embedded_jwk(checker, JWK_THUMBPRINT_SHA256, jkt);
+jwt_checker_verify(checker, proof);
+/* In a jwt_checker_setcb() callback, compare the proof's "ath" to
+ * jwt_token_hash(access_token, JWK_THUMBPRINT_SHA256) and check htm/htu. */
+free(ath); free(jkt);
+@endcode
+
+@note The confirmation is not a shortcut around the signature: even when the
+embedded key's thumbprint matches the pin, the proof's signature must still
+verify against that key, so a proof that embeds the victim's key but is signed by
+another is rejected.
+
+@section prof_mtls \emoji :lock_with_ink_pen: mTLS-bound tokens (RFC 8705)
+
+@rfc{8705} binds an access token to a client TLS certificate via a @c cnf
+@c "x5t#S256" member (the certificate's SHA-256 thumbprint). Set it with
+@ref jwt_builder_setcnf; at the resource server, read it with @ref jwt_get_cnf
+and compare against the thumbprint of the certificate presented in the TLS
+handshake (which your TLS terminator provides).
+
+@code{.c}
+/* Issue: bind to the client certificate thumbprint. */
+jwt_builder_setcnf(builder, "x5t#S256", client_cert_sha256_b64url);
+
+/* Verify (inside a jwt_checker_setcb() callback): */
+char *bound = jwt_get_cnf(jwt, "x5t#S256");
+if (bound && !strcmp(bound, presented_cert_thumbprint))
+    /* the token is being used over the bound mTLS connection */;
+free(bound);
+@endcode
+
+@section prof_jades \emoji :pen: Detached signatures — JAdES (ETSI 119 182-1)
+
+JAdES (the eIDAS JWS profile) commonly signs a **detached** document — the
+payload is conveyed out of band, not inside the token — with the signing
+certificate chain in the @c "x5c" header. Sign opaque bytes unencoded
+(@rfc{7797}) and detached, then verify with @ref jwt_checker_verify_detached,
+supplying the document. Read the chain with @ref jwks_item_x5c (parsed from a
+JWK's @c x5c); certificate-path validation is the caller's PKI policy.
+
+@code{.c}
+/* Sign a detached, unencoded document with the cert chain in x5c. */
+jwt_builder_setpayload(builder, document, document_len);
+jwt_builder_setb64(builder, 0);          /* opaque bytes, not JSON claims */
+jwt_builder_set_detached(builder, 1);    /* payload conveyed out of band */
+/* ...set the "x5c" header to the certificate chain array... */
+token = jwt_builder_generate(builder);
+
+/* Verify with the document supplied out of band. */
+jwt_checker_setkey(checker, JWT_ALG_ES256, signer_key);
+jwt_checker_verify_detached(checker, token, document, document_len);
+@endcode
diff --git a/include/jwt.h b/include/jwt.h
index 23c283a..7a2562c 100644
--- a/include/jwt.h
+++ b/include/jwt.h
@@ -12,6 +12,7 @@
  *
  * @include{doc} mainpage.dox
  * @include{doc} examples.dox
+ * @include{doc} profiles.dox
  */
 
 #ifndef JWT_H
@@ -90,6 +91,23 @@
 } jwt_alg_t;
 
 /** @ingroup jwt_alg_grp
+ * @brief Hash algorithm for a JWK Thumbprint and related digests
+ *
+ * Selects the digest used by jwks_item_thumbprint() /
+ * jwks_item_thumbprint_uri(), the embedded-JWK pin
+ * (jwt_checker_enable_embedded_jwk()), and jwt_token_hash(). SHA-256 is the
+ * value 0, so it is the default for a zero-initialized argument and is what
+ * virtually all deployments use.
+ *
+ * @since 3.6.0
+ */
+typedef enum {
+	JWK_THUMBPRINT_SHA256 = 0,	/**< SHA-256 (default) */
+	JWK_THUMBPRINT_SHA384,		/**< SHA-384 */
+	JWK_THUMBPRINT_SHA512,		/**< SHA-512 */
+} jwk_thumbprint_alg_t;
+
+/** @ingroup jwt_alg_grp
  * @brief JWE key management algorithm types
  *
  * These are the supported JWE ``"alg"`` (key management) algorithm types for
@@ -1044,6 +1062,85 @@
 int jwt_checker_setalgs(jwt_checker_t *checker, const jwt_alg_t *algs, size_t n);
 
 /**
+ * @brief Require that a set of claims is present
+ *
+ * @rfc{9068,4}
+ *
+ * When set, jwt_checker_verify() rejects a token that is missing any of the
+ * named claims, independent of any value match (a value check is a separate
+ * concern -- see jwt_checker_claim_set()). LibJWT otherwise validates only the
+ * claims it is told to compare, and silently tolerates an absent one; this
+ * asserts the mandatory-claims discipline that profiles such as RFC 9068
+ * (@c at+jwt) require -- e.g. @c {"iss","exp","aud","sub","client_id","jti"}.
+ * The names are copied. Passing @p count as 0 (or @p claims as NULL) clears the
+ * requirement.
+ *
+ * @param checker Pointer to a checker object
+ * @param claims An array of claim names that must be present (copied)
+ * @param count The number of names in @p claims
+ * @return 0 on success, non-zero otherwise with error set in the checker
+ * @since 3.6.0
+ */
+JWT_EXPORT
+int jwt_checker_require(jwt_checker_t *checker, const char **claims,
+			unsigned int count);
+
+/**
+ * @brief Verify using the key embedded in the header, pinned by thumbprint
+ *
+ * @rfc{7515,4.1.3} @rfc{9449}
+ *
+ * Enables taking the verification key from the token's protected-header @c "jwk"
+ * (a self-contained token, as used by DPoP and OpenID4VCI key proofs). Because
+ * that key is supplied by whoever made the token, it is **never trusted on its
+ * own**: it is accepted only if its JWK Thumbprint (@p alg) equals @p
+ * expected_jkt -- the value you obtained out of band and are pinning to, such as
+ * the @c cnf.jkt of a presented access token (read with jwt_get_cnf()). The
+ * confirmed key is still held to the usual key-type/algorithm binding. Combine
+ * with jwt_checker_setalgs() to bound the acceptable algorithms.
+ *
+ * Passing a NULL or empty @p expected_jkt fails: there is no "trust whatever is
+ * embedded" mode. Applies to the Compact Serialization (what DPoP/OpenID4VCI
+ * use). Mutually exclusive with the keyring form; the last call wins.
+ *
+ * @param checker Pointer to a checker object
+ * @param alg The hash for the thumbprint comparison (see @ref jwk_thumbprint_alg_t)
+ * @param expected_jkt The pinned base64url JWK Thumbprint the embedded key must match
+ * @return 0 on success, non-zero otherwise with error set in the checker
+ * @since 3.6.0
+ */
+JWT_EXPORT
+int jwt_checker_enable_embedded_jwk(jwt_checker_t *checker,
+				    jwk_thumbprint_alg_t alg,
+				    const char *expected_jkt);
+
+/**
+ * @brief Verify using the key embedded in the header, allowed by a keyring
+ *
+ * @rfc{7515,4.1.3}
+ *
+ * As jwt_checker_enable_embedded_jwk(), but the protected-header @c "jwk" is
+ * accepted only if its JWK Thumbprint (@p alg) matches that of some key in @p
+ * allowed (via jwks_find_bythumbprint()). Use this when the set of acceptable
+ * holder keys is known in advance rather than pinned per token. The keyring is
+ * borrowed: the caller retains ownership and must keep it valid for the
+ * lifetime of the checker.
+ *
+ * Passing a NULL @p allowed fails. Mutually exclusive with the pinned form; the
+ * last call wins.
+ *
+ * @param checker Pointer to a checker object
+ * @param alg The hash for the thumbprint comparison (see @ref jwk_thumbprint_alg_t)
+ * @param allowed A JWKS of acceptable keys (borrowed, not freed by the checker)
+ * @return 0 on success, non-zero otherwise with error set in the checker
+ * @since 3.6.0
+ */
+JWT_EXPORT
+int jwt_checker_enable_embedded_jwk_keyring(jwt_checker_t *checker,
+					    jwk_thumbprint_alg_t alg,
+					    const jwk_set_t *allowed);
+
+/**
  * @brief Set a callback for generating tokens
  *
  * When verifying a token, this callback will be run after jwt_t has been
@@ -1801,6 +1898,46 @@
 char *jwt_get_cnf(const jwt_t *jwt, const char *member);
 
 /**
+ * @brief Compute a base64url token hash (full digest)
+ *
+ * @rfc{9449,4.1}
+ *
+ * Returns @c base64url(SHA-x(@p value)) over the @em full digest. This is the
+ * DPoP @c "ath" (access token hash): bind a DPoP proof to an access token with
+ * @c jwt_token_hash(access_token, ::JWK_THUMBPRINT_SHA256) and set the result as
+ * the proof's @c "ath" claim (RFC 9449 pins SHA-256). It is also the generic
+ * "hash this string and base64url it" helper.
+ *
+ * @param value The string to hash (e.g. the access token), nil-terminated
+ * @param alg The digest to use (see @ref jwk_thumbprint_alg_t); RFC 9449 @c ath
+ *   uses @ref JWK_THUMBPRINT_SHA256
+ * @return A newly allocated, nil-terminated base64url string the caller must
+ *   free with free(), or NULL on error
+ * @since 3.6.0
+ */
+JWT_EXPORT
+char *jwt_token_hash(const char *value, jwk_thumbprint_alg_t alg);
+
+/**
+ * @brief Compute a base64url token hash (left half of the digest)
+ *
+ * Returns @c base64url(left-half(SHA-x(@p value))), where the digest width is
+ * the one the JWS algorithm @p alg signs with (HS/RS/ES/PS-256 -> SHA-256, -384
+ * -> SHA-384, -512 and EdDSA -> SHA-512). This is the OpenID Connect @c "at_hash"
+ * and @c "c_hash" construction (OIDC Core 3.1.3.6 / 3.3.2.11): take the
+ * left-most half of the hash @em bytes before base64url. Distinct from
+ * jwt_token_hash(), which uses the full digest and is keyed to a hash selector.
+ *
+ * @param value The string to hash (an access token or authorization code)
+ * @param alg The signing ::jwt_alg_t whose hash width is used
+ * @return A newly allocated, nil-terminated base64url string the caller must
+ *   free with free(), or NULL on error (including an @p alg with no SHA-2 width)
+ * @since 3.6.0
+ */
+JWT_EXPORT
+char *jwt_token_hash_half(const char *value, jwt_alg_t alg);
+
+/**
  * @}
  * @noop jwt_object_grp
  */
@@ -3123,21 +3260,6 @@
 char *jwks_export(const jwk_set_t *jwk_set, int priv);
 
 /**
- * @brief Hash algorithm for a JWK Thumbprint
- *
- * Selects the digest used by jwks_item_thumbprint() and
- * jwks_item_thumbprint_uri(). SHA-256 is the value 0, so it is the default for
- * a zero-initialized argument and is what virtually all deployments use.
- *
- * @since 3.6.0
- */
-typedef enum {
-	JWK_THUMBPRINT_SHA256 = 0,	/**< SHA-256 (default) */
-	JWK_THUMBPRINT_SHA384,		/**< SHA-384 */
-	JWK_THUMBPRINT_SHA512,		/**< SHA-512 */
-} jwk_thumbprint_alg_t;
-
-/**
  * @brief Compute the JWK Thumbprint of a key
  *
  * @rfc{7638,3}
diff --git a/libjwt/jwt-common.c b/libjwt/jwt-common.c
index 8027acd..7f2fd70 100644
--- a/libjwt/jwt-common.c
+++ b/libjwt/jwt-common.c
@@ -59,6 +59,19 @@
 	jwt_freemem(__cmd->c.expected_typ);
 	jwt_freemem(__cmd->c.alg_allowlist);
 
+	/* @rfc{9068} Free the required-claims list (the embedded_keyring is
+	 * borrowed, like the verify keyring, so it is never freed here). */
+	if (__cmd->c.require != NULL) {
+		size_t i;
+
+		for (i = 0; i < __cmd->c.n_require; i++)
+			jwt_freemem(__cmd->c.require[i]);
+		jwt_freemem(__cmd->c.require);
+	}
+	jwt_freemem(__cmd->c.embedded_jkt);
+	if (__cmd->c.embedded_owned != NULL)
+		jwks_free(__cmd->c.embedded_owned);
+
 	memset(__cmd, 0, sizeof(*__cmd));
 
 	jwt_freemem(__cmd);
@@ -568,6 +581,13 @@
 		}
 		__cmd->c.n_signatures = 0;
 		__cmd->c.last_sig_count = 0;
+
+		/* @rfc{7515,4.1.3} Drop a confirmed embedded key from a prior
+		 * verify before it can be borrowed by this one. */
+		if (__cmd->c.embedded_owned != NULL) {
+			jwks_free(__cmd->c.embedded_owned);
+			__cmd->c.embedded_owned = NULL;
+		}
 	}
 
 	/* @rfc{7515,7.2} A token whose first non-whitespace byte is '{' is a
@@ -599,6 +619,25 @@
 	config.alg = __cmd->c.alg;
 	config.ctx = __cmd->c.cb_ctx;
 
+	/* @rfc{7515,4.1.3} Embedded-JWK verify: seed the key from the protected
+	 * header "jwk", but only after confirming it against the pinned
+	 * thumbprint or the allowlist. The callback (below) still sees and may
+	 * override it. The owning keyring lives on the checker (freed next verify
+	 * or at checker free) so jwt_checker_sig_key() can borrow the key. */
+	if (__cmd->c.embedded_jwk) {
+		const jwk_item_t *ek = NULL;
+
+		__cmd->c.embedded_owned =
+			jwt_embedded_jwk_key(&__cmd->c, jwt->headers, &ek);
+		if (ek == NULL) {
+			jwt_write_error(__cmd,
+				"Embedded JWK is missing or not confirmed");
+			return 1;
+		}
+		config.key = ek;
+		config.alg = jwt->alg;
+	}
+
 	/* Let the user handle this and update config */
         if (__cmd->c.cb && __cmd->c.cb(jwt, &config)) {
 		jwt_write_error(__cmd, "User callback returned error");
@@ -722,6 +761,137 @@
 	return 0;
 }
 
+int jwt_checker_require(jwt_checker_t *checker, const char **claims,
+			unsigned int count)
+{
+	char **copy = NULL;
+	unsigned int i;
+
+	if (checker == NULL)
+		return 1;
+
+	if (claims != NULL && count > 0) {
+		for (i = 0; i < count; i++) {
+			if (claims[i] == NULL || claims[i][0] == '\0') {
+				jwt_write_error(checker,
+					"A required claim name is empty");
+				return 1;
+			}
+		}
+
+		copy = jwt_malloc(count * sizeof(char *));
+		if (copy == NULL) {
+			jwt_write_error(checker, "Error allocating memory"); // LCOV_EXCL_LINE
+			return 1; // LCOV_EXCL_LINE
+		}
+
+		for (i = 0; i < count; i++) {
+			size_t len = strlen(claims[i]) + 1;
+
+			copy[i] = jwt_malloc(len);
+			if (copy[i] == NULL) {
+				// LCOV_EXCL_START
+				while (i-- > 0)
+					jwt_freemem(copy[i]);
+				jwt_freemem(copy);
+				jwt_write_error(checker, "Error allocating memory");
+				return 1;
+				// LCOV_EXCL_STOP
+			}
+			memcpy(copy[i], claims[i], len);
+		}
+	}
+
+	/* Replace any previous requirement set. */
+	if (checker->c.require != NULL) {
+		for (i = 0; i < checker->c.n_require; i++)
+			jwt_freemem(checker->c.require[i]);
+		jwt_freemem(checker->c.require);
+	}
+	checker->c.require = copy;
+	checker->c.n_require = copy ? count : 0;
+
+	return 0;
+}
+
+/* Reject an out-of-range thumbprint selector at configure time (jwks_item_thumbprint
+ * also rejects it later, but failing here gives a clearer error). */
+static int thumbprint_alg_ok(jwt_checker_t *checker, jwk_thumbprint_alg_t alg)
+{
+	if (alg != JWK_THUMBPRINT_SHA256 && alg != JWK_THUMBPRINT_SHA384 &&
+	    alg != JWK_THUMBPRINT_SHA512) {
+		jwt_write_error(checker, "Invalid thumbprint algorithm");
+		return 0;
+	}
+	return 1;
+}
+
+int jwt_checker_enable_embedded_jwk(jwt_checker_t *checker,
+				    jwk_thumbprint_alg_t alg,
+				    const char *expected_jkt)
+{
+	char *copy;
+	size_t n;
+
+	if (checker == NULL)
+		return 1;
+
+	if (!thumbprint_alg_ok(checker, alg))
+		return 1;
+
+	/* @rfc{7515,4.1.3} The header "jwk" is attacker-supplied, so a key
+	 * confirmation is mandatory: refuse to enable a "trust whatever is
+	 * embedded" mode. */
+	if (expected_jkt == NULL || expected_jkt[0] == '\0') {
+		jwt_write_error(checker,
+			"A pinned thumbprint is required to trust an embedded JWK");
+		return 1;
+	}
+
+	n = strlen(expected_jkt) + 1;
+	copy = jwt_malloc(n);
+	if (copy == NULL) {
+		jwt_write_error(checker, "Error allocating memory"); // LCOV_EXCL_LINE
+		return 1; // LCOV_EXCL_LINE
+	}
+	memcpy(copy, expected_jkt, n);
+
+	jwt_freemem(checker->c.embedded_jkt);
+	checker->c.embedded_jkt = copy;
+	checker->c.embedded_keyring = NULL;
+	checker->c.embedded_alg = alg;
+	checker->c.embedded_jwk = 1;
+
+	return 0;
+}
+
+int jwt_checker_enable_embedded_jwk_keyring(jwt_checker_t *checker,
+					    jwk_thumbprint_alg_t alg,
+					    const jwk_set_t *allowed)
+{
+	if (checker == NULL)
+		return 1;
+
+	if (!thumbprint_alg_ok(checker, alg))
+		return 1;
+
+	if (allowed == NULL) {
+		jwt_write_error(checker,
+			"An allowlist keyring is required to trust an embedded JWK");
+		return 1;
+	}
+
+	/* The keyring is borrowed (like the verify keyring); the caller keeps
+	 * ownership and must keep it valid for the checker's lifetime. */
+	jwt_freemem(checker->c.embedded_jkt);
+	checker->c.embedded_jkt = NULL;
+	checker->c.embedded_keyring = allowed;
+	checker->c.embedded_alg = alg;
+	checker->c.embedded_jwk = 1;
+
+	return 0;
+}
+
 static const struct jwt_signature *checker_sig_at(const jwt_checker_t *checker,
 						  unsigned int index)
 {
diff --git a/libjwt/jwt-private.h b/libjwt/jwt-private.h
index 88dd151..5b7ad5f 100644
--- a/libjwt/jwt-private.h
+++ b/libjwt/jwt-private.h
@@ -130,6 +130,28 @@
 	char *expected_typ;
 	jwt_alg_t *alg_allowlist;
 	size_t n_alg_allowlist;
+
+	/* --- @rfc{9068} Required-claims-present (checker) ---
+	 * @require: a copied array of claim names (e.g. "iss","exp","jti") that
+	 * must be PRESENT in the token, independent of any value match. */
+	char **require;
+	size_t n_require;
+
+	/* --- @rfc{7515,4.1.3} Embedded-JWK header verify (checker) ---
+	 * When @embedded_jwk is set, the verification key is taken from the
+	 * token's protected-header "jwk" — but only after the key is CONFIRMED
+	 * against either @embedded_jkt (a pinned thumbprint) or @embedded_keyring
+	 * (a borrowed allowlist), using @embedded_alg as the thumbprint hash.
+	 * Exactly one of @embedded_jkt / @embedded_keyring is set. The header key
+	 * is attacker-supplied, so it is never trusted without this confirmation. */
+	int embedded_jwk;
+	jwk_thumbprint_alg_t embedded_alg;
+	char *embedded_jkt;
+	const jwk_set_t *embedded_keyring;
+	/* The confirmed header key is parsed into a keyring this checker OWNS;
+	 * it must outlive the verify (jwt_checker_sig_key() borrows it), so it is
+	 * reset at the start of each verify and freed at checker free. */
+	jwk_set_t *embedded_owned;
 };
 
 struct jwt_builder {
@@ -676,6 +698,14 @@
 JWT_NO_EXPORT
 int jwt_verify_json(jwt_checker_t *checker, const char *token);
 
+/* @rfc{7515,4.1.3} Build + confirm a verification key from the protected
+ * header's "jwk" (embedded-JWK verify). Returns an owned jwk_set_t (caller
+ * frees) with *out set to the contained item, or NULL if disabled/absent/
+ * unconfirmed. See jwt-verify.c. */
+JWT_NO_EXPORT
+jwk_set_t *jwt_embedded_jwk_key(struct jwt_common *c, jwt_json_t *headers,
+				const jwk_item_t **out);
+
 /* @rfc{7515,7.2.1} Non-zero if any member of @header also appears in
  * @protected (a parameter must not be in both). */
 JWT_NO_EXPORT
diff --git a/libjwt/jwt-setget.c b/libjwt/jwt-setget.c
index 02294b4..2fe8388 100644
--- a/libjwt/jwt-setget.c
+++ b/libjwt/jwt-setget.c
@@ -438,3 +438,79 @@
 
 	return out;
 }
+
+/* Map a thumbprint-hash selector to its SHA-2 bit width. */
+static int thumbprint_sha_bits(jwk_thumbprint_alg_t alg)
+{
+	switch (alg) {
+	case JWK_THUMBPRINT_SHA256:
+		return 256;
+	case JWK_THUMBPRINT_SHA384:
+		return 384;
+	case JWK_THUMBPRINT_SHA512:
+		return 512;
+	default:
+		return 0;
+	}
+}
+
+/* The SHA-2 width a JWS algorithm's signature is built on -- the hash an OIDC
+ * at_hash/c_hash is keyed to. 0 for an algorithm with no defined width. */
+static int alg_sha_bits(jwt_alg_t alg)
+{
+	switch (alg) {
+	case JWT_ALG_HS256:
+	case JWT_ALG_RS256:
+	case JWT_ALG_ES256:
+	case JWT_ALG_ES256K:
+	case JWT_ALG_PS256:
+		return 256;
+	case JWT_ALG_HS384:
+	case JWT_ALG_RS384:
+	case JWT_ALG_ES384:
+	case JWT_ALG_PS384:
+		return 384;
+	case JWT_ALG_HS512:
+	case JWT_ALG_RS512:
+	case JWT_ALG_ES512:
+	case JWT_ALG_PS512:
+	case JWT_ALG_EDDSA:
+		return 512;
+	default:
+		return 0;
+	}
+}
+
+/* base64url(SHA-@bits(@value)), optionally truncated to the left half. Returns
+ * a malloc'd string the caller frees, or NULL on error. */
+static char *token_hash(const char *value, int bits, int half)
+{
+	unsigned char hash[64];	/* SHA-512 is the widest */
+	unsigned int hlen = 0;
+	char *b64 = NULL;
+
+	if (value == NULL || bits == 0 || jwt_ops->sha == NULL)
+		return NULL;
+
+	if (jwt_ops->sha(bits, (const unsigned char *)value, strlen(value),
+			 hash, &hlen) || hlen == 0 || hlen > sizeof(hash))
+		return NULL; // LCOV_EXCL_LINE
+
+	if (half)
+		hlen /= 2;
+
+	if (jwt_base64uri_encode(&b64, (char *)hash, (int)hlen) <= 0)
+		return NULL; // LCOV_EXCL_LINE
+
+	return b64;
+}
+
+char *jwt_token_hash(const char *value, jwk_thumbprint_alg_t alg)
+{
+	return token_hash(value, thumbprint_sha_bits(alg), 0);
+}
+
+char *jwt_token_hash_half(const char *value, jwt_alg_t alg)
+{
+	return token_hash(value, alg_sha_bits(alg), 1);
+}
diff --git a/libjwt/jwt-verify.c b/libjwt/jwt-verify.c
index 43898b7..c3c4e72 100644
--- a/libjwt/jwt-verify.c
+++ b/libjwt/jwt-verify.c
@@ -386,6 +386,109 @@
 	return failed;
 }
 
+/* @rfc{9068} Every claim named via jwt_checker_require() must be PRESENT in the
+ * token, independent of any value match. Returns 0 if all are present, or 1 with
+ * the error set naming the first missing one. */
+static int __verify_required(jwt_t *jwt)
+{
+	jwt_checker_t *checker = jwt->checker;
+	size_t i;
+
+	if (checker == NULL)
+		return 0; // LCOV_EXCL_LINE
+
+	for (i = 0; i < checker->c.n_require; i++) {
+		const char *name = checker->c.require[i];
+		jwt_value_t jval;
+
+		/* Presence is all we assert: a wrong-type claim (a numeric "exp",
+		 * an array "aud") still returns a non-NOEXIST error, i.e. present. */
+		jwt_set_GET_STR(&jval, name);
+		if (jwt_claim_get(jwt, &jval) == JWT_VALUE_ERR_NOEXIST) {
+			jwt_write_error(jwt,
+				"Required claim \"%s\" is missing", name);
+			return 1;
+		}
+	}
+
+	return 0;
+}
+
+/* @rfc{7515,4.1.3} Build and CONFIRM the verification key from a protected
+ * header's "jwk". The header key is attacker-supplied, so it is accepted only
+ * after its thumbprint matches the checker's pin (c->embedded_jkt) or is found
+ * in its allowlist (c->embedded_keyring), using c->embedded_alg. On success
+ * returns a jwk_set_t that OWNS the key (the caller frees it) and points *out at
+ * the contained item; returns NULL (with *out NULL) if embedded-jwk is not
+ * enabled, the header carries no usable "jwk", or confirmation fails. */
+jwk_set_t *jwt_embedded_jwk_key(struct jwt_common *c, jwt_json_t *headers,
+				const jwk_item_t **out)
+{
+	jwt_json_t *jwk;
+	char *jwk_str;
+	jwk_set_t *ks;
+	const jwk_item_t *item;
+	char *tp;
+	int ok;
+
+	*out = NULL;
+
+	if (!c->embedded_jwk || headers == NULL)
+		return NULL; // LCOV_EXCL_LINE (callers guarantee both)
+
+	jwk = jwt_json_obj_get(headers, "jwk");
+	if (jwk == NULL || !jwt_json_is_object(jwk))
+		return NULL;
+
+	jwk_str = jwt_json_serialize(jwk, 0);
+	if (jwk_str == NULL)
+		return NULL; // LCOV_EXCL_LINE
+
+	ks = jwks_create(jwk_str);
+	jwt_freemem(jwk_str);
+	if (ks == NULL)
+		return NULL; // LCOV_EXCL_LINE
+	/* A set-level parse error (a malformed key surfaces as an item error and
+	 * is caught by the thumbprint check below). Defensive depth. */
+	if (jwks_error(ks)) {
+		// LCOV_EXCL_START
+		jwks_free(ks);
+		return NULL;
+		// LCOV_EXCL_STOP
+	}
+
+	item = jwks_item_get(ks, 0);
+	if (item == NULL) {
+		// LCOV_EXCL_START
+		jwks_free(ks);
+		return NULL;
+		// LCOV_EXCL_STOP
+	}
+
+	/* Confirm the attacker-supplied key against the pin or the allowlist. */
+	tp = jwks_item_thumbprint(item, c->embedded_alg);
+	if (tp == NULL) {
+		jwks_free(ks);
+		return NULL;
+	}
+
+	if (c->embedded_jkt != NULL)
+		ok = (strcmp(tp, c->embedded_jkt) == 0);
+	else
+		ok = (c->embedded_keyring != NULL &&
+		      jwks_find_bythumbprint((jwk_set_t *)c->embedded_keyring,
+					     c->embedded_alg, tp) != NULL);
+	jwt_freemem(tp);
+
+	if (!ok) {
+		jwks_free(ks);
+		return NULL;
+	}
+
+	*out = item;
+	return ks;
+}
+
 /* @rfc{7519,4.1.7} jti: hand the id to the application callback, which
  * validates/consumes it (e.g. replay protection). A registered callback means
  * a token with no jti is rejected.
@@ -481,6 +584,10 @@
 		return 1;
 	}
 
+	/* @rfc{9068} Required claims must be present (also JSON-claims only). */
+	if (jwt->b64 && __verify_required(jwt))
+		return 1;
+
 	if (!sig_len) {
 		if (config->key || config->alg != JWT_ALG_NONE ||
 		    jwt->alg != JWT_ALG_NONE) {
@@ -923,6 +1030,10 @@
 			n_verified, n_entries);
 	} else if (jwt->b64 && __verify_claims(jwt)) {
 		jwt_write_error(checker, "Failed one or more claims");
+	} else if (jwt->b64 && __verify_required(jwt)) {
+		/* @rfc{9068} Required-claims-present, same as the compact path; the
+		 * specific "missing" error is written on the jwt. */
+		jwt_copy_error(checker, jwt);
 	} else if (__verify_jti(jwt)) {
 		/* jti runs only after signature + claims succeed. */
 		jwt_write_error(checker, "Failed one or more claims");
diff --git a/tests/jwt_embedded_jwk.c b/tests/jwt_embedded_jwk.c
new file mode 100644
index 0000000..ac2e55e
--- /dev/null
+++ b/tests/jwt_embedded_jwk.c
@@ -0,0 +1,353 @@
+/* Public domain, no copyright. Use at your own risk. */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "jwt_tests.h"
+
+/* Tests for embedded-JWK verification (RFC 7515 4.1.3): jwt_checker_enable_
+ * embedded_jwk() (thumbprint pin) and _keyring() (allowlist). The header "jwk"
+ * is attacker-supplied, so the security contract is that it is accepted ONLY
+ * after the key is confirmed against a caller-supplied pin/allowlist AND its
+ * signature verifies against that confirmed key. This is the DPoP / OpenID4VCI
+ * key-proof mechanism. The proof carries the P-256 public key in its protected
+ * header and is signed by the matching private key. */
+
+/* The public half of tests/keys/ec_key_prime256v1.json (key "A"). */
+#define PUB_JWK_P256 \
+	"{\"kty\":\"EC\",\"crv\":\"P-256\"," \
+	"\"x\":\"Y--DdSpCZ5oF3j__h-SdNJIwvB5aI4AXzpRErGUjWrM\"," \
+	"\"y\":\"_bSTCXlDeU-pZZbOKDUVLANspSIeuKZfTM8rtXFG_RU\"}"
+
+static jwk_set_t *load_key_a(void)
+{
+	jwk_set_t *set = jwks_create_fromfile(KEYDIR "/ec_key_prime256v1.json");
+
+	ck_assert_ptr_nonnull(set);
+	return set;
+}
+
+/* A self-contained proof: header "jwk" = @jwk_hdr (or none), signed with
+ * @signing_key, typ @typ. Caller frees the token. */
+static char *make_proof(const jwk_item_t *signing_key, char *jwk_hdr,
+			const char *typ)
+{
+	jwt_builder_auto_t *b = jwt_builder_new();
+	jwt_value_t v;
+
+	ck_assert_ptr_nonnull(b);
+	ck_assert_int_eq(jwt_builder_setkey(b, JWT_ALG_ES256, signing_key), 0);
+	if (typ != NULL)
+		ck_assert_int_eq(jwt_builder_settyp(b, typ), 0);
+	if (jwk_hdr != NULL) {
+		jwt_set_SET_JSON(&v, "jwk", jwk_hdr);
+		ck_assert_int_eq(jwt_builder_header_set(b, &v),
+				 JWT_VALUE_ERR_NONE);
+	}
+	jwt_set_SET_STR(&v, "htm", "POST");
+	ck_assert_int_eq(jwt_builder_claim_set(b, &v), JWT_VALUE_ERR_NONE);
+
+	return jwt_builder_generate(b);
+}
+
+START_TEST(test_pin_confirms)
+{
+	jwk_set_t *set;
+	const jwk_item_t *key;
+	jwt_checker_auto_t *checker = NULL;
+	char_auto *jkt = NULL;
+	char_auto *token = NULL;
+
+	SET_OPS();
+
+	set = load_key_a();
+	key = jwks_item_get(set, 0);
+	jkt = jwks_item_thumbprint(key, JWK_THUMBPRINT_SHA256);
+	ck_assert_ptr_nonnull(jkt);
+
+	token = make_proof(key, PUB_JWK_P256, "dpop+jwt");
+	ck_assert_ptr_nonnull(token);
+
+	checker = jwt_checker_new();
+	ck_assert_int_eq(jwt_checker_expect_typ(checker, "dpop+jwt"), 0);
+	{
+		const jwt_alg_t algs[] = { JWT_ALG_ES256 };
+		ck_assert_int_eq(jwt_checker_setalgs(checker, algs, 1), 0);
+	}
+	ck_assert_int_eq(jwt_checker_enable_embedded_jwk(checker,
+				JWK_THUMBPRINT_SHA256, jkt), 0);
+
+	ck_assert_int_eq(jwt_checker_verify(checker, token), 0);
+
+	/* The confirmed embedded key is borrowable through the introspection API. */
+	ck_assert_uint_eq(jwt_checker_sig_count(checker), 1);
+	ck_assert_int_eq(jwt_checker_sig_verified(checker, 0), 1);
+	ck_assert_ptr_nonnull(jwt_checker_sig_key(checker, 0));
+
+	jwks_free(set);
+}
+END_TEST
+
+START_TEST(test_pin_mismatch_rejected)
+{
+	jwk_set_t *set;
+	const jwk_item_t *key;
+	jwt_checker_auto_t *checker = NULL;
+	char_auto *token = NULL;
+
+	SET_OPS();
+
+	set = load_key_a();
+	key = jwks_item_get(set, 0);
+	token = make_proof(key, PUB_JWK_P256, "dpop+jwt");
+	ck_assert_ptr_nonnull(token);
+
+	checker = jwt_checker_new();
+	/* Pin to a thumbprint the embedded key does not match. */
+	ck_assert_int_eq(jwt_checker_enable_embedded_jwk(checker,
+				JWK_THUMBPRINT_SHA256,
+				"not-the-right-thumbprint-value-000000000000"), 0);
+	ck_assert_int_ne(jwt_checker_verify(checker, token), 0);
+
+	jwks_free(set);
+}
+END_TEST
+
+START_TEST(test_missing_header_jwk_rejected)
+{
+	jwk_set_t *set;
+	const jwk_item_t *key;
+	jwt_checker_auto_t *checker = NULL;
+	char_auto *jkt = NULL;
+	char_auto *token = NULL;
+
+	SET_OPS();
+
+	set = load_key_a();
+	key = jwks_item_get(set, 0);
+	jkt = jwks_item_thumbprint(key, JWK_THUMBPRINT_SHA256);
+
+	/* A token with no "jwk" header at all. */
+	token = make_proof(key, NULL, "dpop+jwt");
+	ck_assert_ptr_nonnull(token);
+
+	checker = jwt_checker_new();
+	ck_assert_int_eq(jwt_checker_enable_embedded_jwk(checker,
+				JWK_THUMBPRINT_SHA256, jkt), 0);
+	ck_assert_int_ne(jwt_checker_verify(checker, token), 0);
+
+	jwks_free(set);
+}
+END_TEST
+
+/* The crux: even when the embedded key's thumbprint matches the pin, the
+ * signature must verify against THAT key. A proof that embeds key A's public
+ * key but is signed by a different key B must be rejected. */
+START_TEST(test_signature_must_match_embedded)
+{
+	jwk_set_t *set, *bset;
+	const jwk_item_t *akey, *bkey;
+	jwt_checker_auto_t *checker = NULL;
+	char_auto *jkt = NULL;
+	char_auto *token = NULL;
+
+	SET_OPS();
+
+	set = load_key_a();
+	akey = jwks_item_get(set, 0);
+	jkt = jwks_item_thumbprint(akey, JWK_THUMBPRINT_SHA256);
+
+	bset = jwks_create_generate(JWK_KEY_TYPE_EC, "P-256", JWT_ALG_ES256,
+				    JWK_KEY_NONE);
+	ck_assert_ptr_nonnull(bset);
+	bkey = jwks_item_get(bset, 0);
+	if (bkey == NULL || jwks_item_error(bkey)) {
+		/* A backend with no EC keygen: nothing to test here. */
+		jwks_free(bset);
+		jwks_free(set);
+		return;
+	}
+
+	/* header jwk = A (matches the pin), but signed by B. */
+	token = make_proof(bkey, PUB_JWK_P256, "dpop+jwt");
+	ck_assert_ptr_nonnull(token);
+
+	checker = jwt_checker_new();
+	ck_assert_int_eq(jwt_checker_enable_embedded_jwk(checker,
+				JWK_THUMBPRINT_SHA256, jkt), 0);
+	ck_assert_int_ne(jwt_checker_verify(checker, token), 0);
+
+	jwks_free(bset);
+	jwks_free(set);
+}
+END_TEST
+
+START_TEST(test_keyring_allowlist)
+{
+	jwk_set_t *set, *other;
+	const jwk_item_t *key;
+	jwt_checker_auto_t *c1 = NULL, *c2 = NULL;
+	char_auto *token = NULL;
+
+	SET_OPS();
+
+	set = load_key_a();
+	key = jwks_item_get(set, 0);
+	token = make_proof(key, PUB_JWK_P256, "dpop+jwt");
+	ck_assert_ptr_nonnull(token);
+
+	/* The allowlist contains key A: accepted. */
+	c1 = jwt_checker_new();
+	ck_assert_int_eq(jwt_checker_enable_embedded_jwk_keyring(c1,
+				JWK_THUMBPRINT_SHA256, set), 0);
+	ck_assert_int_eq(jwt_checker_verify(c1, token), 0);
+
+	/* An allowlist of some other key: rejected. */
+	other = jwks_create_fromfile(KEYDIR "/ec_key_secp256k1.json");
+	ck_assert_ptr_nonnull(other);
+	c2 = jwt_checker_new();
+	ck_assert_int_eq(jwt_checker_enable_embedded_jwk_keyring(c2,
+				JWK_THUMBPRINT_SHA256, other), 0);
+	ck_assert_int_ne(jwt_checker_verify(c2, token), 0);
+
+	jwks_free(other);
+	jwks_free(set);
+}
+END_TEST
+
+/* A malformed/incomplete header "jwk" is rejected, not crashed on (the header
+ * key is fully attacker-controlled). */
+START_TEST(test_malformed_embedded_jwk)
+{
+	jwk_set_t *set;
+	const jwk_item_t *key;
+	char_auto *jkt = NULL;
+	const char *bad[] = {
+		"{\"kty\":\"EC\",\"crv\":\"P-256\"}",	/* missing x/y */
+		"{\"kty\":\"frobnicate\"}",		/* unknown kty */
+		"{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"@\",\"y\":\"@\"}",
+		"{}",					/* empty object */
+	};
+	size_t n;
+
+	SET_OPS();
+
+	set = load_key_a();
+	key = jwks_item_get(set, 0);
+	jkt = jwks_item_thumbprint(key, JWK_THUMBPRINT_SHA256);
+
+	for (n = 0; n < ARRAY_SIZE(bad); n++) {
+		jwt_checker_auto_t *cc = jwt_checker_new();
+		char *t = make_proof(key, (char *)bad[n], "dpop+jwt");
+
+		ck_assert_ptr_nonnull(t);
+		ck_assert_int_eq(jwt_checker_enable_embedded_jwk(cc,
+					JWK_THUMBPRINT_SHA256, jkt), 0);
+		/* A malformed header key is rejected, never crashed on. */
+		ck_assert_int_ne(jwt_checker_verify(cc, t), 0);
+		free(t);
+	}
+
+	jwks_free(set);
+}
+END_TEST
+
+/* One embedded-JWK checker verifies two proofs in a row: the confirmed key from
+ * the first verify must be released before the second borrows its own. */
+START_TEST(test_checker_reuse)
+{
+	jwk_set_t *set;
+	const jwk_item_t *key;
+	jwt_checker_auto_t *checker = NULL;
+	char_auto *jkt = NULL;
+	char_auto *t1 = NULL, *t2 = NULL;
+
+	SET_OPS();
+
+	set = load_key_a();
+	key = jwks_item_get(set, 0);
+	jkt = jwks_item_thumbprint(key, JWK_THUMBPRINT_SHA256);
+
+	t1 = make_proof(key, PUB_JWK_P256, "dpop+jwt");
+	t2 = make_proof(key, PUB_JWK_P256, "dpop+jwt");
+	ck_assert_ptr_nonnull(t1);
+	ck_assert_ptr_nonnull(t2);
+
+	checker = jwt_checker_new();
+	ck_assert_int_eq(jwt_checker_enable_embedded_jwk(checker,
+				JWK_THUMBPRINT_SHA256, jkt), 0);
+	ck_assert_int_eq(jwt_checker_verify(checker, t1), 0);
+	ck_assert_int_eq(jwt_checker_verify(checker, t2), 0);
+
+	jwks_free(set);
+}
+END_TEST
+
+START_TEST(test_enable_errors)
+{
+	jwk_set_t *set;
+	const jwk_item_t *key;
+	jwt_checker_auto_t *checker = NULL;
+	char_auto *jkt = NULL;
+
+	SET_OPS();
+
+	set = load_key_a();
+	key = jwks_item_get(set, 0);
+	jkt = jwks_item_thumbprint(key, JWK_THUMBPRINT_SHA256);
+
+	checker = jwt_checker_new();
+
+	/* No "trust whatever is embedded" mode: a NULL/empty pin must fail. */
+	ck_assert_int_ne(jwt_checker_enable_embedded_jwk(NULL,
+				JWK_THUMBPRINT_SHA256, jkt), 0);
+	ck_assert_int_ne(jwt_checker_enable_embedded_jwk(checker,
+				JWK_THUMBPRINT_SHA256, NULL), 0);
+	ck_assert_int_ne(jwt_checker_enable_embedded_jwk(checker,
+				JWK_THUMBPRINT_SHA256, ""), 0);
+	/* An out-of-range thumbprint selector. */
+	ck_assert_int_ne(jwt_checker_enable_embedded_jwk(checker,
+				(jwk_thumbprint_alg_t)42, jkt), 0);
+	/* The keyring form: NULL checker, bad alg, and a missing keyring all fail. */
+	ck_assert_int_ne(jwt_checker_enable_embedded_jwk_keyring(NULL,
+				JWK_THUMBPRINT_SHA256, set), 0);
+	ck_assert_int_ne(jwt_checker_enable_embedded_jwk_keyring(checker,
+				(jwk_thumbprint_alg_t)42, set), 0);
+	ck_assert_int_ne(jwt_checker_enable_embedded_jwk_keyring(checker,
+				JWK_THUMBPRINT_SHA256, NULL), 0);
+
+	jwks_free(set);
+}
+END_TEST
+
+static Suite *libjwt_suite(const char *title)
+{
+	Suite *s;
+	TCase *tc_core;
+	int i = ARRAY_SIZE(jwt_test_ops);
+
+	s = suite_create(title);
+
+	tc_core = tcase_create("jwt_embedded_jwk");
+
+	tcase_add_loop_test(tc_core, test_pin_confirms, 0, i);
+	tcase_add_loop_test(tc_core, test_pin_mismatch_rejected, 0, i);
+	tcase_add_loop_test(tc_core, test_missing_header_jwk_rejected, 0, i);
+	tcase_add_loop_test(tc_core, test_signature_must_match_embedded, 0, i);
+	tcase_add_loop_test(tc_core, test_keyring_allowlist, 0, i);
+	tcase_add_loop_test(tc_core, test_malformed_embedded_jwk, 0, i);
+	tcase_add_loop_test(tc_core, test_checker_reuse, 0, i);
+	tcase_add_loop_test(tc_core, test_enable_errors, 0, i);
+
+	tcase_set_timeout(tc_core, 30);
+
+	suite_add_tcase(s, tc_core);
+
+	return s;
+}
+
+int main(void)
+{
+	JWT_TEST_MAIN("LibJWT embedded-JWK verify (RFC 7515 4.1.3)");
+}
diff --git a/tests/jwt_hash.c b/tests/jwt_hash.c
new file mode 100644
index 0000000..fc79d21
--- /dev/null
+++ b/tests/jwt_hash.c
@@ -0,0 +1,147 @@
+/* Public domain, no copyright. Use at your own risk. */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "jwt_tests.h"
+
+/* Tests for jwt_token_hash() (DPoP "ath", the full digest) and
+ * jwt_token_hash_half() (OIDC "at_hash"/"c_hash", the left half keyed to the
+ * signing alg). The anchors are the published OpenID Connect Core 3.1.3.6
+ * at_hash example and a full SHA-256 vector. SET_OPS() runs each across every
+ * compiled crypto backend, so each backend's one-shot SHA is exercised. */
+
+/* OIDC Core 3.1.3.6: at_hash of this access token under an RS256 (SHA-256) ID
+ * token. The value is the left half of the digest, base64url. */
+#define OIDC_AT		"jHkWEdUXMU1BwAsC4vtUsZwnNvTIxEl0z9K3vx5KF0Y"
+#define OIDC_ATHASH	"77QmUPtjPfzWtF2AnpK9RQ"
+
+START_TEST(test_at_hash_oidc_vector)
+{
+	char_auto *h = NULL;
+
+	SET_OPS();
+
+	h = jwt_token_hash_half(OIDC_AT, JWT_ALG_RS256);
+	ck_assert_ptr_nonnull(h);
+	ck_assert_str_eq(h, OIDC_ATHASH);
+}
+END_TEST
+
+/* DPoP "ath" = FULL base64url(SHA-256(access_token)). */
+START_TEST(test_ath_full_vector)
+{
+	char_auto *h = NULL;
+
+	SET_OPS();
+
+	h = jwt_token_hash("Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU",
+			   JWK_THUMBPRINT_SHA256);
+	ck_assert_ptr_nonnull(h);
+	ck_assert_str_eq(h, "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo");
+}
+END_TEST
+
+START_TEST(test_full_lengths_and_determinism)
+{
+	char_auto *a = NULL, *b = NULL;
+	char_auto *c256 = NULL, *c384 = NULL, *c512 = NULL;
+
+	SET_OPS();
+
+	/* base64url of a 32/48/64-byte digest = 43/64/86 chars (no padding). */
+	c256 = jwt_token_hash("hello", JWK_THUMBPRINT_SHA256);
+	c384 = jwt_token_hash("hello", JWK_THUMBPRINT_SHA384);
+	c512 = jwt_token_hash("hello", JWK_THUMBPRINT_SHA512);
+	ck_assert_ptr_nonnull(c256);
+	ck_assert_ptr_nonnull(c384);
+	ck_assert_ptr_nonnull(c512);
+	ck_assert_int_eq((int)strlen(c256), 43);
+	ck_assert_int_eq((int)strlen(c384), 64);
+	ck_assert_int_eq((int)strlen(c512), 86);
+
+	/* Deterministic; different inputs differ. */
+	a = jwt_token_hash("hello", JWK_THUMBPRINT_SHA256);
+	b = jwt_token_hash("world", JWK_THUMBPRINT_SHA256);
+	ck_assert_str_eq(a, c256);
+	ck_assert_str_ne(a, b);
+}
+END_TEST
+
+START_TEST(test_half_is_left_half)
+{
+	char_auto *full = NULL, *half = NULL;
+
+	SET_OPS();
+
+	/* RS256 -> SHA-256; the half is 16 bytes -> 22 chars and is the leading
+	 * bytes of the full digest, so the first five base64url groups (15 bytes
+	 * -> 20 chars) are shared with the full encoding. */
+	full = jwt_token_hash("hello", JWK_THUMBPRINT_SHA256);
+	half = jwt_token_hash_half("hello", JWT_ALG_RS256);
+	ck_assert_ptr_nonnull(half);
+	ck_assert_int_eq((int)strlen(half), 22);
+	ck_assert(!strncmp(full, half, 20));
+}
+END_TEST
+
+START_TEST(test_half_alg_widths)
+{
+	char_auto *h256 = NULL, *h384 = NULL, *h512 = NULL, *hed = NULL;
+
+	SET_OPS();
+
+	h256 = jwt_token_hash_half("hello", JWT_ALG_ES256);
+	h384 = jwt_token_hash_half("hello", JWT_ALG_ES384);
+	h512 = jwt_token_hash_half("hello", JWT_ALG_ES512);
+	hed  = jwt_token_hash_half("hello", JWT_ALG_EDDSA);
+	ck_assert_int_eq((int)strlen(h256), 22);	/* 16 bytes */
+	ck_assert_int_eq((int)strlen(h384), 32);	/* 24 bytes */
+	ck_assert_int_eq((int)strlen(h512), 43);	/* 32 bytes */
+	ck_assert_int_eq((int)strlen(hed),  43);	/* EdDSA -> SHA-512 -> 32 */
+}
+END_TEST
+
+START_TEST(test_errors)
+{
+	SET_OPS();
+
+	ck_assert_ptr_null(jwt_token_hash(NULL, JWK_THUMBPRINT_SHA256));
+	ck_assert_ptr_null(jwt_token_hash_half(NULL, JWT_ALG_ES256));
+	/* An alg with no SHA-2 width -> NULL. */
+	ck_assert_ptr_null(jwt_token_hash_half("hello", JWT_ALG_NONE));
+	ck_assert_ptr_null(jwt_token_hash_half("hello", JWT_ALG_INVAL));
+	/* An out-of-range thumbprint selector -> NULL. */
+	ck_assert_ptr_null(jwt_token_hash("hello", (jwk_thumbprint_alg_t)999));
+}
+END_TEST
+
+static Suite *libjwt_suite(const char *title)
+{
+	Suite *s;
+	TCase *tc_core;
+	int i = ARRAY_SIZE(jwt_test_ops);
+
+	s = suite_create(title);
+
+	tc_core = tcase_create("jwt_hash");
+
+	tcase_add_loop_test(tc_core, test_at_hash_oidc_vector, 0, i);
+	tcase_add_loop_test(tc_core, test_ath_full_vector, 0, i);
+	tcase_add_loop_test(tc_core, test_full_lengths_and_determinism, 0, i);
+	tcase_add_loop_test(tc_core, test_half_is_left_half, 0, i);
+	tcase_add_loop_test(tc_core, test_half_alg_widths, 0, i);
+	tcase_add_loop_test(tc_core, test_errors, 0, i);
+
+	tcase_set_timeout(tc_core, 30);
+
+	suite_add_tcase(s, tc_core);
+
+	return s;
+}
+
+int main(void)
+{
+	JWT_TEST_MAIN("LibJWT token hash (ath / at_hash / c_hash)");
+}
diff --git a/tests/jwt_profiles.c b/tests/jwt_profiles.c
new file mode 100644
index 0000000..19b3a5b
--- /dev/null
+++ b/tests/jwt_profiles.c
@@ -0,0 +1,443 @@
+/* Public domain, no copyright. Use at your own risk. */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "jwt_tests.h"
+
+/* Worked, end-to-end recipes for the real-world JWT application profiles of
+ * issue #317. Each builds AND verifies a token using only the public primitives
+ * (no per-profile API): at+jwt (RFC 9068), VAPID (RFC 8292), PASSporT
+ * (RFC 8225), OpenID4VCI key-proof, DPoP (RFC 9449), OAuth mTLS (RFC 8705), and
+ * JAdES (ETSI 119 182-1). One EC P-256 key (ES256, every backend) is used. */
+
+/* The public half of tests/keys/ec_key_prime256v1.json. */
+#define PUB_JWK_P256 \
+	"{\"kty\":\"EC\",\"crv\":\"P-256\"," \
+	"\"x\":\"Y--DdSpCZ5oF3j__h-SdNJIwvB5aI4AXzpRErGUjWrM\"," \
+	"\"y\":\"_bSTCXlDeU-pZZbOKDUVLANspSIeuKZfTM8rtXFG_RU\"}"
+
+static jwk_set_t *load_key(void)
+{
+	jwk_set_t *set = jwks_create_fromfile(KEYDIR "/ec_key_prime256v1.json");
+
+	ck_assert_ptr_nonnull(set);
+	return set;
+}
+
+static void set_str(jwt_builder_t *b, const char *name, const char *val)
+{
+	jwt_value_t v;
+
+	jwt_set_SET_STR(&v, name, val);
+	ck_assert_int_eq(jwt_builder_claim_set(b, &v), JWT_VALUE_ERR_NONE);
+}
+
+/* ---- RFC 9068 OAuth 2.0 JWT access token (typ "at+jwt") ---------------- */
+START_TEST(test_at_jwt)
+{
+	jwk_set_t *set;
+	const jwk_item_t *key;
+	jwt_builder_auto_t *b = NULL;
+	jwt_checker_auto_t *c = NULL;
+	char_auto *token = NULL;
+	const char *required[] = { "iss", "sub", "aud", "exp", "iat",
+				   "jti", "client_id" };
+	const jwt_alg_t algs[] = { JWT_ALG_ES256 };
+
+	SET_OPS();
+
+	set = load_key();
+	key = jwks_item_get(set, 0);
+
+	/* Issue: typ=at+jwt and the RFC 9068 mandatory claims. */
+	b = jwt_builder_new();
+	ck_assert_int_eq(jwt_builder_setkey(b, JWT_ALG_ES256, key), 0);
+	ck_assert_int_eq(jwt_builder_settyp(b, "at+jwt"), 0);
+	set_str(b, "iss", "https://as.example");
+	set_str(b, "sub", "user-1");
+	set_str(b, "aud", "https://rs.example");
+	set_str(b, "client_id", "client-42");
+	set_str(b, "jti", "wU3ifM");
+	ck_assert_int_eq(jwt_builder_time_offset(b, JWT_CLAIM_EXP, 300), 0);
+	token = jwt_builder_generate(b);
+	ck_assert_ptr_nonnull(token);
+
+	/* Verify: pin typ + alg, and assert the mandatory claims are present. */
+	c = jwt_checker_new();
+	ck_assert_int_eq(jwt_checker_setkey(c, JWT_ALG_ES256, key), 0);
+	ck_assert_int_eq(jwt_checker_expect_typ(c, "at+jwt"), 0);
+	ck_assert_int_eq(jwt_checker_setalgs(c, algs, 1), 0);
+	ck_assert_int_eq(jwt_checker_require(c, required, ARRAY_SIZE(required)), 0);
+	ck_assert_int_eq(jwt_checker_claim_set(c, JWT_CLAIM_AUD, "https://rs.example"), 0);
+	ck_assert_int_eq(jwt_checker_verify(c, token), 0);
+
+	jwks_free(set);
+}
+END_TEST
+
+/* ---- RFC 8292 VAPID (Web Push) ---------------------------------------- */
+START_TEST(test_vapid)
+{
+	jwk_set_t *set;
+	const jwk_item_t *key;
+	jwt_builder_auto_t *b = NULL;
+	jwt_checker_auto_t *c = NULL;
+	char_auto *token = NULL;
+	const char *required[] = { "aud", "exp", "sub" };
+	const jwt_alg_t algs[] = { JWT_ALG_ES256 };
+
+	SET_OPS();
+
+	set = load_key();
+	key = jwks_item_get(set, 0);
+
+	/* A plain ES256 JWS over P-256: aud=push origin, sub=contact, short exp. */
+	b = jwt_builder_new();
+	ck_assert_int_eq(jwt_builder_setkey(b, JWT_ALG_ES256, key), 0);
+	set_str(b, "aud", "https://push.example.com");
+	set_str(b, "sub", "mailto:admin@example.com");
+	ck_assert_int_eq(jwt_builder_time_offset(b, JWT_CLAIM_EXP, 12 * 3600), 0);
+	token = jwt_builder_generate(b);
+	ck_assert_ptr_nonnull(token);
+
+	c = jwt_checker_new();
+	ck_assert_int_eq(jwt_checker_setkey(c, JWT_ALG_ES256, key), 0);
+	ck_assert_int_eq(jwt_checker_setalgs(c, algs, 1), 0);	/* ES256 only */
+	ck_assert_int_eq(jwt_checker_require(c, required, ARRAY_SIZE(required)), 0);
+	ck_assert_int_eq(jwt_checker_verify(c, token), 0);
+
+	jwks_free(set);
+}
+END_TEST
+
+/* ---- RFC 8225 PASSporT / STIR-SHAKEN (typ "passport") ------------------ */
+START_TEST(test_passport)
+{
+	jwk_set_t *set;
+	const jwk_item_t *key;
+	jwt_builder_auto_t *b = NULL;
+	jwt_checker_auto_t *c = NULL;
+	char_auto *token = NULL;
+	const char *required[] = { "iat", "orig", "dest" };
+	const jwt_alg_t algs[] = { JWT_ALG_ES256 };
+	jwt_value_t v;
+
+	SET_OPS();
+
+	set = load_key();
+	key = jwks_item_get(set, 0);
+
+	b = jwt_builder_new();
+	ck_assert_int_eq(jwt_builder_setkey(b, JWT_ALG_ES256, key), 0);
+	ck_assert_int_eq(jwt_builder_settyp(b, "passport"), 0);
+	set_str(b, "attest", "A");
+	/* orig/dest are JSON objects per RFC 8225. */
+	jwt_set_SET_JSON(&v, "orig", "{\"tn\":\"12155551212\"}");
+	ck_assert_int_eq(jwt_builder_claim_set(b, &v), JWT_VALUE_ERR_NONE);
+	jwt_set_SET_JSON(&v, "dest", "{\"tn\":[\"12155551213\"]}");
+	ck_assert_int_eq(jwt_builder_claim_set(b, &v), JWT_VALUE_ERR_NONE);
+	token = jwt_builder_generate(b);
+	ck_assert_ptr_nonnull(token);
+
+	c = jwt_checker_new();
+	ck_assert_int_eq(jwt_checker_setkey(c, JWT_ALG_ES256, key), 0);
+	ck_assert_int_eq(jwt_checker_expect_typ(c, "passport"), 0);
+	ck_assert_int_eq(jwt_checker_setalgs(c, algs, 1), 0);
+	ck_assert_int_eq(jwt_checker_require(c, required, ARRAY_SIZE(required)), 0);
+	ck_assert_int_eq(jwt_checker_verify(c, token), 0);
+
+	jwks_free(set);
+}
+END_TEST
+
+/* ---- OpenID4VCI key proof (typ "openid4vci-proof+jwt") ----------------- */
+START_TEST(test_openid4vci_proof)
+{
+	jwk_set_t *set;
+	const jwk_item_t *key;
+	jwt_builder_auto_t *b = NULL;
+	jwt_checker_auto_t *c = NULL;
+	char_auto *jkt = NULL;
+	char_auto *token = NULL;
+	jwt_value_t v;
+	const jwt_alg_t algs[] = { JWT_ALG_ES256 };
+
+	SET_OPS();
+
+	set = load_key();
+	key = jwks_item_get(set, 0);
+	jkt = jwks_item_thumbprint(key, JWK_THUMBPRINT_SHA256);
+
+	/* The proof's signing key IS the header "jwk"; the credential request
+	 * binds the credential to that key (its thumbprint here). */
+	b = jwt_builder_new();
+	ck_assert_int_eq(jwt_builder_setkey(b, JWT_ALG_ES256, key), 0);
+	ck_assert_int_eq(jwt_builder_settyp(b, "openid4vci-proof+jwt"), 0);
+	jwt_set_SET_JSON(&v, "jwk", PUB_JWK_P256);
+	ck_assert_int_eq(jwt_builder_header_set(b, &v), JWT_VALUE_ERR_NONE);
+	set_str(b, "iss", "client-42");
+	set_str(b, "aud", "https://issuer.example");
+	set_str(b, "nonce", "c_nonce_from_issuer");
+	token = jwt_builder_generate(b);
+	ck_assert_ptr_nonnull(token);
+
+	c = jwt_checker_new();
+	ck_assert_int_eq(jwt_checker_expect_typ(c, "openid4vci-proof+jwt"), 0);
+	ck_assert_int_eq(jwt_checker_setalgs(c, algs, 1), 0);
+	ck_assert_int_eq(jwt_checker_enable_embedded_jwk(c,
+				JWK_THUMBPRINT_SHA256, jkt), 0);
+	ck_assert_int_eq(jwt_checker_verify(c, token), 0);
+
+	jwks_free(set);
+}
+END_TEST
+
+/* ---- RFC 9449 DPoP (typ "dpop+jwt") ----------------------------------- */
+struct dpop_ctx {
+	const char *access_token;
+	int ok;
+};
+
+static int dpop_cb(jwt_t *jwt, jwt_config_t *config)
+{
+	struct dpop_ctx *d = config->ctx;
+	jwt_value_t v;
+	char *ath;
+	const char *htm, *got_ath;
+
+	jwt_set_GET_STR(&v, "htm");
+	if (jwt_claim_get(jwt, &v) != JWT_VALUE_ERR_NONE)
+		return 1;
+	htm = v.str_val;
+
+	jwt_set_GET_STR(&v, "ath");
+	if (jwt_claim_get(jwt, &v) != JWT_VALUE_ERR_NONE)
+		return 1;
+	got_ath = v.str_val;
+
+	/* ath = base64url(SHA-256(access_token)). */
+	ath = jwt_token_hash(d->access_token, JWK_THUMBPRINT_SHA256);
+	d->ok = (ath != NULL && !strcmp(ath, got_ath) && !strcmp(htm, "POST"));
+	free(ath);
+
+	return 0;
+}
+
+START_TEST(test_dpop)
+{
+	jwk_set_t *set;
+	const jwk_item_t *key;
+	jwt_builder_auto_t *atb = NULL, *pb = NULL;
+	jwt_checker_auto_t *c = NULL;
+	char_auto *jkt = NULL;
+	char_auto *access_token = NULL;
+	char_auto *ath = NULL;
+	char_auto *proof = NULL;
+	jwt_value_t v;
+	struct dpop_ctx d;
+
+	SET_OPS();
+
+	set = load_key();
+	key = jwks_item_get(set, 0);
+	jkt = jwks_item_thumbprint(key, JWK_THUMBPRINT_SHA256);
+
+	/* The AS issues an access token bound to the holder key via cnf.jkt. */
+	atb = jwt_builder_new();
+	ck_assert_int_eq(jwt_builder_setkey(atb, JWT_ALG_ES256, key), 0);
+	ck_assert_int_eq(jwt_builder_setcnf_jkt(atb, key), 0);
+	set_str(atb, "sub", "user-1");
+	access_token = jwt_builder_generate(atb);
+	ck_assert_ptr_nonnull(access_token);
+
+	ath = jwt_token_hash(access_token, JWK_THUMBPRINT_SHA256);
+	ck_assert_ptr_nonnull(ath);
+
+	/* The client makes a DPoP proof carrying its key in the header. */
+	pb = jwt_builder_new();
+	ck_assert_int_eq(jwt_builder_setkey(pb, JWT_ALG_ES256, key), 0);
+	ck_assert_int_eq(jwt_builder_settyp(pb, "dpop+jwt"), 0);
+	jwt_set_SET_JSON(&v, "jwk", PUB_JWK_P256);
+	ck_assert_int_eq(jwt_builder_header_set(pb, &v), JWT_VALUE_ERR_NONE);
+	set_str(pb, "htm", "POST");
+	set_str(pb, "htu", "https://rs.example/resource");
+	set_str(pb, "jti", "Xy123");
+	set_str(pb, "ath", ath);
+	proof = jwt_builder_generate(pb);
+	ck_assert_ptr_nonnull(proof);
+
+	/* The RS confirms the proof's self-key against the AT's cnf.jkt and that
+	 * its ath binds to the presented access token. */
+	memset(&d, 0, sizeof(d));
+	d.access_token = access_token;
+
+	c = jwt_checker_new();
+	ck_assert_int_eq(jwt_checker_expect_typ(c, "dpop+jwt"), 0);
+	{
+		const jwt_alg_t algs[] = { JWT_ALG_ES256 };
+		ck_assert_int_eq(jwt_checker_setalgs(c, algs, 1), 0);
+	}
+	ck_assert_int_eq(jwt_checker_enable_embedded_jwk(c,
+				JWK_THUMBPRINT_SHA256, jkt), 0);
+	ck_assert_int_eq(jwt_checker_setcb(c, dpop_cb, &d), 0);
+	ck_assert_int_eq(jwt_checker_verify(c, proof), 0);
+	ck_assert_int_eq(d.ok, 1);
+
+	jwks_free(set);
+}
+END_TEST
+
+/* ---- RFC 8705 OAuth 2.0 mutual-TLS certificate-bound token ------------- */
+struct mtls_ctx {
+	const char *presented_x5t;	/* SHA-256 thumbprint of the client cert */
+	int ok;
+};
+
+static int mtls_cb(jwt_t *jwt, jwt_config_t *config)
+{
+	struct mtls_ctx *m = config->ctx;
+	char *bound = jwt_get_cnf(jwt, "x5t#S256");
+
+	/* The token is accepted only if its cnf.x5t#S256 equals the thumbprint
+	 * of the certificate presented in the TLS handshake (computed by the
+	 * caller / TLS terminator). */
+	m->ok = (bound != NULL && !strcmp(bound, m->presented_x5t));
+	free(bound);
+
+	return 0;
+}
+
+START_TEST(test_mtls)
+{
+	jwk_set_t *set;
+	const jwk_item_t *key;
+	jwt_builder_auto_t *b = NULL;
+	jwt_checker_auto_t *c = NULL;
+	char_auto *token = NULL;
+	struct mtls_ctx m;
+	const char *cert_thumb = "bwcK0esc3ACC3DB2Y5_lESsXE8u9ie-9UWlCEx4dyk8";
+
+	SET_OPS();
+
+	set = load_key();
+	key = jwks_item_get(set, 0);
+
+	/* The AS binds the access token to the client certificate. */
+	b = jwt_builder_new();
+	ck_assert_int_eq(jwt_builder_setkey(b, JWT_ALG_ES256, key), 0);
+	ck_assert_int_eq(jwt_builder_settyp(b, "at+jwt"), 0);
+	ck_assert_int_eq(jwt_builder_setcnf(b, "x5t#S256", cert_thumb), 0);
+	set_str(b, "iss", "https://as.example");
+	set_str(b, "sub", "user-1");
+	token = jwt_builder_generate(b);
+	ck_assert_ptr_nonnull(token);
+
+	memset(&m, 0, sizeof(m));
+	m.presented_x5t = cert_thumb;
+
+	c = jwt_checker_new();
+	ck_assert_int_eq(jwt_checker_setkey(c, JWT_ALG_ES256, key), 0);
+	ck_assert_int_eq(jwt_checker_expect_typ(c, "at+jwt"), 0);
+	ck_assert_int_eq(jwt_checker_setcb(c, mtls_cb, &m), 0);
+	ck_assert_int_eq(jwt_checker_verify(c, token), 0);
+	ck_assert_int_eq(m.ok, 1);
+
+	jwks_free(set);
+}
+END_TEST
+
+/* ---- ETSI 119 182-1 JAdES: detached payload + x5c cert chain ----------- */
+struct jades_ctx {
+	int has_x5c;
+};
+
+static int jades_cb(jwt_t *jwt, jwt_config_t *config)
+{
+	struct jades_ctx *j = config->ctx;
+	jwt_value_t v;
+
+	/* The signing certificate chain rides in the protected "x5c" header. */
+	jwt_set_GET_JSON(&v, "x5c");
+	if (jwt_header_get(jwt, &v) == JWT_VALUE_ERR_NONE) {
+		j->has_x5c = (v.json_val != NULL &&
+			      strstr(v.json_val, "MII") != NULL);
+		free(v.json_val);
+	}
+
+	return 0;
+}
+
+START_TEST(test_jades)
+{
+	jwk_set_t *set;
+	const jwk_item_t *key;
+	jwt_builder_auto_t *b = NULL;
+	jwt_checker_auto_t *c = NULL;
+	char_auto *token = NULL;
+	jwt_value_t v;
+	struct jades_ctx j;
+	const unsigned char payload[] = "JAdES signs this detached document";
+
+	SET_OPS();
+
+	set = load_key();
+	key = jwks_item_get(set, 0);
+
+	/* Sign a detached payload; carry the cert chain in x5c. */
+	b = jwt_builder_new();
+	ck_assert_int_eq(jwt_builder_setkey(b, JWT_ALG_ES256, key), 0);
+	ck_assert_int_eq(jwt_builder_setpayload(b, payload,
+				sizeof(payload) - 1), 0);
+	/* The document is opaque, not JSON claims: sign it unencoded (RFC 7797). */
+	ck_assert_int_eq(jwt_builder_setb64(b, 0), 0);
+	ck_assert_int_eq(jwt_builder_set_detached(b, 1), 0);
+	jwt_set_SET_JSON(&v, "x5c", "[\"MIIBdummyLeafCertBase64==\"]");
+	ck_assert_int_eq(jwt_builder_header_set(b, &v), JWT_VALUE_ERR_NONE);
+	token = jwt_builder_generate(b);
+	ck_assert_ptr_nonnull(token);
+
+	memset(&j, 0, sizeof(j));
+
+	c = jwt_checker_new();
+	ck_assert_int_eq(jwt_checker_setkey(c, JWT_ALG_ES256, key), 0);
+	ck_assert_int_eq(jwt_checker_setcb(c, jades_cb, &j), 0);
+	/* The detached payload is supplied out of band. */
+	ck_assert_int_eq(jwt_checker_verify_detached(c, token, payload,
+				sizeof(payload) - 1), 0);
+	ck_assert_int_eq(j.has_x5c, 1);
+
+	jwks_free(set);
+}
+END_TEST
+
+static Suite *libjwt_suite(const char *title)
+{
+	Suite *s;
+	TCase *tc_core;
+	int i = ARRAY_SIZE(jwt_test_ops);
+
+	s = suite_create(title);
+
+	tc_core = tcase_create("jwt_profiles");
+
+	tcase_add_loop_test(tc_core, test_at_jwt, 0, i);
+	tcase_add_loop_test(tc_core, test_vapid, 0, i);
+	tcase_add_loop_test(tc_core, test_passport, 0, i);
+	tcase_add_loop_test(tc_core, test_openid4vci_proof, 0, i);
+	tcase_add_loop_test(tc_core, test_dpop, 0, i);
+	tcase_add_loop_test(tc_core, test_mtls, 0, i);
+	tcase_add_loop_test(tc_core, test_jades, 0, i);
+
+	tcase_set_timeout(tc_core, 30);
+
+	suite_add_tcase(s, tc_core);
+
+	return s;
+}
+
+int main(void)
+{
+	JWT_TEST_MAIN("LibJWT application profiles (#317)");
+}
diff --git a/tests/jwt_require.c b/tests/jwt_require.c
new file mode 100644
index 0000000..2ce355b
--- /dev/null
+++ b/tests/jwt_require.c
@@ -0,0 +1,251 @@
+/* Public domain, no copyright. Use at your own risk. */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "jwt_tests.h"
+
+/* Tests for jwt_checker_require(): assert a set of claims is PRESENT in a token,
+ * independent of any value match (the RFC 9068 mandatory-claims discipline). A
+ * token is built with iss/aud/sub/client_id/iat/exp but NO jti; requiring a
+ * present set verifies, requiring an absent claim (jti) is rejected. */
+
+static jwk_set_t *load_key(void)
+{
+	jwk_set_t *set = jwks_create_fromfile(KEYDIR "/ec_key_prime256v1.json");
+
+	ck_assert_ptr_nonnull(set);
+	return set;
+}
+
+/* An ES256 token carrying iss/aud/sub/client_id/iat/exp, optionally jti. */
+static char *make_token(const jwk_item_t *key, int with_jti)
+{
+	jwt_builder_auto_t *b = jwt_builder_new();
+	jwt_value_t v;
+
+	ck_assert_ptr_nonnull(b);
+	ck_assert_int_eq(jwt_builder_setkey(b, JWT_ALG_ES256, key), 0);
+
+	jwt_set_SET_STR(&v, "iss", "https://issuer.example");
+	ck_assert_int_eq(jwt_builder_claim_set(b, &v), JWT_VALUE_ERR_NONE);
+	jwt_set_SET_STR(&v, "aud", "https://api.example");
+	ck_assert_int_eq(jwt_builder_claim_set(b, &v), JWT_VALUE_ERR_NONE);
+	jwt_set_SET_STR(&v, "sub", "user-1");
+	ck_assert_int_eq(jwt_builder_claim_set(b, &v), JWT_VALUE_ERR_NONE);
+	jwt_set_SET_STR(&v, "client_id", "client-42");
+	ck_assert_int_eq(jwt_builder_claim_set(b, &v), JWT_VALUE_ERR_NONE);
+	if (with_jti) {
+		jwt_set_SET_STR(&v, "jti", "id-1");
+		ck_assert_int_eq(jwt_builder_claim_set(b, &v), JWT_VALUE_ERR_NONE);
+	}
+
+	/* A future exp (an int claim, to prove presence is type-agnostic). */
+	ck_assert_int_eq(jwt_builder_time_offset(b, JWT_CLAIM_EXP, 3600), 0);
+
+	return jwt_builder_generate(b);
+}
+
+START_TEST(test_require_present)
+{
+	jwk_set_t *set;
+	const jwk_item_t *key;
+	jwt_checker_auto_t *checker = NULL;
+	char_auto *token = NULL;
+	const char *names[] = { "iss", "exp", "aud", "sub", "client_id", "iat" };
+
+	SET_OPS();
+
+	set = load_key();
+	key = jwks_item_get(set, 0);
+	token = make_token(key, 0);
+	ck_assert_ptr_nonnull(token);
+
+	checker = jwt_checker_new();
+	ck_assert_int_eq(jwt_checker_setkey(checker, JWT_ALG_ES256, key), 0);
+	ck_assert_int_eq(jwt_checker_require(checker, names, ARRAY_SIZE(names)), 0);
+	ck_assert_int_eq(jwt_checker_verify(checker, token), 0);
+
+	jwks_free(set);
+}
+END_TEST
+
+START_TEST(test_require_missing_rejected)
+{
+	jwk_set_t *set;
+	const jwk_item_t *key;
+	jwt_checker_auto_t *checker = NULL;
+	char_auto *token = NULL;
+	const char *names[] = { "iss", "jti" };	/* jti is absent */
+
+	SET_OPS();
+
+	set = load_key();
+	key = jwks_item_get(set, 0);
+	token = make_token(key, 0);
+	ck_assert_ptr_nonnull(token);
+
+	checker = jwt_checker_new();
+	ck_assert_int_eq(jwt_checker_setkey(checker, JWT_ALG_ES256, key), 0);
+	ck_assert_int_eq(jwt_checker_require(checker, names, ARRAY_SIZE(names)), 0);
+	ck_assert_int_ne(jwt_checker_verify(checker, token), 0);
+	/* The error names the missing claim. */
+	ck_assert_ptr_nonnull(strstr(jwt_checker_error_msg(checker), "jti"));
+
+	jwks_free(set);
+}
+END_TEST
+
+START_TEST(test_require_present_with_jti)
+{
+	jwk_set_t *set;
+	const jwk_item_t *key;
+	jwt_checker_auto_t *checker = NULL;
+	char_auto *token = NULL;
+	const char *names[] = { "jti" };
+
+	SET_OPS();
+
+	set = load_key();
+	key = jwks_item_get(set, 0);
+	token = make_token(key, 1);	/* with jti */
+	ck_assert_ptr_nonnull(token);
+
+	checker = jwt_checker_new();
+	ck_assert_int_eq(jwt_checker_setkey(checker, JWT_ALG_ES256, key), 0);
+	ck_assert_int_eq(jwt_checker_require(checker, names, ARRAY_SIZE(names)), 0);
+	ck_assert_int_eq(jwt_checker_verify(checker, token), 0);
+
+	jwks_free(set);
+}
+END_TEST
+
+START_TEST(test_require_clear)
+{
+	jwk_set_t *set;
+	const jwk_item_t *key;
+	jwt_checker_auto_t *checker = NULL;
+	char_auto *token = NULL;
+	const char *names[] = { "jti" };
+
+	SET_OPS();
+
+	set = load_key();
+	key = jwks_item_get(set, 0);
+	token = make_token(key, 0);	/* no jti */
+	ck_assert_ptr_nonnull(token);
+
+	checker = jwt_checker_new();
+	ck_assert_int_eq(jwt_checker_setkey(checker, JWT_ALG_ES256, key), 0);
+
+	/* Require jti, then clear the requirement: the absent jti no longer fails. */
+	ck_assert_int_eq(jwt_checker_require(checker, names, 1), 0);
+	ck_assert_int_eq(jwt_checker_require(checker, NULL, 0), 0);
+	ck_assert_int_eq(jwt_checker_verify(checker, token), 0);
+
+	jwks_free(set);
+}
+END_TEST
+
+START_TEST(test_require_errors)
+{
+	jwk_set_t *set;
+	const jwk_item_t *key;
+	jwt_checker_auto_t *checker = NULL;
+	char_auto *token = NULL;
+	const char *good[] = { "iss" };
+	const char *bad[] = { "iss", "" };	/* empty name */
+
+	SET_OPS();
+
+	set = load_key();
+	key = jwks_item_get(set, 0);
+	token = make_token(key, 0);
+	ck_assert_ptr_nonnull(token);
+
+	ck_assert_int_ne(jwt_checker_require(NULL, good, 1), 0);
+
+	checker = jwt_checker_new();
+	ck_assert_int_eq(jwt_checker_setkey(checker, JWT_ALG_ES256, key), 0);
+
+	/* An empty name in the list is rejected and leaves no requirement set. */
+	ck_assert_int_ne(jwt_checker_require(checker, bad, ARRAY_SIZE(bad)), 0);
+	ck_assert_int_eq(jwt_checker_verify(checker, token), 0);
+
+	jwks_free(set);
+}
+END_TEST
+
+/* Required-claims must be enforced on the JSON Serialization too, not only the
+ * compact form (else a token could dodge the check by being JSON-wrapped). */
+START_TEST(test_require_json_serialization)
+{
+	jwk_set_t *set;
+	const jwk_item_t *key;
+	jwt_builder_auto_t *b = NULL;
+	jwt_checker_auto_t *c1 = NULL, *c2 = NULL;
+	char_auto *token = NULL;
+	jwt_value_t v;
+	const char *need_iss[] = { "iss" };
+	const char *need_jti[] = { "jti" };
+
+	SET_OPS();
+
+	set = load_key();
+	key = jwks_item_get(set, 0);
+
+	/* A Flattened JSON token (RFC 7515 7.2.2) with iss but no jti. */
+	b = jwt_builder_new();
+	ck_assert_int_eq(jwt_builder_setkey(b, JWT_ALG_ES256, key), 0);
+	ck_assert_int_eq(jwt_builder_set_format(b, JWT_FORMAT_JSON_FLAT), 0);
+	jwt_set_SET_STR(&v, "iss", "https://issuer.example");
+	ck_assert_int_eq(jwt_builder_claim_set(b, &v), JWT_VALUE_ERR_NONE);
+	token = jwt_builder_generate(b);
+	ck_assert_ptr_nonnull(token);
+	ck_assert_int_eq(token[0], '{');	/* a JSON serialization */
+
+	/* Present passes... */
+	c1 = jwt_checker_new();
+	ck_assert_int_eq(jwt_checker_setkey(c1, JWT_ALG_ES256, key), 0);
+	ck_assert_int_eq(jwt_checker_require(c1, need_iss, 1), 0);
+	ck_assert_int_eq(jwt_checker_verify(c1, token), 0);
+
+	/* ...absent is rejected (the JSON path enforces it). */
+	c2 = jwt_checker_new();
+	ck_assert_int_eq(jwt_checker_setkey(c2, JWT_ALG_ES256, key), 0);
+	ck_assert_int_eq(jwt_checker_require(c2, need_jti, 1), 0);
+	ck_assert_int_ne(jwt_checker_verify(c2, token), 0);
+
+	jwks_free(set);
+}
+END_TEST
+
+static Suite *libjwt_suite(const char *title)
+{
+	Suite *s;
+	TCase *tc_core;
+	int i = ARRAY_SIZE(jwt_test_ops);
+
+	s = suite_create(title);
+
+	tc_core = tcase_create("jwt_require");
+
+	tcase_add_loop_test(tc_core, test_require_present, 0, i);
+	tcase_add_loop_test(tc_core, test_require_missing_rejected, 0, i);
+	tcase_add_loop_test(tc_core, test_require_present_with_jti, 0, i);
+	tcase_add_loop_test(tc_core, test_require_clear, 0, i);
+	tcase_add_loop_test(tc_core, test_require_json_serialization, 0, i);
+	tcase_add_loop_test(tc_core, test_require_errors, 0, i);
+
+	tcase_set_timeout(tc_core, 30);
+
+	suite_add_tcase(s, tc_core);
+
+	return s;
+}
+
+int main(void)
+{
+	JWT_TEST_MAIN("LibJWT required-claims (jwt_checker_require)");
+}