HTTP & APIs in Practice
HTTP & APIs in Practice

curl as a debugging tool

Lesson · ~30 min
After this unit you can
  • Predict what -i, -v, -s and -o show, and which parts go to stdout and which to stderr
  • Say what -d does to the method and Content-Type, and send JSON with --json (curl 7.82.0+) or explicit headers
  • Quote JSON, URLs and variables correctly in zsh and bash, and use --data-raw to avoid the @ file trap
  • Replay a signup → login → authenticated request, carrying a bearer token and cookies with -c/-b
  • Predict what -X and -L change on the wire, and make a script fail when the server returns a 5xx

Read

Simple
1 / 2

What curl prints, and where it prints it

Flag

curl takes a URL, sends one request, and prints the response body to stdout. That's all, until you ask for more. Every output below is real, from curl 8.7.1 against a local API.

$ curl http://localhost:4123/orders/42
{"id":42,"status":"open"}

No URL scheme tricks, no -X GET: a bare URL is a GET.

-i: the response headers, in the same stream as the body

$ curl -i http://localhost:4123/orders/42
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 26
Date: Sat, 17 Oct 2026 06:40:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"id":42,"status":"open"}

Status line, response headers, the empty line, the body — the response exactly as the previous unit described it. All of it goes to stdout, so curl -i … > out.txt puts the headers in the file too.

-v: both directions, plus curl's own notes

$ curl -v http://localhost:4123/orders/42
* Host localhost:4123 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:4123...
* Connected to localhost (::1) port 4123
> GET /orders/42 HTTP/1.1
> Host: localhost:4123
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< Content-Type: application/json
< Content-Length: 26
< Date: Sat, 17 Oct 2026 06:40:37 GMT
< Connection: keep-alive
< Keep-Alive: timeout=5
<
{"id":42,"status":"open"}
* Connection #0 to host localhost left intact

The prefixes are the whole trick: > is what curl sent, < is what it received, * is curl narrating (DNS, connection, TLS). This is the flag for "did my header actually go out?" — -i can't answer that, because it shows only the response.

The *, > and < lines go to stderr; only the body goes to stdout. So curl -v … > body.json still shows you the conversation on screen while the file gets just the JSON.

-s, -o: quiet and saved

When stdout isn't a terminal (you pipe into jq or redirect to a file), curl draws a progress meter on stderr. -s turns that off — and also hides error messages, so scripts usually say -sS to keep the errors. -o file writes the body to a file instead of stdout.

$ curl -sS http://localhost:4123/orders/42 | jq .status
"open"

Medium
1 / 2

Worked example — sign up, log in, call an authenticated endpoint

Flag

The bug report says "login works but /me returns 401". Before reading any server code, reproduce the whole flow in a terminal. Real output from a local API:

1. Sign up — expect 201

$ curl -i --json '{"email":"ada@example.com","password":"correct-horse"}' \
    http://localhost:4123/auth/signup
HTTP/1.1 201 Created
Content-Type: application/json
Content-Length: 39
Location: /users/u_1
Date: Sat, 17 Oct 2026 06:41:34 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"id":"u_1","email":"ada@example.com"}

201 plus Location — the user exists. Run it again and you get 409 Conflict with {"error":"email already registered"}, which is worth knowing before you debug "signup is broken".

2. Log in — expect 200 and an access token

$ curl -i -c jar.txt --json '{"email":"ada@example.com","password":"correct-horse"}' \
    http://localhost:4123/auth/login
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 229
Set-Cookie: refresh_token=pi1B33Pk3rNJ1MovEnXR0gG4KVYF3vP8; HttpOnly; Secure; SameSite=Strict; Path=/auth; Max-Age=1209600
Date: Sat, 17 Oct 2026 06:41:34 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1XzEiLCJzY29wZSI6Im9yZGVyczpyZWFkIiwiaWF0IjoxNzkyMjE5Mjk0LCJleHAiOjE3OTIyMjAxOTR9.5wc-kqjtumFJgsybymv4FKVWuHGJLT2NbtOXsvW_wMI","tokenType":"Bearer","expiresIn":900}

