DEV Community

jaryn
jaryn

Posted on

Reproduce a Bearer Token Replay From a curl -v Fixture

The internal billing API returned 401. I needed a second pair of eyes, so I pasted the full curl -v transcript into the agent and asked why the token was rejected.

That paste still contained Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.... The model did not need the credential to explain a 401. The next tool call might. Conversation logs definitely would.

So the invariant is not "the model promised not to leak." It is this: verbose HTTP debug never crosses into model context until hop-by-hop credential headers are stripped. A gate. Not manners.

The trust boundary you actually crossed

A 401 debug session looks harmless. Status line, JSON error body, maybe a WWW-Authenticate challenge. Then you add -v and you have moved a live secret across four principals at once.

[operator shell] -- curl -v stderr --> [clipboard / paste]
        |                                      |
        v                                      v
[tool result channel] ------------------> [model context]
        |                                      |
        v                                      v
[prompt / response logs]                 [next tool arguments]
Enter fullscreen mode Exit fullscreen mode

Which of those layers is allowed to see Authorization? Only the shell that already held the token. Everything after that is a different principal. Ask yourself: if the agent later calls write_file or http_request, is the bearer still in-window? If your telemetry stores prompts, did you just persist a live token next to a "why is this 401" question?

I treat the rest of this article as a fixture walkthrough, not a claim that I found a production breach. Synthetic values only. Pinned tools for the commands below: curl 8.11.1, Python 3.12.

What curl -v actually emits

Useful parts of a 401: the path, the status, the error body, the challenge. Not useful: the request header that proves you already had the secret.

* Connected to billing.internal (10.1.2.3) port 443
> GET /v1/invoices HTTP/1.1
> Host: billing.internal
> Authorization: Bearer FAKESECRET_g2h3i4j5k6l7m8n9o0p1
> User-Agent: curl/8.11.1
> Accept: application/json
>
< HTTP/1.1 401 Unauthorized
< WWW-Authenticate: Bearer error="invalid_token"
< Content-Type: application/json
{"error":"token expired"}
Enter fullscreen mode Exit fullscreen mode

Same class of leak, different names: Cookie, Proxy-Authorization, X-Api-Key, X-Amz-Security-Token, X-Auth-Token. If your agent wraps curl, httpie, or a generated SDK client with debug logging, you are in this threat model even when nobody "pastes" anything. The tool-result channel is the paste.

Fixtures first: one must block, one must pass

I keep two files under fixtures/http-verbose/. If a scanner cannot tell them apart, you do not have a control. You have a regex you hope is lucky.

Positive fixture (must-block.txt) — expected: exit 2, hit on Authorization.

> GET /v1/invoices HTTP/1.1
> Host: billing.internal
> Authorization: Bearer FAKESECRET_g2h3i4j5k6l7m8n9o0p1
> User-Agent: curl/8.11.1
< HTTP/1.1 401 Unauthorized
{"error":"token expired"}
Enter fullscreen mode Exit fullscreen mode

Negative fixture (must-pass.txt) — expected: exit 0. The 401 body and the challenge stay. No credential header.

> GET /v1/invoices HTTP/1.1
> Host: billing.internal
> User-Agent: curl/8.11.1
> Accept: application/json
< HTTP/1.1 401 Unauthorized
< WWW-Authenticate: Bearer error="invalid_token"
{"error":"token expired"}
Enter fullscreen mode Exit fullscreen mode

The canary JWT is the point. It must never be a production token. If that exact string later appears in a tool argument or a prompt log, the boundary failed even if a human "redacted" the first paste by eye.

Numbered workflow: capture, scan, strip, prompt, prove

1. Capture the dump without widening the blast radius

Write stderr to a file. Do not pipe it straight into the agent.

curl -sS -D - -o /tmp/body.json -v \
  -H "Accept: application/json" \
  -H "Authorization: Bearer ${BILLING_TOKEN}" \
  https://billing.internal/v1/invoices \
  > /tmp/billing-401.headers 2> /tmp/billing-401.verbose
Enter fullscreen mode Exit fullscreen mode

${BILLING_TOKEN} already lived in the shell. The file /tmp/billing-401.verbose is the new copy. Treat it like a secret until the gate says otherwise.

2. Scan with a boring, deterministic gate

Label: local scanner, not an LLM classifier. I want it dull enough to put in CI.

#!/usr/bin/env python3
"""http_verbose_gate.py — fail closed on hop-by-hop secrets in HTTP dumps."""
from __future__ import annotations

import re
import sys
from pathlib import Path

BLOCK = re.compile(
    r"(?im)^(?:[<>*]\s+)?(?:"
    r"authorization|proxy-authorization|cookie|set-cookie|"
    r"x-api-key|x-amz-security-token|x-auth-token"
    r")\s*:",
)
BEARER = re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._\-+=/]+")

def scan(text: str) -> list[str]:
    hits = []
    for i, line in enumerate(text.splitlines(), 1):
        if BLOCK.search(line) or BEARER.search(line):
            hits.append(f"L{i}: {line.strip()[:80]}")
    return hits

def main(argv: list[str]) -> int:
    if len(argv) != 2:
        print("usage: http_verbose_gate.py <dump.txt>", file=sys.stderr)
        return 2
    text = Path(argv[1]).read_text(encoding="utf-8", errors="replace")
    hits = scan(text)
    if hits:
        print("BLOCKED: verbose HTTP dump still contains credential headers")
        print("\n".join(hits))
        return 2
    print("PASS: no hop-by-hop credential headers")
    return 0

if __name__ == "__main__":
    raise SystemExit(main(sys.argv))
