Order is part of the signed material
Signing schemes that cover headers take one of two approaches. The first selects headers by name, so order does not matter and only membership and values count. The second walks the header block as it arrived, joins each name and value with a separator, and hashes the result. In that second design the position of a line is data, and moving one changes the string the server rebuilds.
Establish which approach you are facing before touching the client, because the two produce the same error message. Reorder two headers in your replay while leaving every value untouched. If the response is identical, the server selects by name. If the response changes, order is in scope and you have to reproduce the original sequence.
How a position-dependent signature is built
A server that hashes the block usually lowercases each header name, keeps the value as received, joins them with a newline or a delimiter, and hashes the whole string with the request method and path. Some implementations exclude a fixed list of headers that the transport manages, such as Host, Content-Length, Connection and the signature header itself, and hash everything else in order.
That exclusion list matters as much as the sequence. Adding a header your own HTTP library injects by default, such as Accept-Encoding or a connection hint, inserts a line the server did not exclude and did not see from the app, and the digest diverges for a reason that has nothing to do with the endpoint.
What moves the order in transit
Header order is not stable by protocol. HTTP/2 requires lowercase names and permits intermediaries to reorder fields, and a proxy can merge or add lines on the way through. Applications that hash order therefore tend to do it on the client, where the client controls what it builds, and the server reconstructs the same order from a normalised request.
OkHttp keeps the headers of one request in insertion order, so capturing at an interceptor shows the sequence the app produced before the connection layer appends its own fields. Capture there rather than at the socket. A capture taken below the HTTP stack shows extra lines in a place the signing code never saw them.
Separating order from membership
Run these tests in sequence and the failure class becomes clear.
- Swap two header lines and resend. An unchanged response means the server selects by name, so the problem lies in a value or in the set of headers you send.
- Remove one unsigned header and resend. A new failure points at a header the server includes in the hash, which tells you its exclusion list is shorter than you assumed.
- Add one default header from your library and resend. A new failure points at a field the app never sent, and the fix is to suppress it rather than to change the signature.
- Keep the set fixed and the values fixed, then reorder once more. A second change in behaviour confirms positional hashing and rules out a stale session or a freshness window.
Rebuilding the sequence in your client
Most HTTP clients let you control the order they emit headers, but they also add their own. In Python, a dict preserves insertion order, and the requests library sends the headers roughly as given while appending its defaults; a prepared request exposes the final set so you can inspect it. In TypeScript, a Headers object sorts names in some runtimes, which is a reason to build the wire request through a lower-level API when order is signed.
The reliable method is mechanical: print the ordered header list your client will send, place it next to the intercepted list from the app, and remove every line the app did not send. Case is worth checking at the same time. A server that lowercases names before hashing accepts either form from you, but one that hashes the names as written will not, and a title-case header from your library then fails against a lowercase header from the app.
Duplicates and joined values
Two headers with the same name are legal, and clients differ on how they emit them. One sends two lines, another joins the values with a comma and a space. A server that hashes the block in order sees a different string in each case, so a cookie or accept list split across calls can break the digest even though every value is present. Decide how the app emits duplicates by reading the interceptor output, and reproduce that emission rather than the merged form your library prefers.
When the order and the set match, this class of failure disappears and the remaining problems are usually freshness or session state. Dynamic headers that break replay covers the values that cannot be copied from a capture, and dynamic header reverse engineering is the service that produces a client which builds them.
Related work
Reviewed 28 September 2026 · SReverse research desk