DEV Community

Kazu
Kazu

Posted on

curl works, your app gets 400: read the exact request your SDK sent

curl returns 200. The same request from your app returns 400. You followed the docs. The parameters look right. It still doesn't work.

When that happens, what you really want to know is what your app actually sent. But that's the one thing you can't see. The SDK assembles the final request below your code, and TLS encrypts it before it hits the wire — capture the traffic and you get ciphertext.

This post is about seeing that plaintext with one command, no CA certificate install, no proxy, no changes to your app. The tool is tinytap, something I built for exactly this. Here's what it looks like.

The left terminal runs the app and sends one HTTPS request. The right terminal shows tinytap printing that request as plaintext — request line, headers, Authorization: Bearer token, JSON body.

curl works, the app gets 400

Here's a concrete example of how this goes wrong. You're hitting a payment API that expects an integer amount in cents. Your code passes 1000. The app returns 400 invalid integer: amount. You hardcode the same value in curl and it goes through fine.

The usual suspects come to mind. Double-encoded body? Type coercion somewhere? Wrong Content-Type? Missing token? All plausible, none confirmable. Your app's logs show the 1000 you passed into the SDK — not the final form that went over the wire.

If you could see the request body with "amount": "10.00" — the value that silently became a string somewhere — you'd have exactly what you need to compare what you meant to send against what you actually sent.

The SDK's final form never appears in any log

The obvious move is to check the logs. But whatever you print or pass to your logger is the value before the SDK gets it. The SDK adds headers, serializes the body, and possibly retries or signs the request — all below the layer your logs can see. You get the intent, not the result.

Wireshark is another option. But the connection is TLS, so you get ciphertext. Decoding that requires setting up SSLKEYLOGFILE and a decryption step, and not every client supports it.

mitmproxy gives you plaintext — but it requires installing a CA certificate in your trust store, configuring your app to route through the proxy, and some SDKs ignore proxy environment variables entirely. Installing a CA cert on your machine is a heavy setup just for debugging.

So there was a gap: seeing the actual plaintext, without touching the app, without installing a certificate, without a proxy in the middle.

Peek at the plaintext before it gets encrypted

That's what tinytap does. The gif above shows it: run your app on the left, and the request's plaintext appears on the right in tinytap.

The left side is running this:

from openai import OpenAI

client = OpenAI(api_key="sk-...", base_url="https://127.0.0.1:8443/v1")

client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": "what is a uprobe"}],
)
Enter fullscreen mode Exit fullscreen mode

And on the right:

POST /v1/chat/completions HTTP/1.1
Host: 127.0.0.1:8443
Authorization: Bearer sk-demo-xxxx
Content-Type: application/json
User-Agent: OpenAI/Python 2.53.0
...
Request body (decoded): {"messages":[{"role":"user","content":"what is a uprobe"}],"model":"gpt-4o-mini"}
Enter fullscreen mode Exit fullscreen mode

The request line, every header, the Authorization: Bearer token, the JSON body — exactly what the SDK built and sent. The User-Agent: OpenAI/Python line confirms this is real bytes from the real SDK, not something I reconstructed. No proxy, no certificate, no changes to the app. The token in the gif is a dummy (sk-demo-xxxx), but when you point tinytap at your own app, your real token appears in plaintext just like this.

Your code on the left, what it actually sent on the right. The moment you see both side by side, the "I thought I sent X" question is answered on the spot.

This is not breaking TLS

Worth stating upfront before anyone raises an eyebrow: this is not a vulnerability that lets you intercept traffic.

tinytap looks at the buffer your process hands to the TLS library, just before encryption — and the buffer that comes back out, just after decryption.

App
  │  plaintext
  ▼
TLS library  ◀── tinytap looks here
  │  ciphertext
  ▼
Network
  │  ciphertext
  ▼
API server
Enter fullscreen mode Exit fullscreen mode

It hooks into your process from the inside, at the moment the plaintext is still plaintext. TLS itself is untouched. Other people's traffic is not accessible. This isn't about someone with root on your machine — it's about the inside of your own process. It's the same idea as curl printing its own request to your terminal. The ciphertext on the wire is untouched. All you're doing is looking at it from your own side, one step before encryption.

Try it yourself

"Plaintext without a certificate or proxy" sounds like it might be overselling it. So here's how to reproduce it yourself. tinytap uses eBPF, which means Linux only. On Mac or Windows, spin up a Linux container with Docker.

# install
curl -fsSL https://raw.githubusercontent.com/shinagawa-web/tinytap/main/scripts/install.sh | sh

# grant the capabilities needed for eBPF and TLS capture
sudo setcap cap_dac_read_search,cap_perfmon,cap_bpf,cap_sys_admin=eip $(command -v tinytap)

# set the execute bit on libssl (Debian/Ubuntu ships it without one)
sudo chmod +x $(ldconfig -p | grep libssl.so | awk '{print $NF}' | head -1)

tinytap
Enter fullscreen mode Exit fullscreen mode

setcap grants the binary the capabilities it needs for eBPF and TLS capture. chmod +x sets the execute bit on libssl — Debian and Ubuntu ship libssl.so.3 without it by default, so this line is required there. With both steps done, tinytap runs without sudo. Run your app normally and the requests show up.

If something doesn't work, run tinytap doctor. It checks your kernel version, BTF availability, capabilities, and the libssl execute bit, and tells you exactly what's missing.

$ tinytap doctor
tinytap doctor — v0.6.1

[OK      ] kernel version               6.1.0 (>= 5.8 required)
[OK      ] kernel BTF                   present
[OK      ] cap_dac_read_search          present
[OK      ] cap_perfmon                  present
[OK      ] cap_bpf                      present
[OK      ] cap_sys_admin                present
[OK      ] cap_syslog                   not needed on arm64
[OK      ] perf_event_paranoid          4
[OK      ] unprivileged_bpf_disabled    2
[OK      ] RLIMIT_MEMLOCK               soft=unlimited hard=unlimited
[OK      ] syscall tracepoints          available
[OK      ] BPF dry-run load             ok
[DEGRADED] libssl execute bit           /usr/lib/x86_64-linux-gnu/libssl.so.3 not set
    Affects: TLS capture only. Plaintext HTTP capture is unaffected.
    Fix:     sudo chmod +x /usr/lib/x86_64-linux-gnu/libssl.so.3

12 ok, 1 degraded, 0 blocking, 0 info
Enter fullscreen mode Exit fullscreen mode

tinytap only speaks HTTP/1.1 — gRPC and HTTP/2 binary frames aren't readable as-is. Go is also out of scope: its crypto/tls bypasses OpenSSL entirely. Python, Ruby, PHP, and Node.js all work out of the box. The language is a quick guide: Python / Ruby / PHP / Node.js, you're good; Go, you'll need a different approach.

Instead of the self-signed local server in the demo, you can point tinytap at whatever API-connected app you normally run. The Authorization header and body that show up are what you need to debug "curl works, app gets 400."

Which SDK has bitten you? Silent retries, double-encoded bodies, headers disappearing for no reason — if any of that sounds familiar, it's probably the kind of thing that resolves the moment you can see what was actually sent.

So how does it actually see the plaintext?

This post has stayed focused on what tinytap shows, not how it works. The mechanism — eBPF and uprobes, hooking into the TLS library boundary — is a story for another post.

The server returned 400. It's telling you the request was wrong. You don't know what's wrong because you can't see what your code actually sent. If you could see it, you'd just compare what you meant to send against what went out. tinytap is built for that.

Top comments (0)