SEVERIAN ROTHEngineering notes / 01

API integration · Reproducible experiment

Your JSON is equivalent.
Your signature is not.

I sent the same JSON object twice. The first request verified. The second failed. Formatting changed the message I had signed.

“Sign the JSON body” leaves out the part that matters: which representation of that body reaches the signer, and which reaches the server.

I built a small request-signing workbench to make that distinction visible. Then I put an HTTP server on localhost and sent two requests through Node’s fetch. Both bodies parsed to the same object. Only one matched the bytes I had signed.

Signed and sent · 15 bytes

{"msg":"hello"}

Signature verifies

Signed compact, sent formatted · 20 bytes

{ "msg": "hello" }

Signature does not verify

Same parsed object, different UTF-8 bytes. Results come from the local HTTP reproduction linked below.

A formatter can change the signed message

The experiment uses RSA with SHA-256 and PKCS#1 v1.5 padding. Its message format is the UTF-8 encoding of body + path + timestamp. That concatenation is a compatibility example, not a proposed authentication protocol. A real integration has to use its provider’s specified format, including separators, encoding and timestamp units.

I generated a temporary 2048-bit key, serialized { msg: 'hello' } once, and signed it. The first request sent that string. The second sent JSON.stringify(object, null, 2) with the original signature.

The receiver collected the request body before parsing JSON. It measured 15 bytes in the first request and 20 in the second. Verification returned true, then false. An assertion also confirmed that the two parsed objects were deeply equal.

The added whitespace belongs to the message. The verifier has no instruction to disregard it. The same issue appears if one side changes property order or uses a different textual representation of a value while the protocol expects exact request bytes.

Give serialization one owner

The fix I prefer is small: construct the body once, then pass that same serialized value to signing and transport. In the reproduction, the critical path is:

const body = JSON.stringify({ msg: 'hello' });
const signature = signRequest(body, path, timestamp, privateKey);

await fetch(url, {
  method: 'POST',
  body,
  headers: {
    'content-type': 'application/json',
    'x-demo-signature': signature,
    'x-demo-timestamp': timestamp,
  },
});

The x-demo-* headers belong only to this experiment. The full script starts its own receiver and closes it when both checks finish.

I would put this boundary inside the integration adapter. Callers can hand it an object; the adapter owns serialization, signing and the request body. That leaves fewer places for a later HTTP helper to helpfully serialize the data again.

A local sign/verify check alone would miss that transport mistake. Both operations could use the original string while the HTTP client sends something else. Capturing the received body is what makes this reproduction useful.

Canonicalization is a protocol choice

Sorting keys looks like an easy fix. It works only if it matches the representation the verifier expects. A provider that verifies the raw body will still reject a signature over a different, canonicalized body.

RFC 8785’s JSON Canonicalization Scheme defines a deterministic representation, including serialization, property sorting and UTF-8 encoding. That is useful when both sides adopt it. Adding an informal “sort and stringify” step to one client is a different contract.

For an existing API, I would first look for its maintained SDK and a published signing vector. If those are unavailable, I want an example that specifies the exact message bytes, public key, algorithm, padding and signature. A screenshot of the JSON object cannot answer those questions.

The SHA-256 trap is separate

The workbench also tests a second mistake: hashing the assembled message before passing it to sign('sha256', ...). With this RSA signing call, Node applies SHA-256 as part of signing. Passing a SHA-256 digest as its data therefore hashes the digest again.

I kept node:crypto in charge of the cryptography. The adapter assembles bytes and explicitly selects RSA_PKCS1_PADDING. The corresponding Node documentation describes the signing API and RSA padding options. A provider requiring RSA-PSS needs its corresponding settings; the two schemes are not interchangeable.

Selected checks in the existing signing workbench
ChangeOriginal signature verifies?
NoneYes
Append a space to the JSONNo
Change the endpoint pathNo
Change seconds to millisecondsNo
Sign a precomputed digest, then verify the original messageNo

The Node signature also verified in the browser’s Web Crypto implementation, and an OpenSSL command-line check accepted the original payload and rejected a tampered one. Those checks establish local compatibility for this sample. They do not establish that a remote API accepts it, or that a timestamp is fresh enough for that API.

Before changing the key, compare the bytes at the signer with the bytes at the receiver.

Reproduce it. Clone the sample repository and run node writing/json-signatures/reproduce.mjs. It uses Node’s built-in HTTP and crypto modules and the existing sample adapter. It sends two requests to localhost and makes no external API calls.

Read the full reproduction · Recorded HTTP results · Run the browser workbench · Workbench verification report