Merge pull request #329 from benmcollins/315-rfc7797-b64

jwt: RFC 7797 unencoded (b64=false) + detached payloads
diff --git a/CMakeLists.txt b/CMakeLists.txt
index e86e7aa..96417bd 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -415,6 +415,9 @@
 	# JWS JSON Serialization (multi-signature)
 	list (APPEND UNIT_TESTS jws_json)
 
+	# RFC 7797 unencoded / detached payload
+	list (APPEND UNIT_TESTS jws_b64)
+
 	# 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 38e0cde..f366b40 100644
--- a/README.md
+++ b/README.md
@@ -99,6 +99,18 @@
 every compatible key, always under the usual algorithm/key-type binding.
 `jwt_checker_verify()` auto-detects Compact vs JSON input.
 
+##### Unencoded and detached payloads (RFC 7797)
+
+For a generic JWS over an opaque (non-claims) payload — e.g. HTTP message
+signatures or JAdES/eIDAS detached signatures — set the payload bytes with
+`jwt_builder_setpayload()`. `jwt_builder_setb64(b, 0)` selects the
+[RFC 7797](https://datatracker.ietf.org/doc/html/rfc7797) unencoded form
+(`"b64":false`, with `"b64"` marked critical and the signature computed over the
+raw payload), and `jwt_builder_set_detached()` omits the payload from the output.
+A detached token is verified with `jwt_checker_verify_detached()`, supplying the
+payload out-of-band. As mandated by RFC 7797 §6, the checker rejects a
+`"b64":false` token unless `"b64"` appears in `"crit"`.
+
 #### JWE
 
 LibJWT supports JWE (RFC 7516) in both the Compact Serialization and the JSON
diff --git a/include/jwt.h b/include/jwt.h
index f7dce39..52c577d 100644
--- a/include/jwt.h
+++ b/include/jwt.h
@@ -606,6 +606,59 @@
 				  const char *key, const char *value_json);
 
 /**
+ * @brief Sign an opaque (non-claims) payload
+ *
+ * Sets a raw payload to be signed verbatim instead of JSON claims, for a
+ * generic JWS (RFC 7515) — for example RFC 7797 unencoded payloads or a
+ * detached signature over an arbitrary document (JAdES). This is mutually
+ * exclusive with claims: when a raw payload is set it is used and any claims
+ * are ignored. Pass @p data as NULL (or @p len 0) to clear it and return to
+ * the claims model. The bytes are copied.
+ *
+ * @param builder Pointer to a builder object
+ * @param data The payload bytes (copied), or NULL to clear
+ * @param len The payload length in bytes
+ * @return 0 on success, non-zero otherwise with error set in the builder
+ * @since 3.6.0
+ */
+JWT_EXPORT
+int jwt_builder_setpayload(jwt_builder_t *builder, const unsigned char *data,
+			   size_t len);
+
+/**
+ * @brief Select base64url or unencoded payload (RFC 7797)
+ *
+ * With @p b64 nonzero (the default) the payload is base64url-encoded as usual.
+ * With @p b64 zero, @rfc{7797} unencoded payloads are produced: ``"b64":false``
+ * is emitted in the protected header, ``"b64"`` is added to ``"crit"``, and the
+ * signature is computed over the **raw** payload bytes. Requires a raw payload
+ * (jwt_builder_setpayload()), since RFC 7797 forbids the option for JWTs.
+ *
+ * @param builder Pointer to a builder object
+ * @param b64 Nonzero for base64url (default), zero for an unencoded payload
+ * @return 0 on success, non-zero otherwise with error set in the builder
+ * @since 3.6.0
+ */
+JWT_EXPORT
+int jwt_builder_setb64(jwt_builder_t *builder, int b64);
+
+/**
+ * @brief Detach the payload from the output
+ *
+ * With @p detached nonzero, the produced token omits the payload (an empty
+ * payload part in the Compact form, no ``"payload"`` member in the JSON forms).
+ * The verifier must supply the payload out-of-band with
+ * jwt_checker_verify_detached(). Orthogonal to jwt_builder_setb64().
+ *
+ * @param builder Pointer to a builder object
+ * @param detached Nonzero to omit the payload from the output
+ * @return 0 on success, non-zero otherwise with error set in the builder
+ * @since 3.6.0
+ */
+JWT_EXPORT
+int jwt_builder_set_detached(jwt_builder_t *builder, int detached);
+
+/**
  * @brief Set IssuedAt usage on builder
  *
  * By default, the builder will set the ``iat`` claim to all tokens. You can
@@ -1077,6 +1130,27 @@
 				      unsigned int index);
 
 /**
+ * @brief Verify a token whose payload was detached
+ *
+ * Verifies a token produced with jwt_builder_set_detached() (or any JWS with a
+ * detached payload), supplying the payload out-of-band. Works for both
+ * base64url and @rfc{7797} unencoded (``"b64":false``) payloads; the signing
+ * input is reconstructed from @p payload according to the token's ``"b64"``
+ * header. As with jwt_checker_verify(), an unencoded payload is accepted only
+ * if ``"b64"`` is present in the token's ``"crit"`` header (RFC 7797 §6).
+ *
+ * @param checker Pointer to a checker object
+ * @param token A string containing the (detached) token to verify
+ * @param payload The out-of-band payload bytes
+ * @param len The payload length in bytes
+ * @return 0 on success, non-zero otherwise with error set in the checker
+ * @since 3.6.0
+ */
+JWT_EXPORT
+int jwt_checker_verify_detached(jwt_checker_t *checker, const char *token,
+				const unsigned char *payload, size_t len);
+
+/**
  * @}
  * @noop jwt_checker_grp
  */
diff --git a/libjwt/jwt-common.c b/libjwt/jwt-common.c
index 7990085..ee33ce4 100644
--- a/libjwt/jwt-common.c
+++ b/libjwt/jwt-common.c
@@ -52,6 +52,9 @@
 		}
 	}
 
+	/* @rfc{7797} Free any raw payload (jwt_builder_setpayload()). */
+	jwt_scrub_and_free(__cmd->c.payload_raw, __cmd->c.payload_raw_len);
+
 	memset(__cmd, 0, sizeof(*__cmd));
 
 	jwt_freemem(__cmd);
@@ -70,6 +73,9 @@
 	 * (builder) or the JSON parse (checker) materializes it. */
 	INIT_LIST_HEAD(&__cmd->c.signatures);
 