Two credentials came back: an accessToken in the body, and a refresh token in a Set-Cookie. -c jar.txt told curl to save every cookie it receives into jar.txt, in the Netscape cookie-file format.

3. Capture the token and send it

$ TOKEN=$(curl -sS --json '{"email":"ada@example.com","password":"correct-horse"}' \
    http://localhost:4123/auth/login | jq -r .accessToken)

$ curl -i -H "Authorization: Bearer $TOKEN" http://localhost:4123/me
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 35
Date: Sat, 17 Oct 2026 06:41:39 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"id":"u_1","scope":"orders:read"}

jq -r prints the string without quotes (jq is a separate tool: recent macOS ships one, otherwise install it, e.g. brew install jq). -s matters here: without it, the progress meter would go to your terminal while the pipe carries the body. And the header is in double quotes so the shell expands $TOKEN.

4. Compare with the failures

$ curl -i http://localhost:4123/me
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api"
…
{"error":"missing bearer token"}

$ curl -i -H 'Authorization: Bearer $TOKEN' http://localhost:4123/me
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api", error="invalid_token"
…
{"error":"invalid or expired token"}

The second one is the shell, not the server: single quotes keep $TOKEN literal, so the server received the six characters $TOKEN as the token. curl -v would have shown it on the > Authorization: line.

5. Send the cookie back

$ curl -i -b jar.txt -c jar.txt -X POST http://localhost:4123/auth/refresh
HTTP/1.1 200 OK
Set-Cookie: refresh_token=-Zrw4CiF-HiFN9vvibZa_55z2_WAhusl; HttpOnly; Secure; SameSite=Strict; Path=/auth; Max-Age=1209600
…