Enter fullscreen mode Exit fullscreen mode
python3 http_verbose_gate.py fixtures/http-verbose/must-block.txt; echo exit:$?
# expected: BLOCKED, L3 Authorization, exit 2

python3 http_verbose_gate.py fixtures/http-verbose/must-pass.txt; echo exit:$?
# expected: PASS, exit 0
Enter fullscreen mode Exit fullscreen mode

If must-block.txt ever exits 0, the fixture is the regression. Do not negotiate with it.

3. Strip, then rebuild the prompt from the 401 body only

python3 - <<'PY'
from pathlib import Path
import re, sys
src = Path("/tmp/billing-401.verbose").read_text(encoding="utf-8", errors="replace")
block = re.compile(
    r"(?im)^(?:[<>*]\s+)?(?:authorization|proxy-authorization|cookie|set-cookie|x-api-key|x-amz-security-token|x-auth-token)\s*:.*\n?"
)
redacted = block.sub("> Authorization: Bearer [REDACTED]\n", src)
redacted = re.sub(r"(?i)Bearer\s+[A-Za-z0-9._\-+=/]+", "Bearer [REDACTED]", redacted)
Path("/tmp/billing-401.redacted").write_text(redacted, encoding="utf-8")
print("wrote /tmp/billing-401.redacted")
PY
python3 http_verbose_gate.py /tmp/billing-401.redacted; echo exit:$?
# expected: PASS after strip; if still BLOCKED, do not prompt
Enter fullscreen mode Exit fullscreen mode

Now the model gets the status line, the challenge, and {"error":"token expired"}. It does not get the bearer. That is the whole product of this workflow.

4. Prompt on the redacted side of the boundary

Unexecuted template — fill in your own completion endpoint:

# template, not executed against a public chatbot
printf '%s\n' \
  "Explain this 401. Do not request credentials. Headers are redacted." \
  "---" \
  "$(cat /tmp/billing-401.redacted)" \
  "---" \
  "$(cat /tmp/body.json)" > /tmp/prompt.txt
Enter fullscreen mode Exit fullscreen mode

If you cannot explain a 401 without the original Authorization line, the model is not the right debugger. The token was already rejected. The body is the evidence.

5. Prove the canary did not replay

After the turn, scan downstream artifacts, not just the input dump.

CANARY='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.CANARY_NOT_A_SECRET'
grep -R --line-number -F "$CANARY" \
  /tmp/prompt.txt \
  /var/log/agent/ \
  ./tool-args.json \
  && echo "BOUNDARY FAIL: canary replayed" && exit 2
echo "canary absent from prompt, logs, and tool args"
Enter fullscreen mode Exit fullscreen mode

No canary check means you only proved the first file was redacted. You did not prove the agent failed to re-emit it.

Threat model, in one table

Boundary Secret that should stop Failure mode Evidence I accept
Shell → file Bearer in curl -v stderr Engineer pastes verbose output must-block.txt exits 2
File → model Same header after "please debug" Tool result concatenated into context gate on the prompt blob
Model → next tool Canary JWT in arguments Agent retries the API "with the token it saw" grep on tool-arg JSON
Model → logs Bearer in prompt store SaaS or self-hosted telemetry keeps transcripts SIEM hit on Bearer eyJ

Prevent / detect / recover is not a slogan. It is three different owners.

Layer Prevent Detect Recover
Operator shell Do not alias debug curl into the agent stdin Too late once it is on the clipboard Rotate the token; you cannot unpaste
Tool result channel Strip hop-by-hop headers before completion Gate exit 2 fails the turn Retry with 401 body only
Model context Never ask for "full verbose output" Canary JWT in the positive fixture Wipe the conversation; invalidate the canary
Telemetry Prompt logs deny Authorization / Bearer Alert on Bearer eyJ in agent logs Treat the log store as a secret incident

What this gate is not

This does not make kubectl get secret -o yaml a safe tool. It does not parse HAR, docker inspect Env, or terraform state. Those encodings are out of scope here. A header regex will also lose to base64 -w0, to split lines, to HTTP/2 binary dumps, and to gRPC traces. Label those as uncovered.

It does not stop a model from requesting a secret. If the harness already injects BILLING_TOKEN into an exec tool, the credential is on the wrong side of the boundary before any dump exists. Result redaction is a backstop, not a permission model.

Who should not use this as their only control? Anyone whose agent has unrestricted exec. Anyone who thinks a 30-line scanner replaces allowlisted tools. Anyone about to paste a real 401 transcript into a public chatbot "just this once" because the gate passed on a fixture.

Where a self-hosted completion path actually participates

I still want a model to explain the 401. I do not want that paste in a third-party prompt log I do not control.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode is an open-source, self-hosted AI development platform. For this workflow two operator-supplied facts matter: free model access, and a free server option. That lets the "explain this 401" loop hit a completion endpoint on your side of the diagram above. The gate still runs before the prompt is built. The product does not replace the fixture, and it does not change which headers are forbidden.

If you already self-host a coding agent, put http_verbose_gate.py in front of the tool-result path and add the two fixtures to CI. That is the only ask.

Limitations I will not paper over

  • Expected evidence is exit codes on must-block.txt and must-pass.txt. I have not executed this against your billing API.
  • Free model access and a free server option only change which network the remaining prompt crosses. They do not make verbose dumps safe.
  • ASCII header matching is the scope. Binary protocol dumps need a different parser; do not pretend this one works there.
  • Synthetic JWTs in fixtures must never have been production tokens. Rotate anything that touched a real dump.

Which invariant belongs in CI? The positive/negative pair, every commit that touches the agent harness. Which layer should enforce it? The tool-result channel — not the model's manners, and not a human remembering to edit the paste.

Top comments (0)