Audit a Browser HAR Before a Model Sees the Session You Captured
The login form spun. 401, then 302, then 401 again. I opened DevTools, reproduced it once, and hit Export HAR. The file landed on my desktop as login.har. Forty-seven requests. One of them was the IdP token endpoint. I w
The login form spun. 401, then 302, then 401 again. I opened DevTools, reproduced it once, and hit Export HAR. The file landed on my desktop as login.har. Forty-seven requests. One of them was the IdP token endpoint. I was one paste away from asking a model why the cookie refused to stick.
Would you have opened the file first?
I did not, not the first time I wired this fixture. The HAR held Set-Cookie, an Authorization: Bearer header, and a refresh token in a JSON body. That is not a stack trace. That is a credential export sitting on the wrong side of a trust boundary.
The failing sequence
Here is the event chain I keep reproducing in a lab folder. No production tenant. No real IdP. Just a file that looks like the one you almost pasted.
- Browser records a login redirect.
- DevTools writes HTTP Archive 1.2 to disk.
- You copy the HAR, or a βsanitizedβ excerpt, into a model prompt.
- The model provider now holds the session cookie, the bearer, and often the refresh token.
- You rotate nothing, because it βwas only debugging.β
Sound familiar? The failure is not that the model is clever. The failure is that a HAR is a full-fidelity wire dump, and dump formats do not care about your intent.
Trust boundaries, not vibes
A HAR crosses four boundaries I actually care about.
| Layer | What it holds | Who can read it after a paste |
|---|---|---|
| Browser process | Live cookies, in-flight POSTs | You, extensions, the OS |
| Workstation disk | The .har file, editor swap, shell history |
Backup agents, sync clients, the next intern |
| Clipboard / chat UI | Whatever you selected | Screenshot tools, paste logs |
| Remote model API | Prompt + attachments + provider logs | The vendor, operators, whoever they retain |
The invariant I want is boring. No Cookie, Set-Cookie, Authorization, OAuth token, or password field may leave the workstation toward a model. If that sentence makes you flinch, good. That is the point.
What must not go to a model? Session cookies. Bearer tokens. Refresh tokens. client_secret. Authorization codes in query strings. Password fields in postData. CSRF tokens tied to a live session. Anything that lets the recipient become you.
What can go? Status codes. Hostnames you already publish. Redirect hop counts. A redacted method/path list. Timing. A synthetic reproduction script. That is usually enough to debug a 401.
Minimal fixtures (pinned, synthetic)
Pinned for this write-up: HTTP Archive 1.2, Python 3.12 stdlib only, jq 1.7 if you want a one-liner. The hosts are .test. The secrets are canaries. This is a regression fixture, not a claim that I found a live leak.
Negative fixture β should pass the gate. Static asset, no credentials:
{
"log": {
"version": "1.2",
"entries": [
{
"request": {
"method": "GET",
"url": "https://app.example.test/assets/app.css",
"headers": [{"name": "Accept", "value": "text/css"}],
"queryString": [],
"postData": {"text": ""}
},
"response": {
"status": 200,
"headers": [{"name": "Content-Type", "value": "text/css"}],
"content": {"text": "body{margin:0}"}
}
}
]
}
}
Positive fixture β must fail the gate. One login hop, three credential classes:
{
"log": {
"version": "1.2",
"entries": [
{
"request": {
"method": "POST",
"url": "https://idp.example.test/oauth/token?code=AUTHCODECANARY",
"headers": [
{"name": "Authorization", "value": "Bearer eyJhbGciOiJub25lIn0.CANARY"},
{"name": "Cookie", "value": "session=SESSCANARY"}
],
"queryString": [{"name": "code", "value": "AUTHCODECANARY"}],
"postData": {"text": "grant_type=authorization_code&client_secret=SECRETCANARY"}
},
"response": {
"status": 200,
"headers": [
{"name": "Set-Cookie", "value": "session=SESSCANARY; HttpOnly; Secure"}
],
"content": {"text": "{\"refresh_token\":\"REFRESHCANARY\"}"}
}
}
]
}
}
Save them as fixtures/har_clean.har and fixtures/har_session.har. If your gate cannot tell them apart, you do not have a gate.
A workstation audit you can run
Label: this is a local fixture script, not a scanner I ran against anyoneβs production HAR.
#!/usr/bin/env python3
"""Fail closed if a HAR still carries session material. Python 3.12."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from urllib.parse import urlparse, parse_qs
SENSITIVE_HEADERS = {
"authorization",
"proxy-authorization",
"cookie",
"set-cookie",
"x-api-key",
"x-auth-token",
}
SENSITIVE_PARAMS = {
"access_token",
"id_token",
"refresh_token",
"token",
"password",
"passwd",
"code",
"client_secret",
"client_id",
"api_key",
"session",
}
BODY_RE = re.compile(
r"(password|passwd|refresh_token|access_token|client_secret|id_token|session)=",
re.I,
)
def _headers(obj: dict) -> list[tuple[str, str]]:
out = []
for h in obj.get("headers") or []:
out.append((str(h.get("name", "")), str(h.get("value", ""))))
return out
def audit(path: Path) -> list[str]:
har = json.loads(path.read_text(encoding="utf-8"))
hits: list[str] = []
for i, entry in enumerate(har.get("log", {}).get("entries", []), start=1):
req, resp = entry.get("request") or {}, entry.get("response") or {}
url = req.get("url") or ""
parsed = urlparse(url)
qs = parse_qs(parsed.query, keep_blank_values=True)
for name, values in qs.items():
if name.lower() in SENSITIVE_PARAMS:
hits.append(f"entry {i}: query {name} on {parsed.hostname}{parsed.path}")
for collection, label in ((req, "req"), (resp, "resp")):
for name, value in _headers(collection):
if name.lower() in SENSITIVE_HEADERS and value.strip():
hits.append(f"entry {i}: {label} header {name}")
for blob in (
(req.get("postData") or {}).get("text") or "",
(resp.get("content") or {}).get("text") or "",
):
if BODY_RE.search(blob):
hits.append(f"entry {i}: body matched credential pattern")
try:
data = json.loads(blob) if blob.strip().startswith("{") else {}
except json.JSONDecodeError:
data = {}
if isinstance(data, dict):
for key in data:
if str(key).lower() in SENSITIVE_PARAMS:
hits.append(f"entry {i}: json key {key}")
return hits
def main() -> int:
if len(sys.argv) != 2:
print("usage: har_audit.py <file.har>", file=sys.stderr)
return 2
path = Path(sys.argv[1])
hits = audit(path)
if hits:
print("FAIL: HAR still carries session material")
for h in hits:
print(f" - {h}")
return 1
print("PASS: no session headers, token params, or password fields")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Expected fixture output, local and synthetic:
$ python3 har_audit.py fixtures/har_clean.har
PASS: no session headers, token params, or password fields
$ python3 har_audit.py fixtures/har_session.har; echo exit:$?
FAIL: HAR still carries session material
- entry 1: query code on idp.example.test/oauth/token
- entry 1: req header Authorization
- entry 1: req header Cookie
- entry 1: resp header Set-Cookie
- entry 1: body matched credential pattern
- entry 1: json key refresh_token
exit:1
If you only have jq, this is the header half. It is not complete. It is a tripwire.
jq -r '
.log.entries[]
| . as $e
| (
($e.request.headers[]? | select(.name|ascii_downcase|test("cookie|authorization|x-api-key"))),
($e.response.headers[]? | select(.name|ascii_downcase|test("set-cookie")))
)
| "HIT \(.name)"
' fixtures/har_session.har
A hit means stop. Do not βsummarize the HAR for the model.β Summaries still leak the first 20 characters of a cookie, and those 20 characters are often enough.
Redact, then ask β never the reverse
I keep a drop folder the model is allowed to read. The HAR never goes there raw.
mkdir -p /tmp/model-inbox
python3 har_audit.py ./login.har && echo "refusing: gate must fail on a real login HAR"
What I actually send after a fail is a skeleton I type by hand:
IdP: idp.example.test
App: app.example.test
Hop 1: GET /login -> 302 to /oauth/authorize
Hop 2: POST /oauth/token -> 200
Hop 3: GET /app -> 401 (cookie not sent on API origin)
Question: is this a Secure+Domain mismatch or a SameSite=Lax POST?
No cookies, no bearers, no codes.
That is enough. If the model cannot debug a 401 without the cookie value, the model is not the bottleneck. Your reproduction is.
Prevent / detect / recover
| Control | Layer | What it buys you | What it does not |
|---|---|---|---|
| Do not export production HARs | Process | No credential file exists | Useless if staging shares the IdP |
har_audit.py on a pre-commit or drop folder |
Workstation / CI | Fail closed before a model sees the file | Will miss novel header names |
| Allowlist prompt inbox | Agent harness | Model tools cannot open() *.har
|
Does not stop a human paste |
| Rotate session + refresh + client secret | IdP | Caps blast radius after a paste | Too late if you already pasted |
| Provider retention review | Legal / vendor | You know the retention story | Does not unsay a prompt |
Prevent first. Detect in CI if .har files ever land in the repo. Recover by rotating, not by asking the model to βforget.β
I treat a self-hosted coding workspace as one more enforcement layer, not as magic. MonkeyCode is an open-source AI development platform I evaluate for that reason β keeping the model on my side of the HAR boundary, instead of shipping the capture to a public chat. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Operator-supplied for this piece: free model access and a free server option, which is relevant only after the gate is green. I still refuse to let the model vote on whether Set-Cookie is in scope.
Limitations, and who should not do this
This script is a deny-list. Deny-lists rot. A header named X-Session-Blob sails through. Multipart bodies, base64 cookies, and WebSocket frames inside a HAR are out of scope here. Redaction is not anonymization. Paths still identify tenants. Timestamps still identify people.
A local or self-hosted model does not erase the file. Prompts hit disk. Swap hits disk. Backups hit disk. If your threat is βa coworkerβs laptop sync,β you need disk policy, not a smarter chatbot.
Who should not use this approach? Anyone whose plan is to HAR a production user and then βbe careful.β Anyone debugging an incident that already needs legal hold β you do not run a hobby redactor over evidence. Anyone who thinks a passing gate means the HAR is safe to email. It is not. The gate only says the patterns I listed were absent.
Also skip this if you do not own the app. Exporting a session HAR from a site you do not control is how you accidentally steal someone elseβs cookie. Including your own, in a shared browser profile.
What I put in CI versus what I enforce by hand
Numbered, because this is the only part that survives a week later:
- Pre-commit: reject
*.harunless it lives underfixtures/and passeshar_audit.py. - Agent allowlist: tools may read
fixtures/and/tmp/model-inbox, never~/Downloads. - Human rule: if DevTools can see
Set-Cookie, the model cannot see the file. - Recovery drill: rotate the canary session in the fixture repo on a schedule, so the rotate path is not theoretical.
The trend right now is to paste richer artifacts because models debug faster than we type. Faster is not a trust boundary. A HAR is a credential format that happens to end in .har.
So which invariant belongs in CI, and which layer should enforce it? I put βno session HAR in git or in the model inboxβ in CI. I put βdo not export production sessionsβ in the human layer, because no regex will save you from capturing the wrong browser profile. If you only pick one, pick the human rule. The script is there for the night you are tired and the 401 will not die.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.