+	/* @rfc{7797} base64url payloads by default (b64=true). */
+	__cmd->c.b64 = 1;
+
 	__cmd->c.payload = jwt_json_create();
 	__cmd->c.headers = jwt_json_create();
 	__cmd->c.claims = CLAIMS_DEF;
@@ -715,6 +721,51 @@
 	return 0;
 }
 
+int jwt_builder_setpayload(jwt_builder_t *builder, const unsigned char *data,
+			   size_t len)
+{
+	unsigned char *copy = NULL;
+
+	if (builder == NULL)
+		return 1;
+
+	if (data != NULL && len > 0) {
+		copy = jwt_malloc(len);
+		if (copy == NULL) {
+			jwt_write_error(builder, "Error allocating memory"); // LCOV_EXCL_LINE
+			return 1; // LCOV_EXCL_LINE
+		}
+		memcpy(copy, data, len);
+	}
+
+	/* Replace any previous raw payload (NULL/0 clears it). */
+	jwt_scrub_and_free(builder->c.payload_raw, builder->c.payload_raw_len);
+	builder->c.payload_raw = copy;
+	builder->c.payload_raw_len = copy ? len : 0;
+
+	return 0;
+}
+
+int jwt_builder_setb64(jwt_builder_t *builder, int b64)
+{
+	if (builder == NULL)
+		return 1;
+
+	builder->c.b64 = b64 ? 1 : 0;
+
+	return 0;
+}
+
+int jwt_builder_set_detached(jwt_builder_t *builder, int detached)
+{
+	if (builder == NULL)
+		return 1;
+
+	builder->c.detached = detached ? 1 : 0;
+
+	return 0;
+}
+
 jwt_signature_t *jwt_builder_add_signature(jwt_builder_t *builder,
 					   jwt_alg_t alg, const jwk_item_t *key)
 {
@@ -831,6 +882,37 @@
 	jwt->alg = config.alg;
 	jwt->key = config.key;
 
+	/* @rfc{7797} Thread the unencoded/detached options onto the token. */
+	jwt->b64 = __cmd->c.b64;
+	jwt->detached = __cmd->c.detached;
+	jwt->payload_raw = __cmd->c.payload_raw;
+	jwt->payload_raw_len = __cmd->c.payload_raw_len;
+
+	if (!jwt->b64) {
+		/* RFC 7797 forbids b64=false for JWTs (JSON claim sets), so it
+		 * is only valid over a raw payload. */
+		if (jwt->payload_raw == NULL) {
+			jwt_write_error(__cmd,
+				"b64=false (RFC 7797) requires a raw payload "
+				"set with jwt_builder_setpayload()");
+			return NULL;
+		}
+		/* An unencoded payload appears verbatim in the serialized token
+		 * (a C string / JSON string), which cannot carry an embedded NUL.
+		 * Reject it rather than silently truncate. */
+		if (memchr(jwt->payload_raw, '\0', jwt->payload_raw_len) != NULL) {
+			jwt_write_error(__cmd,
+				"An unencoded (b64=false) payload must not "
+				"contain a NUL byte");
+			return NULL;
+		}
+		/* @rfc{7797,6} Emit "b64":false and mark "b64" critical. */
+		if (jwt_apply_b64_header(jwt)) {
+			jwt_copy_error(__cmd, jwt);
+			return NULL;
+		}
+	}
+
 	/* @rfc{7515,4.1.11} Emit the "crit" header if any were registered.
 	 * Done after the callback so it can add the headers being marked. */
 	if (jwt_write_crit(jwt, __cmd->c.understood)) {
diff --git a/libjwt/jwt-encode.c b/libjwt/jwt-encode.c
index 0167ef5..6c496bd 100644
--- a/libjwt/jwt-encode.c
+++ b/libjwt/jwt-encode.c
@@ -216,17 +216,13 @@
 {
 	char_auto *payload = NULL;
 	char *buf = NULL;
-	int payload_len;
+	size_t payload_len;
 	struct jwt_signature *s;
 	jwt_json_auto_t *out_obj = NULL;
 	jwt_json_t *sig_arr = NULL, *v;
 
-	/* Shared payload, encoded once. */
-	if (write_js(jwt->claims, &buf))
-		return NULL; // LCOV_EXCL_LINE
-	payload_len = jwt_base64uri_encode(&payload, buf, (int)strlen(buf));
-	jwt_freemem(buf);
-	if (payload_len <= 0) {
+	/* @rfc{7797} Shared payload (base64url or raw), encoded once. */
+	if (jwt_build_payload_part(jwt, &payload, &payload_len)) {
 		jwt_write_error(jwt, "Error encoding payload"); // LCOV_EXCL_LINE
 		return NULL; // LCOV_EXCL_LINE
 	}
@@ -235,9 +231,12 @@
 	if (out_obj == NULL)
 		return NULL; // LCOV_EXCL_LINE
 
-	v = jwt_json_create_str(payload);
-	if (v == NULL || jwt_json_obj_set(out_obj, "payload", v))
-		return NULL; // LCOV_EXCL_LINE
+	/* @rfc{7515,7.2.1} Detached: omit "payload" (supplied out-of-band). */
+	if (!jwt->detached) {
+		v = jwt_json_create_str(payload);
+		if (v == NULL || jwt_json_obj_set(out_obj, "payload", v))
+			return NULL; // LCOV_EXCL_LINE
+	}
 
 	if (cmd->format == JWT_FORMAT_JSON_GENERAL) {
 		sig_arr = jwt_json_create_arr();
@@ -251,6 +250,7 @@
 		char_auto *prot_b64 = NULL, *sig_b64 = NULL, *input = NULL;
 		char *rawsig = NULL;
 		unsigned int rawsig_len = 0;
+		size_t input_len;
 		int prot_len, enc;
 		jwt_alg_t alg;
 
@@ -282,16 +282,23 @@
 			jwt_write_error(jwt, "Error encoding protected header"); // LCOV_EXCL_LINE
 			return NULL; // LCOV_EXCL_LINE
 		}
+		/* The return counts stripped '=' padding; use the true length. */
+		prot_len = (int)strlen(prot_b64);
 
-		/* Signing input: BASE64URL(protected) "." BASE64URL(payload). */
-		input = jwt_malloc((size_t)prot_len + payload_len + 2);
+		/* @rfc{7797,3} Signing input: BASE64URL(protected) "." payload
+		 * (payload is base64url or raw per b64; binary-safe). */
+		input_len = (size_t)prot_len + 1 + payload_len;
+		input = jwt_malloc(input_len + 1);
 		if (input == NULL)
 			return NULL; // LCOV_EXCL_LINE
-		sprintf(input, "%s.%s", prot_b64, payload);
+		memcpy(input, prot_b64, prot_len);
+		input[prot_len] = '.';
+		memcpy(input + prot_len + 1, payload, payload_len);
+		input[input_len] = '\0';
 
 		jwt->alg = alg;
 		jwt->key = s->key;
-		if (jwt_sign(jwt, &rawsig, &rawsig_len, input, strlen(input)))
+		if (jwt_sign(jwt, &rawsig, &rawsig_len, input, input_len))
 			return NULL;
 
 		enc = jwt_base64uri_encode(&sig_b64, rawsig, (int)rawsig_len);
@@ -510,11 +517,96 @@
 	return 0;
 }
 
+/* @rfc{7797} Build the payload as it appears after the first '.': the raw
+ * payload bytes (jwt_builder_setpayload()) or the serialized claims, then
+ * base64url-encoded unless b64 is false. Returns a malloc'd, NUL-terminated
+ * buffer (binary-safe via @out_len). 0 on success. */
+int jwt_build_payload_part(jwt_t *jwt, char **out, size_t *out_len)
+{
+	const unsigned char *bytes;
+	char_auto *claims_str = NULL;
+	size_t len;
+
+	if (jwt->payload_raw != NULL) {
+		bytes = jwt->payload_raw;
+		len = jwt->payload_raw_len;
+	} else {
+		if (write_js(jwt->claims, &claims_str))
+			return 1; // LCOV_EXCL_LINE
+		bytes = (const unsigned char *)claims_str;
+		len = strlen(claims_str);
+	}
+
+	if (jwt->b64) {
+		int n;
+
+		if (len > INT_MAX)
+			return 1; // LCOV_EXCL_LINE
+		n = jwt_base64uri_encode(out, (const char *)bytes, (int)len);
+		if (n <= 0)
+			return 1; // LCOV_EXCL_LINE
+		/* The return counts stripped '=' padding; the string is shorter. */
+		*out_len = strlen(*out);
+	} else {
+		char *copy = jwt_malloc(len + 1);
+
+		if (copy == NULL)
+			return 1; // LCOV_EXCL_LINE
+		memcpy(copy, bytes, len);
+		copy[len] = '\0';
+		*out = copy;
+		*out_len = len;
+	}
+
+	return 0;
+}
+
+/* @rfc{7797,6} For an unencoded payload, the protected header must carry
+ * "b64":false and "b64" MUST be marked critical. Inject both into @jwt->headers
+ * (the shared base, which the compact path signs directly and the JSON path
+ * clones into each signature). */
+int jwt_apply_b64_header(jwt_t *jwt)
+{
+	jwt_json_t *crit, *ent, *v;
+	size_t i;
+	int found = 0;
+
+	v = jwt_json_create_bool(0);
+	if (v == NULL || jwt_json_obj_set(jwt->headers, "b64", v))
+		return 1; // LCOV_EXCL_LINE
+
+	crit = jwt_json_obj_get(jwt->headers, "crit");
+	if (crit == NULL) {
+		crit = jwt_json_create_arr();
+		if (crit == NULL || jwt_json_obj_set(jwt->headers, "crit", crit))
+			return 1; // LCOV_EXCL_LINE
+	} else if (!jwt_json_is_array(crit)) {
+		jwt_write_error(jwt, "\"crit\" header must be an array");
+		return 1;
+	}
+
+	jwt_json_arr_foreach(crit, i, ent) {
+		if (jwt_json_is_string(ent) &&
+		    !strcmp(jwt_json_str_val(ent), "b64")) {
+			found = 1;
+			break;
+		}
+	}
+	if (!found) {
+		v = jwt_json_create_str("b64");
+		if (v == NULL || jwt_json_arr_append(crit, v))
+			return 1; // LCOV_EXCL_LINE
+	}
+
+	return 0;
+}
+
 static int jwt_encode(jwt_t *jwt, char **out)
 {
-	char_auto *head = NULL, *payload = NULL, *sig = NULL;
-	char *buf = NULL;
-	int ret, head_len, payload_len;
+	char_auto *head = NULL, *payload = NULL, *sig_b64 = NULL;
+	char *buf = NULL, *si = NULL, *token = NULL, *p;
+	int head_len, ret;
+	size_t payload_len, si_len, pout_len, token_len, sb_len;
 	unsigned int sig_len;
 
 	if (out == NULL) {
@@ -525,110 +617,95 @@
 	}
 	*out = NULL;
 
-	/* First the header. */
-	ret = write_js(jwt->headers, &buf);
-	if (ret)
+	/* Header. */
+	if (write_js(jwt->headers, &buf))
 		return 1; // LCOV_EXCL_LINE
-	/* Encode it */
 	head_len = jwt_base64uri_encode(&head, buf, (int)strlen(buf));
 	jwt_freemem(buf);
-
 	if (head_len <= 0) {
 		// LCOV_EXCL_START
 		jwt_write_error(jwt, "Error encoding header");
 		return 1;
 		// LCOV_EXCL_STOP
 	}
+	/* The return counts stripped '=' padding; use the true string length. */
+	head_len = (int)strlen(head);
 
-	/* Now the payload. */
-	ret = write_js(jwt->claims, &buf);
-	if (ret) {
-		// LCOV_EXCL_START
-		jwt_write_error(jwt, "Error writing payload");
-		return 1;
-		// LCOV_EXCL_STOP
+	/* @rfc{7797} Payload part (base64url or raw). */
+	if (jwt_build_payload_part(jwt, &payload, &payload_len)) {
+		jwt_write_error(jwt, "Error encoding payload"); // LCOV_EXCL_LINE
+		return 1; // LCOV_EXCL_LINE
 	}
 
-	payload_len = jwt_base64uri_encode(&payload, buf, (int)strlen(buf));
-	jwt_freemem(buf);
-
-	if (payload_len <= 0) {
-		// LCOV_EXCL_START
-		jwt_write_error(jwt, "Error encoding payload");
-		return 1;
-		// LCOV_EXCL_STOP
+	if ((size_t)head_len > SIZE_MAX - payload_len - 2) {
+		jwt_write_error(jwt, "Encoded token too large"); // LCOV_EXCL_LINE
+		return 1; // LCOV_EXCL_LINE
 	}
 
-	/* head_len and payload_len are each bounded by jwt_base64uri_encode to
-	 * encode within INT_MAX, but guard their sum so head_len + payload_len + 3
-	 * cannot overflow the int allocation size below. */
-	if (head_len > INT_MAX - payload_len - 3) {
-		// LCOV_EXCL_START
-		jwt_write_error(jwt, "Encoded token too large");
-		return 1;
-		// LCOV_EXCL_STOP
+	/* Signing input: BASE64URL(header) "." payload (binary-safe). */
+	si_len = (size_t)head_len + 1 + payload_len;
+	si = jwt_malloc(si_len + 1);
+	if (si == NULL) {
+		jwt_write_error(jwt, "Error allocating memory"); // LCOV_EXCL_LINE
+		return 1; // LCOV_EXCL_LINE
 	}
+	memcpy(si, head, head_len);
+	si[head_len] = '.';
+	memcpy(si + head_len + 1, payload, payload_len);
+	si[si_len] = '\0';
 
-	/* The part we need to sign, but add space for 2 dots and a nil */
-	buf = jwt_malloc(head_len + payload_len + 3);
-	if (buf == NULL) {
-		// LCOV_EXCL_START
-		jwt_write_error(jwt, "Error allocating memory");
-		return 1;
-		// LCOV_EXCL_STOP
-	}
-
-	strcpy(buf, head);
-	strcat(buf, ".");
-	strcat(buf, payload);
-
+	/* Signature (empty for an unsecured "none" token). */
 	if (jwt->alg == JWT_ALG_NONE) {
-		/* Add the trailing dot, and send it back */
-		strcat(buf, ".");
-		*out = buf;
-		return 0;
-	}
-
-	/* At this point buf has "head.payload" */
-
-	/* Now the signature. */
-	ret = jwt_sign(jwt, &sig, &sig_len, buf, strlen(buf));
-	jwt_freemem(buf);
-	if (ret) {
-		// LCOV_EXCL_START
-		jwt_write_error(jwt, "Error allocating memory");
-		return ret;
-		// LCOV_EXCL_STOP
-	}
-
-	ret = jwt_base64uri_encode(&buf, sig, sig_len);
-	/* At this point buf has b64 of sig and ret is size of it */
-
-	if (ret < 0) {
-		// LCOV_EXCL_START
-		jwt_write_error(jwt, "Error allocating memory");
-		return 1;
-		// LCOV_EXCL_STOP
-	}
-
-	/* plus 2 dots and a nil */
-	ret = strlen(head) + strlen(payload) + strlen(buf) + 3;
-
-	/* We're good, so let's get it all together */
-	*out = jwt_malloc(ret);
-	// LCOV_EXCL_START
-	if (*out == NULL) {
-		jwt_write_error(jwt, "Error allocating memory");
-		ret = 1;
+		sig_b64 = jwt_malloc(1);
+		if (sig_b64 != NULL)
+			sig_b64[0] = '\0';
 	} else {
-		sprintf(*out, "%s.%s.%s", head, payload, buf);
-		ret = 0;
+		char *rawsig = NULL;
+
+		ret = jwt_sign(jwt, &rawsig, &sig_len, si, si_len);
+		if (ret) {
+			jwt_freemem(si);
+			return ret;
+		}
+		ret = jwt_base64uri_encode(&sig_b64, rawsig, sig_len);
+		jwt_freemem(rawsig);
+		if (ret < 0) {
+			jwt_write_error(jwt, "Error encoding signature"); // LCOV_EXCL_LINE
+			jwt_freemem(si); // LCOV_EXCL_LINE
+			return 1; // LCOV_EXCL_LINE
+		}
 	}
-	// LCOV_EXCL_STOP
+	jwt_freemem(si);
 
-	jwt_freemem(buf);
+	if (sig_b64 == NULL) {
+		jwt_write_error(jwt, "Error allocating memory"); // LCOV_EXCL_LINE
+		return 1; // LCOV_EXCL_LINE
+	}
 
-	return ret;
+	/* @rfc{7797} Assemble: header "." (detached ? "" : payload) "." sig. */
+	sb_len = strlen(sig_b64);
+	pout_len = jwt->detached ? 0 : payload_len;
+	token_len = (size_t)head_len + 1 + pout_len + 1 + sb_len;	token = jwt_malloc(token_len + 1);
+	if (token == NULL) {
+		jwt_write_error(jwt, "Error allocating memory"); // LCOV_EXCL_LINE
+		return 1; // LCOV_EXCL_LINE
+	}
+	p = token;
+	memcpy(p, head, head_len);
+	p += head_len;
+	*p++ = '.';
+	if (pout_len) {
+		memcpy(p, payload, pout_len);
+		p += pout_len;
+	}
+	*p++ = '.';
+	memcpy(p, sig_b64, sb_len);
+	p += sb_len;
+	*p = '\0';
+
+	*out = token;
+
+	return 0;
 }
 
 char *jwt_encode_str(jwt_t *jwt)
diff --git a/libjwt/jwt-private.h b/libjwt/jwt-private.h
index 26435bf..b7e2242 100644
--- a/libjwt/jwt-private.h
+++ b/libjwt/jwt-private.h
@@ -112,6 +112,16 @@
 	const jwk_set_t *keyring;
 	jwt_verify_policy_t policy;
 	unsigned int last_sig_count;	/* signatures in the last JSON token	*/
+
+	/* --- @rfc{7797} Unencoded payload / detached payload ---
+	 * An opaque payload set via jwt_builder_setpayload() (mutually exclusive
+	 * with JSON claims); signed as-is. @b64 (default 1) selects base64url vs
+	 * the RFC 7797 unencoded ("b64":false) signing input; @detached omits the
+	 * payload from the output (supplied out-of-band on verify). */
+	unsigned char *payload_raw;
+	size_t payload_raw_len;
+	int b64;
+	int detached;
 };
 
 struct jwt_builder {
@@ -235,6 +245,15 @@
 	jwt_alg_t alg;
 	int error;
 	char error_msg[JWT_ERR_LEN];
+
+	/* @rfc{7797} An opaque payload (instead of JSON @claims), and the b64 /
+	 * detached flags, threaded from the builder/checker into encode/verify.
+	 * @payload_raw is borrowed (owned by jwt_common or the caller). */
+	const unsigned char *payload_raw;
+	size_t payload_raw_len;
+	int b64;
+	int detached;
+
 	union {
 		struct jwt_checker *checker;
 		struct jwt_builder *builder;
@@ -574,6 +593,17 @@
 JWT_NO_EXPORT
 char *jwt_encode_json(jwt_t *jwt, struct jwt_common *cmd);
 
+/* @rfc{7797} The payload as it appears after the first '.': raw bytes or
+ * serialized claims, base64url-encoded unless jwt->b64 is false. Malloc'd,
+ * NUL-terminated, binary-safe via @out_len. 0 on success. */
+JWT_NO_EXPORT
+int jwt_build_payload_part(jwt_t *jwt, char **out, size_t *out_len);
+
+/* @rfc{7797,6} Inject "b64":false + "b64" in "crit" into jwt->headers (for an
+ * unencoded payload). 0 on success. */
+JWT_NO_EXPORT
+int jwt_apply_b64_header(jwt_t *jwt);
+
 /* Checker: parse + verify a JWS JSON Serialization against the checker's
  * key/keyring and policy. Returns 0 if the policy is satisfied. */
 JWT_NO_EXPORT
diff --git a/libjwt/jwt-verify.c b/libjwt/jwt-verify.c
index b47b5cb..a02a25a 100644
--- a/libjwt/jwt-verify.c
+++ b/libjwt/jwt-verify.c
@@ -135,8 +135,13 @@
 			return 1;
 		}
 
+		/* @rfc{7797} The library handles "b64" itself, so it is always
+		 * understood (and is enforced separately during parsing). */
+		if (!strcmp(name, "b64"))
+			found = 1;
+
 		/* Must be understood by the application. */
-		if (understood) {
+		if (!found && understood) {
 			size_t j;
 
 			for (j = 0; understood[j] != NULL; j++) {
@@ -157,54 +162,123 @@
 	return 0;
 }
 
+/* @rfc{7797,6} Read the "b64" header (default true). A false value is the
+ * RFC 7797 unencoded payload option and is ONLY accepted if "b64" is also
+ * present in "crit" (else an attacker could flip the signing-input meaning).
+ * Sets *b64; returns 1 on the crit violation or a non-boolean "b64". */
+static int jwt_payload_b64(jwt_t *jwt, int *b64)
+{
+	jwt_json_t *jb64, *crit, *ent;
+	size_t i;
+	int found = 0;
+
+	*b64 = 1;
+
+	jb64 = jwt_json_obj_get(jwt->headers, "b64");
+	if (jb64 == NULL)
+		return 0;
+
+	if (!jwt_json_is_bool(jb64)) {
+		jwt_write_error(jwt, "\"b64\" header must be a boolean");
+		return 1;
+	}
+
+	if (jwt_json_is_true(jb64))
+		return 0;
+
+	*b64 = 0;
+
+	crit = jwt_json_obj_get(jwt->headers, "crit");
+	if (crit != NULL && jwt_json_is_array(crit)) {
+		jwt_json_arr_foreach(crit, i, ent) {
+			if (jwt_json_is_string(ent) &&
+			    !strcmp(jwt_json_str_val(ent), "b64")) {
+				found = 1;
+				break;
+			}
+		}
+	}
+	if (!found) {
+		jwt_write_error(jwt,
+			"\"b64\":false requires \"b64\" in \"crit\" (RFC 7797)");
+		return 1;
+	}
+
+	return 0;
+}
+
 int jwt_parse(jwt_t *jwt, const char *token, unsigned int *len)
 {
-	char_auto *head = NULL;
-	char *payload, *sig;
-	int head_len = strlen(token) + 1;
+	char_auto *buf = NULL;
+	char *payload, *sig, *dot, *lastdot = NULL;
+	int buf_len = strlen(token) + 1;
+	int b64;
 
-	head = jwt_malloc(head_len);
-	if (!head) {
+	buf = jwt_malloc(buf_len);
+	if (!buf) {
 		// LCOV_EXCL_START
 		jwt_write_error(jwt, "Error allocating memory");
 		return 1;
 		// LCOV_EXCL_STOP
 	}
 
-	/* head_len includes nil */
-	memcpy(head, token, head_len);
+	/* buf_len includes nil */
+	memcpy(buf, token, buf_len);
 
-	/* Find the components. */
-	for (payload = head; payload[0] != '.'; payload++) {
+	/* Header: everything up to the first '.'. */
+	for (payload = buf; payload[0] != '.'; payload++) {
 		if (payload[0] == '\0') {
 			jwt_write_error(jwt,
 				"No dot found looking for end of header");
 			return 1;
 		}
 	}
-
 	payload[0] = '\0';
 	payload++;
 
-	for (sig = payload; sig[0] != '.'; sig++) {
-		if (sig[0] == '\0') {
+	/* Parse the header now so we know the "b64" setting. */
+	if (jwt_parse_head(jwt, buf))
+		return 1;
+
+	/* @rfc{7797} Determine the payload encoding (and enforce crit). */
+	if (jwt_payload_b64(jwt, &b64))
+		return 1;
+	jwt->b64 = b64;
+
+	if (b64) {
+		/* Standard: the payload is between the 1st and 2nd '.'. */
+		for (sig = payload; sig[0] != '.'; sig++) {
+			if (sig[0] == '\0') {
+				jwt_write_error(jwt,
+					"No dot found looking for end of payload");
+				return 1;
+			}
+		}
+		sig[0] = '\0';
+		sig++;	/* point past the dot, as the b64=false branch does */
+
+		if (jwt_parse_payload(jwt, payload))
+			return 1;
+	} else {
+		/* @rfc{7797,5.2} Unencoded: the signature is after the LAST '.'
+		 * and the raw payload (which may itself contain '.') is between
+		 * the 1st and last '.'. The raw payload is not JSON claims. */
+		for (dot = payload; dot[0] != '\0'; dot++) {
+			if (dot[0] == '.')
+				lastdot = dot;
+		}
+		if (lastdot == NULL) {
 			jwt_write_error(jwt,
-				"No dot found looking for end of payload");
+				"No dot found looking for signature");
 			return 1;
 		}
+		*lastdot = '\0';
+		sig = lastdot + 1;
 	}
 
-	sig[0] = '\0';
-
-	/* Now that we have everything split up, let's check out the
-	 * header. */
-	if (jwt_parse_head(jwt, head))
-		return 1;
-
-	if (jwt_parse_payload(jwt, payload))
-		return 1;
-
-	*len = sig - head;
+	/* @rfc{7515,5.1}/@rfc{7797,3} The signing input is buf[0 .. sig's dot],
+	 * i.e. BASE64URL(header) "." (base64url or raw) payload. */
+	*len = (sig - 1) - buf;
 
 	return 0;
 }
@@ -349,8 +423,9 @@
 static int __verify_config_post(jwt_t *jwt, const jwt_config_t *config,
 				unsigned int sig_len)
 {
-	/* Yes, we do this before checking a signature. */
-	if (__verify_claims(jwt)) {
+	/* Yes, we do this before checking a signature. @rfc{7797} An unencoded
+	 * (b64=false) payload is opaque, not JSON claims, so skip claim checks. */
+	if (jwt->b64 && __verify_claims(jwt)) {
 		/* TODO Pass back the ORd list of claims failed. */
 		jwt_write_error(jwt, "Failed one or more claims");
 		return 1;
@@ -609,6 +684,17 @@
 		return 1;
 	}
 
+	/* @rfc{7797,6} Enforce "b64" in "crit" if this signature is unencoded. */
+	{
+		int eb64;
+
+		if (jwt_payload_b64(jwt, &eb64)) {
+			jwt_copy_error(checker, jwt);
+			jwt->headers = NULL;
+			return 1;
+		}
+	}
+
 	/* Seed the candidate: a kid-named keyring key (a binding assertion, no
 	 * fallback), or the checker's single key. A keyless keyring entry scans. */
 	kid = json_str(s->protected, "kid");
@@ -657,7 +743,7 @@
 	jwt_auto_t *jwt = NULL;
 	const char *payload_b64;
 	struct jwt_signature *s;
-	int n_verified = 0, sig_ok, payload_len;
+	int n_verified = 0, sig_ok, payload_len, pb64 = 1;
 	size_t n_entries;
 
 	/* Reset for a reused checker. */
@@ -720,6 +806,18 @@
 		return 1;
 	}
 
+	/* @rfc{7797} The shared payload's encoding comes from the (shared)
+	 * signatures; read it from the first one. An unencoded payload is opaque
+	 * (not JSON claims), so it is neither base64-decoded nor claim-checked. */
+	{
+		struct jwt_signature *first = jwt_signature_first(&checker->c);
+		jwt_json_t *jb = first ?
+			jwt_json_obj_get(first->protected, "b64") : NULL;
+
+		if (jb != NULL && jwt_json_is_bool(jb) && !jwt_json_is_true(jb))
+			pb64 = 0;
+	}
+
 	/* A transient JWT carrying the shared payload for the per-signature
 	 * verify and the read-only claim checks. */
 	jwt = jwt_new();
@@ -728,14 +826,21 @@
 		return 1; // LCOV_EXCL_LINE
 	}
 	jwt->checker = checker;
+	jwt->b64 = pb64;
 	jwt_json_releasep(&jwt->claims);
 	/* Release the empty header object jwt_new() allocated: from here on
 	 * jwt->headers only ever borrows each signature's protected header. */
 	jwt_json_releasep(&jwt->headers);
-	jwt->claims = jwt_base64uri_decode_to_json((char *)payload_b64);
-	if (jwt->claims == NULL) {
-		jwt_write_error(checker, "Error parsing payload");
-		return 1;
+	if (pb64) {
+		jwt->claims = jwt_base64uri_decode_to_json((char *)payload_b64);
+		if (jwt->claims == NULL) {
+			jwt_write_error(checker, "Error parsing payload");
+			return 1;
+		}
+	} else {
+		jwt->claims = jwt_json_create();
+		if (jwt->claims == NULL)
+			return 1; // LCOV_EXCL_LINE
 	}
 
 	list_for_each_entry(s, &checker->c.signatures, node) {
@@ -759,7 +864,7 @@
 		jwt_write_error(checker,
 			"Signature policy not met (%d of %zu verified)",
 			n_verified, n_entries);
-	} else if (__verify_claims(jwt)) {
+	} else if (jwt->b64 && __verify_claims(jwt)) {
 		jwt_write_error(checker, "Failed one or more claims");
 	} else if (__verify_jti(jwt)) {
 		/* jti runs only after signature + claims succeed. */
@@ -768,3 +873,151 @@
 
 	return checker->error;
 }
+
+/* Read the "b64" flag from a base64url-encoded protected header. Default 1. */
+static int detached_b64(const char *prot_b64)
+{
+	jwt_json_auto_t *hdr = NULL;
+	char_auto *copy = NULL;
+	jwt_json_t *jb;
+
+	if (prot_b64 == NULL)
+		return 1;
+	copy = jwt_str_dup(prot_b64);
+	if (copy == NULL)
+		return 1; // LCOV_EXCL_LINE
+	hdr = jwt_base64uri_decode_to_json(copy);
+	if (hdr == NULL)
+		return 1;
+	jb = jwt_json_obj_get(hdr, "b64");
+	if (jb != NULL && jwt_json_is_bool(jb) && !jwt_json_is_true(jb))
+		return 0;
+
+	return 1;
+}
+
+/* @rfc{7797,3} The payload as it appears in the signing input: base64url(@payload)
+ * when b64, else the raw bytes. Returns a malloc'd NUL-terminated string. */
+static char *detached_payload_part(int b64, const unsigned char *payload,
+				   size_t len)
+{
+	char *pp = NULL;
+
+	if (b64) {
+		if (len == 0)
+			return jwt_str_dup("");
+		if (len > INT_MAX ||
+		    jwt_base64uri_encode(&pp, (const char *)payload, (int)len) <= 0)
+			return NULL; // LCOV_EXCL_LINE
+		return pp;
+	}
+
+	pp = jwt_malloc(len + 1);
+	if (pp == NULL)
+		return NULL; // LCOV_EXCL_LINE
+	if (len)
+		memcpy(pp, payload, len);
+	pp[len] = '\0';
+
+	return pp;
+}
+
+int jwt_checker_verify_detached(jwt_checker_t *checker, const char *token,
+				const unsigned char *payload, size_t len)
+{
+	char_auto *pp = NULL, *recon = NULL;
+	const char *p;
+	int b64, ret;
+
+	if (checker == NULL)
+		return 1;
+
+	if (token == NULL || !strlen(token)) {
+		jwt_write_error(checker, "Must pass a token");
+		return 1;
+	}
+	if (payload == NULL && len > 0) {
+		jwt_write_error(checker, "Must pass a payload");
+		return 1;
+	}
+
+	p = token;
+	while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')
+		p++;
+
+	if (*p == '{') {
+		/* @rfc{7515,7.2} JSON: insert the out-of-band "payload" and verify
+		 * the now-attached token. */
+		jwt_json_auto_t *root = NULL;
+		jwt_json_t *sigs, *prot_j, *v;
+		const char *prot_b64;
+
+		root = jwt_json_parse(token, JWT_JSON_REJECT_DUPLICATES, NULL);
+		if (root == NULL || !jwt_json_is_object(root)) {
+			jwt_write_error(checker, "Invalid JWS JSON Serialization");
+			return 1;
+		}
+
+		sigs = jwt_json_obj_get(root, "signatures");
+		if (sigs != NULL && jwt_json_is_array(sigs) &&
+		    jwt_json_arr_size(sigs) > 0)
+			prot_j = jwt_json_obj_get(jwt_json_arr_get(sigs, 0),
+						  "protected");
+		else
+			prot_j = jwt_json_obj_get(root, "protected");
+		prot_b64 = (prot_j && jwt_json_is_string(prot_j))
+			? jwt_json_str_val(prot_j) : NULL;
+
+		b64 = detached_b64(prot_b64);
+		pp = detached_payload_part(b64, payload, len);
+		if (pp == NULL) {
+			jwt_write_error(checker, "Error allocating memory"); // LCOV_EXCL_LINE
+			return 1; // LCOV_EXCL_LINE
+		}
+
+		v = jwt_json_create_str(pp);
+		if (v == NULL || jwt_json_obj_set(root, "payload", v))
+			return 1; // LCOV_EXCL_LINE
+
+		recon = jwt_json_serialize(root, JWT_JSON_COMPACT);
+		if (recon == NULL)
+			return 1; // LCOV_EXCL_LINE
+	} else {
+		/* @rfc{7515,7.1} Compact: rebuild header "." payload "." signature. */
+		const char *firstdot = strchr(token, '.');
+		const char *lastdot = strrchr(token, '.');
+		char_auto *head_b64 = NULL;
+		size_t head_len, sig_len, recon_len;
+		const char *sig;
+
+		if (firstdot == NULL || lastdot == firstdot) {
+			jwt_write_error(checker,
+				"Not a detached Compact Serialization");
+			return 1;
+		}
+		head_len = (size_t)(firstdot - token);
+		sig = lastdot + 1;
+		sig_len = strlen(sig);
+
+		head_b64 = jwt_malloc(head_len + 1);
+		if (head_b64 == NULL)
+			return 1; // LCOV_EXCL_LINE
+		memcpy(head_b64, token, head_len);
+		head_b64[head_len] = '\0';
+
+		b64 = detached_b64(head_b64);
+		pp = detached_payload_part(b64, payload, len);
+		if (pp == NULL)
+			return 1; // LCOV_EXCL_LINE
+
+		recon_len = head_len + 1 + strlen(pp) + 1 + sig_len;
+		recon = jwt_malloc(recon_len + 1);
+		if (recon == NULL)
+			return 1; // LCOV_EXCL_LINE
+		sprintf(recon, "%s.%s.%s", head_b64, pp, sig);
+	}
+
+	ret = jwt_checker_verify(checker, recon);
+
+	return ret;
+}
diff --git a/tests/jws_b64.c b/tests/jws_b64.c
new file mode 100644
index 0000000..b09d9be
--- /dev/null
+++ b/tests/jws_b64.c
@@ -0,0 +1,234 @@
+/* Public domain, no copyright. Use at your own risk. */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "jwt_tests.h"
+
+/* @rfc{7797} JWS unencoded ("b64":false) + detached payloads (issue #315).
+ * Runs under each compiled crypto backend (SET_OPS loop) and both JSON
+ * backends. The Appendix A payload "$.02" is non-JSON on purpose. */
+
+static const unsigned char RAW[] = "$.02";
+#define RAW_LEN 4
+
+static jwk_set_t *load_one(const char *file)
+{
+	char *path;
+	jwk_set_t *set;
+	int ret;
+
+	ret = asprintf(&path, KEYDIR "/%s", file);
+	ck_assert_int_gt(ret, 0);
+	set = jwks_create_fromfile(path);
+	free(path);
+	ck_assert_ptr_nonnull(set);
+
+	return set;
+}
+
+static const jwt_serialization_t formats[] = {
+	JWT_FORMAT_COMPACT, JWT_FORMAT_JSON_FLAT, JWT_FORMAT_JSON_GENERAL,
+};
+
+/* ---- b64=false, attached, every serialization ---- */
+START_TEST(test_b64_false_attached)
+{
+	jwk_set_t *ks;
+	const jwk_item_t *ec;
+	size_t f;
+
+	SET_OPS();
+	ks = load_one("ec_key_prime256v1.json");
+	ec = jwks_item_get(ks, 0);
+
+	for (f = 0; f < ARRAY_SIZE(formats); f++) {
+		jwt_builder_auto_t *b = jwt_builder_new();
+		jwt_checker_auto_t *c = jwt_checker_new();
+		char_auto *tok = NULL;
+
+		ck_assert_int_eq(jwt_builder_setkey(b, JWT_ALG_ES256, ec), 0);
+		ck_assert_int_eq(jwt_builder_setpayload(b, RAW, RAW_LEN), 0);
+		ck_assert_int_eq(jwt_builder_setb64(b, 0), 0);
+		ck_assert_int_eq(jwt_builder_set_format(b, formats[f]), 0);
+		tok = jwt_builder_generate(b);
+		ck_assert_ptr_nonnull(tok);
+
+		ck_assert_int_eq(jwt_checker_setkey(c, JWT_ALG_ES256, ec), 0);
+		ck_assert_int_eq(jwt_checker_verify(c, tok), 0);
+	}
+
+	jwks_free(ks);
+}
+END_TEST
+
+/* ---- b64=false, detached, every serialization ---- */
+START_TEST(test_b64_false_detached)
+{
+	jwk_set_t *ks;
+	const jwk_item_t *ec;
+	size_t f;
+
+	SET_OPS();
+	ks = load_one("ec_key_prime256v1.json");
+	ec = jwks_item_get(ks, 0);
+
+	for (f = 0; f < ARRAY_SIZE(formats); f++) {
+		jwt_builder_auto_t *b = jwt_builder_new();
+		jwt_checker_auto_t *c = jwt_checker_new();
+		char_auto *tok = NULL;
+
+		ck_assert_int_eq(jwt_builder_setkey(b, JWT_ALG_ES256, ec), 0);
+		ck_assert_int_eq(jwt_builder_setpayload(b, RAW, RAW_LEN), 0);
+		ck_assert_int_eq(jwt_builder_setb64(b, 0), 0);
+		ck_assert_int_eq(jwt_builder_set_detached(b, 1), 0);
+		ck_assert_int_eq(jwt_builder_set_format(b, formats[f]), 0);
+		tok = jwt_builder_generate(b);
+		ck_assert_ptr_nonnull(tok);
+
+		ck_assert_int_eq(jwt_checker_setkey(c, JWT_ALG_ES256, ec), 0);
+		/* Correct out-of-band payload verifies; a wrong one is rejected. */
+		ck_assert_int_eq(jwt_checker_verify_detached(c, tok, RAW, RAW_LEN), 0);
+		ck_assert_int_ne(jwt_checker_verify_detached(c, tok,
+			(const unsigned char *)"x.99", 4), 0);
+	}
+
+	jwks_free(ks);
+}
+END_TEST
+
+/* ---- b64=true detached JSON claims (a detached JWT, RFC 7515 App F) ---- */
+START_TEST(test_b64_true_detached_jwt)
+{
+	static const unsigned char JSON[] = "{\"sub\":\"alice\"}";
+	jwk_set_t *ks;
+	const jwk_item_t *ec;
+	jwt_builder_auto_t *b = NULL;
+	jwt_checker_auto_t *c = NULL;
+	char_auto *tok = NULL;
+
+	SET_OPS();
+	ks = load_one("ec_key_prime256v1.json");
+	ec = jwks_item_get(ks, 0);
+
+	b = jwt_builder_new();
+	ck_assert_int_eq(jwt_builder_setkey(b, JWT_ALG_ES256, ec), 0);
+	ck_assert_int_eq(jwt_builder_setpayload(b, JSON, strlen((char *)JSON)), 0);
+	ck_assert_int_eq(jwt_builder_set_detached(b, 1), 0);
+	tok = jwt_builder_generate(b);
+	ck_assert_ptr_nonnull(tok);
+
+	c = jwt_checker_new();
+	ck_assert_int_eq(jwt_checker_setkey(c, JWT_ALG_ES256, ec), 0);
+	ck_assert_int_eq(jwt_checker_verify_detached(c, tok, JSON,
+		strlen((char *)JSON)), 0);
+
+	jwks_free(ks);
+}
+END_TEST
+
+/* ---- RFC 7797 Appendix A.4: HS256, detached, unencoded "$.02" ---- */
+START_TEST(test_rfc7797_appendix_a)
+{
+	/* The HMAC key from RFC 7515 Appendix A.1. */
+	static const char *KEY =
+		"{\"kty\":\"oct\",\"k\":\"AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ"
+		"-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow\"}";
+	/* The detached JWS from RFC 7797 Appendix A.4. */
+	static const char *TOKEN =
+		"eyJhbGciOiJIUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19"
+		"..A5dxf2s96_n5FLueVuW1Z_vh161FwXZC4YLPff6dmDY";
+	jwk_set_t *ks;
+	const jwk_item_t *k;
+	jwt_checker_auto_t *c = NULL, *c2 = NULL;
+
+	SET_OPS();
+
+	ks = jwks_create(NULL);
+	ck_assert_ptr_nonnull(ks);
+	ck_assert_ptr_nonnull(jwks_load(ks, KEY));
+	k = jwks_item_get(ks, 0);
+	ck_assert_ptr_nonnull(k);
+
+	c = jwt_checker_new();
+	ck_assert_int_eq(jwt_checker_setkey(c, JWT_ALG_HS256, k), 0);
+	ck_assert_int_eq(jwt_checker_verify_detached(c, TOKEN, RAW, RAW_LEN), 0);
+
+	/* A different payload must not verify against the RFC signature. */
+	c2 = jwt_checker_new();
+	ck_assert_int_eq(jwt_checker_setkey(c2, JWT_ALG_HS256, k), 0);
+	ck_assert_int_ne(jwt_checker_verify_detached(c2, TOKEN,
+		(const unsigned char *)"$.03", 4), 0);
+
+	jwks_free(ks);
+}
+END_TEST
+
+/* ---- Security + builder guards ---- */
+START_TEST(test_b64_security)
+{
+	jwk_set_t *ks;
+	const jwk_item_t *ec;
+	jwt_builder_auto_t *b = NULL;
+	jwt_checker_auto_t *c = NULL;
+	/* base64url({"alg":"ES256","b64":false}) -- b64 NOT in crit. */
+	static const char *NOCRIT =
+		"eyJhbGciOiJFUzI1NiIsImI2NCI6ZmFsc2V9.$.02.AAAA";
+
+	SET_OPS();
+	ks = load_one("ec_key_prime256v1.json");
+	ec = jwks_item_get(ks, 0);
+
+	/* @rfc{7797,6} b64=false without "b64" in "crit" is rejected. */
+	c = jwt_checker_new();
+	ck_assert_int_eq(jwt_checker_setkey(c, JWT_ALG_ES256, ec), 0);
+	ck_assert_int_ne(jwt_checker_verify(c, NOCRIT), 0);
+
+	/* The builder refuses b64=false without a raw payload (would be a JWT). */
+	b = jwt_builder_new();
+	ck_assert_int_eq(jwt_builder_setkey(b, JWT_ALG_ES256, ec), 0);
+	ck_assert_int_eq(jwt_builder_setb64(b, 0), 0);
+	ck_assert_ptr_null(jwt_builder_generate(b));
+
+	/* An unencoded payload with an embedded NUL cannot be serialized verbatim,
+	 * so it is rejected rather than silently truncated. */
+	{
+		jwt_builder_auto_t *bn = jwt_builder_new();
+		static const unsigned char NUL_PT[] = { 'a', '\0', 'b' };
+
+		ck_assert_int_eq(jwt_builder_setkey(bn, JWT_ALG_ES256, ec), 0);
+		ck_assert_int_eq(jwt_builder_setpayload(bn, NUL_PT, 3), 0);
+		ck_assert_int_eq(jwt_builder_setb64(bn, 0), 0);
+		ck_assert_ptr_null(jwt_builder_generate(bn));
+	}
+
+	jwks_free(ks);
+}
+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("jws_b64");
+
+	tcase_add_loop_test(tc_core, test_b64_false_attached, 0, i);
+	tcase_add_loop_test(tc_core, test_b64_false_detached, 0, i);
+	tcase_add_loop_test(tc_core, test_b64_true_detached_jwt, 0, i);
+	tcase_add_loop_test(tc_core, test_rfc7797_appendix_a, 0, i);
+	tcase_add_loop_test(tc_core, test_b64_security, 0, i);
+
+	tcase_set_timeout(tc_core, 60);
+	suite_add_tcase(s, tc_core);
+
+	return s;
+}
+
+int main(void)
+{
+	JWT_TEST_MAIN("LibJWT RFC 7797 unencoded/detached payload (#315)");
+}