-b jar.txt reads cookies from the file and sends the ones that match the URL (here, because /auth/refresh is under the cookie's Path=/auth); -c jar.txt writes the rotated cookie back. Naming the same file for both is the normal way to carry a session across commands. The cookie is Secure, yet curl kept and sent it over http:// — curl, like browsers, treats localhost as a secure context. (-X POST is one of its few honest uses: a POST with no body at all. -d '' would also send an empty body, but labelled application/x-www-form-urlencoded with Content-Length: 0.)

Complex

-X, -L and exit codes — the flags that do less (or more) than you think

Flag

Three habits produce most "curl lied to me" moments. Each output below was captured with curl 8.7.1 against the local /echo endpoint.

-X changes the method word and nothing else

The manual is blunt: -X "only changes the actual word used in the HTTP request, it does not alter the way curl behaves". Two consequences:

$ curl -X GET -d 'q=tea' http://localhost:4123/echo
{"method":"GET","path":"/echo","headers":{"content-type":"application/x-www-form-urlencoded","content-length":"5","accept":"*/*"},"body":"q=tea"}

A GET with a form body. RFC 9110 says content in a GET has no defined meaning and some servers reject it. If you meant a query string, -G -d 'q=tea' sends GET /echo?q=tea.

$ curl -sS -m 3 -X HEAD http://localhost:4123/orders/42
curl: (28) Operation timed out after 3007 milliseconds with 0 out of 26 bytes received

The server answered HEAD correctly — headers only, including Content-Length: 26. curl still behaves as if it sent a GET and waits for 26 body bytes that will never come, until the -m 3 timeout (exit 28). Without -m it waits until the server closes the connection. -I is the real HEAD: it tells curl not to expect a body.

-L follows redirects — and may change your request

-L re-sends to the Location of a 3xx. For a POST, what it re-sends depends on the code:

$ curl -L -d 'a=1' http://localhost:4123/old-echo        # 301 -> /echo
{"method":"GET","path":"/echo","headers":{"accept":"*/*"},"body":""}

$ curl -L -d 'a=1' http://localhost:4123/old-echo-308    # 308 -> /echo
{"method":"POST","path":"/echo","headers":{"content-type":"application/x-www-form-urlencoded","content-length":"3","accept":"*/*"},"body":"a=1"}

After a 301, 302 or 303, curl switches the POST to a GET and drops the body (-v prints * Switch from POST to GET), as browsers do. 307 and 308 keep the method and body. Now add a habitual -X POST:

$ curl -X POST -L -d 'a=1' http://localhost:4123/old-echo
{"method":"POST","path":"/echo","headers":{"accept":"*/*"},"body":""}

The method word is forced to POST on every hop, but the body is still dropped after the 301: the server gets an empty POST. The manual warns about exactly this, and since curl 8.16.0 --follow is the redirect option that applies a -X method the way the HTTP spec says.

-L also stops sending Authorization and Cookie once a redirect leads to a different host — your token isn't leaked to wherever a redirect points. --location-trusted turns that off; use it only for hosts you control.

curl succeeds when the server fails

$ curl -s http://localhost:4123/boom; echo " exit=$?"
{"error":"something broke"} exit=0

$ curl -s --fail-with-body http://localhost:4123/boom; echo " exit=$?"
{"error":"something broke"} exit=22

By default an HTTP 500 is a successful transfer, so a script (or a CI health check) carries on. -f / --fail exits 22 for any status of 400 or more and prints no body; --fail-with-body (curl 7.76.0+) exits 22 and keeps the body, which is what you want in logs. When you only need the status code:

$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4123/boom
500

Quiz

curl -d 'name=ada' https://api.example.com/users — what method and Content-Type go out?

Flag

curl -d '{"email":"ada@example.com"}' http://localhost:4123/auth/signup gets 400 email is required from an Express API using express.json(). The JSON is valid. Why?

Flag

You suspect your Authorization header never actually goes out. Which flag shows you?

Flag

TOKEN holds a valid access token, yet curl -H 'Authorization: Bearer $TOKEN' http://localhost:4123/me returns 401 with error="invalid_token". Why?

Flag

A script posts user comments with curl -d "$COMMENT" …. One comment is @channel please review. With curl 8.3.0 or later, what happens?

Flag

In zsh, curl http://localhost:4123/search?q=tea prints zsh: no matches found: http://localhost:4123/search?q=tea and curl never runs. What's the fix?

Flag

A deploy step runs curl -s https://api.example.com/health and moves on if it exits 0. The service is down and answers 503. What happens?

Flag

Fill in the blank

Send this body as JSON in one flag (curl 7.82.0+).

Flag
curl  '{"email":"ada@example.com","password":"correct-horse"}' http://localhost:4123/auth/signup

The same request on a curl older than 7.82.0, with the data sent literally even if it starts with @.

Flag
curl -H ': application/json'  '{"email":"ada@example.com"}' http://localhost:4123/auth/signup

Send the captured access token. (Note the double quotes.)

Flag
curl -H "Authorization:  $TOKEN" http://localhost:4123/me

Save the cookies from login, then send them (and save the rotated one) on refresh.

Flag
curl  jar.txt --json "$CREDENTIALS" http://localhost:4123/auth/login
curl  jar.txt -c jar.txt -X POST http://localhost:4123/auth/refresh

Print just the status code — no body, no progress meter.

Flag
curl -s -o /dev/null -w '\n' http://localhost:4123/boom

Predict the output

$ curl -s -i http://localhost:4123/orders/42 > out.txt
$ head -1 out.txt
Flag
$ curl -s -v http://localhost:4123/orders/42 > out.txt
$ cat out.txt
Flag
# /old-echo answers 301 Moved Permanently, Location: /echo
# /echo replies with the method and body it received
$ curl -s -X POST -L -d 'a=1' http://localhost:4123/old-echo
Flag

Further reading