What you copied and what you did not
A capture records the bytes of one request at one moment. It does not record how those bytes were produced. When the app runs, several fields are calculated fresh for that call. If any of them feed the server's validation, replaying the captured values fails even though every header and body value looks correct.
The fields that most often change between the capture and your replay:
- A signature computed over the method, path, query, body or a subset of headers.
- A timestamp with a server-side validity window, often measured in minutes.
- A nonce or request identifier the server expects to be unique.
- A device identifier derived from build properties, keystore values or an attestation result.
- A session value refreshed by an earlier call in the same flow.
Separate transport failures from validation failures
Before changing code, establish which layer rejected you. The distinction decides the entire investigation.
- A TLS or network error means the connection never delivered a request.
- A connection reset during the handshake points at certificate pinning rather than anything in your headers.
- An HTTP response means the request arrived and the server evaluated it. A 401 usually means the session or credential is wrong or expired. A 403 usually means the request was understood and refused, which points at a signature, timestamp or integrity check.
An HTTP status code is good news. It means you are arguing with the application rather than the network.
Test one variable at a time
The fast way to find the derived value is to change one thing and watch the result.
- Send the captured request byte for byte with no edits. If it fails, the problem is time, uniqueness or session state rather than your formatting.
- Resend the identical request twice in a row. If the first succeeds and the second fails, a nonce or timestamp check is active.
- Change only the timestamp while keeping everything else identical. If the response changes, you have found a validated field.
- Change one character of a header the app signs, leaving the signature alone. A rejection confirms that field is inside the signed set.
Reproduce the value rather than the value itself
Replaying a captured signature has a short shelf life. The durable fix is to compute the value the same way the app does, which means finding the routine that produces it. In most Android apps that routine sits in one of three places: an OkHttp interceptor, a generated client class, or native code reached through JNI.
Start with the interceptor chain. Application code usually adds interceptors in a fixed order, and the signing step is often the last one before the network call. Log the outgoing request after every interceptor runs, not before, so you observe the final bytes rather than an intermediate state.
Where the derived value usually lives
- Plain Java or Kotlin: a signing helper called from an interceptor or a request builder.
- Obfuscated builds: the same helper with renamed symbols and possibly inlined logic.
- Native libraries: a JNI method that takes the request components and returns a signature.
- Remote configuration: a key or salt fetched at startup and cached for the session.
Once you can produce a valid request from Python, the remaining work is the flow around it: token refresh, cookie handling, request order for stateful screens, and error paths the server returns when a value goes stale.
Related work
Reviewed 28 September 2026 · SReverse research desk