curl as a debugging tool
- Predict what
-i,-v,-sand-oshow, and which parts go to stdout and which to stderr - Say what
-ddoes to the method andContent-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-rawto avoid the@file trap - Replay a signup → login → authenticated request, carrying a bearer token and cookies with
-c/-b - Predict what
-Xand-Lchange on the wire, and make a script fail when the server returns a5xx
Read
What curl prints, and where it prints it
Flagcurl 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"
Worked example — sign up, log in, call an authenticated endpoint
FlagThe 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.)
-X, -L and exit codes — the flags that do less (or more) than you think
FlagThree 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?
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?
You suspect your Authorization header never actually goes out. Which flag shows you?
TOKEN holds a valid access token, yet curl -H 'Authorization: Bearer $TOKEN' http://localhost:4123/me returns 401 with error="invalid_token". Why?
A script posts user comments with curl -d "$COMMENT" …. One comment is @channel please review. With curl 8.3.0 or later, what happens?
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?
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?
Fill in the blank
Send this body as JSON in one flag (curl 7.82.0+).
Flagcurl '{"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 @.
curl -H ': application/json' '{"email":"ada@example.com"}' http://localhost:4123/auth/signup
Send the captured access token. (Note the double quotes.)
Flagcurl -H "Authorization: $TOKEN" http://localhost:4123/me
Save the cookies from login, then send them (and save the rotated one) on refresh.
Flagcurl 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.
Flagcurl -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
$ curl -s -v http://localhost:4123/orders/42 > out.txt $ cat out.txt
# /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