What a dynamic header is
Most requests carry headers that are stable for weeks: an app version, a platform name, a locale. A dynamic header changes on its own between two calls to the same endpoint. It changes because it was derived from something that moved, not because anyone edited it.
The common derivations:
- A timestamp read from the device clock at the moment of sending.
- A counter or sequence number that increments per request.
- A digest of the outgoing body, so the header tracks the payload.
- A session or access token refreshed by an earlier call.
- A value copied from an app startup handshake and reused for a while.
- A device identifier read from build properties or the keystore.
Where these headers are built
In an OkHttp based app the header is usually attached in an interceptor rather than at the call site. Application code calls addInterceptor for the signing or device layer and only then builds the client, so the header exists for every request without each feature team remembering it. That is convenient for the app and inconvenient for you, because the value never appears in the source of the screen you are studying.
Retrofit adds a second layer with @Header annotations, which can carry literal values or parameters passed in by the caller. Ktor and Volley place the logic in plugin or request-building code instead. In all three cases the final header block is assembled inside the HTTP stack, which means a breakpoint on the screen's controller shows you nothing useful.
Why the signature behind it cannot be reused
When a signature covers a dynamic field, the signature inherits the field's lifetime. Sign the tuple of method, path, query, body and timestamp with an HMAC and the output changes on every call even when nothing about your request changed. Replaying the captured signature alongside a new timestamp produces a value the server can detect as inconsistent, and it answers 403 without telling you which check failed.
Header order and casing add a separate trap. If the app signs a canonical string it builds itself, order matters only in that string. If it signs the header block as transmitted, then the order and casing on the wire matter too. A client that sorts headers alphabetically, or normalises a lowercase name into title case, changes the bytes and invalidates the signature.
Finding the field that moved
Capture two requests to the same endpoint from the device and diff them. Fields that differ are candidates. Then separate the ones that differ because the query or body differed from the ones that differ on their own. Dynamic headers fall into the second group.
A short sequence narrows it quickly:
- Send an identical captured request twice. If the first passes and the second fails, uniqueness is enforced on a field you already hold.
- Replace one candidate header with a fixed value and resend. If the failure changes from a signature error to a validation error, you altered signed material.
- Hold everything constant and change only the timestamp. A different response means the timestamp is validated or signed.
- Call the endpoint twice and compare the values the app produced for each call. A value that repeats is reused state. A value that never repeats is fresh per request.
- Read the number's magnitude before assuming cryptography. Ten digits is a second-based epoch. Thirteen is milliseconds. A four or five digit value that grows slowly is a counter.
Reproducing the header rather than the capture
Replay has a short shelf life, so the durable fix is to compute the header the way the app computes it. That means finding the routine, which sits in one of a few places. In plain Java or Kotlin it is a signing helper called from an interceptor or a request builder. In R8 or ProGuard builds the same helper exists with renamed symbols, and simple helpers are often inlined so there is no method to hook by name. In native builds a JNI method takes the request parts and returns the value, and the algorithm never appears in the dex at all. Sometimes the key arrives from remote configuration at startup and is cached for the session, which is why a reproduction that worked yesterday can fail after the app has been reinstalled or the config has rotated.
Once your client produces the same value as the device for the same input, the signature stops being a blocker, and what remains is ordinary flow work: refreshing tokens, preserving cookies, and keeping the calls to a stateful screen in the order the server expects.
Related work
Reviewed 28 September 2026 · SReverse research desk