Blog · Engineering
How to Reverse-Engineer a Web App's Auth Flow: Cookies, JWTs, Refresh Tokens, and PKCE
Finding a web app’s private API endpoints is the easy part. Open DevTools, filter to XHR, and there they are. The part that stops people is authentication: you copy the request, replay it, and get a 401. Or it works for ten minutes and then stops. Or it works from the browser and never from your script.
This post covers the four auth patterns you will actually meet in a HAR file, how to recognise each one, and what it takes to reproduce it.
An authentication flow is the sequence of requests that turns a set of credentials into a short-lived token, plus the rules for attaching that token to subsequent requests and renewing it when it expires. Reproducing a flow means identifying every step in that chain — not just the final request that carried the data you wanted.
The core mistake
Nearly everyone starts the same way: find the data request, copy it as cURL, run it, watch it work, build on it, and discover a day later that everything is broken.
The copied request contained a token that was valid at capture time. It is now expired. What you needed was not the token but the mechanism that produces tokens.
So the question to ask of a HAR is never “what header did this request send?” It is “where did this header’s value come from, and how do I get a fresh one?”
Pattern 1: Session cookies
The oldest and still the most common pattern. You POST credentials to a login endpoint, the server responds with Set-Cookie, and the browser attaches that cookie to everything afterwards.
How to spot it in a HAR: look for a response with a Set-Cookie header carrying HttpOnly, and subsequent requests with a matching Cookie header. The cookie value is usually opaque — a random session ID, not something you can decode.
What it takes to reproduce: a cookie jar. In curl, that is -c and -b. In Python, requests.Session(). The important thing is that you re-run the login step rather than pasting a captured cookie.
import requests
session = requests.Session()
# The login call sets the cookie on the session automatically.
session.post(
"https://example.com/login",
json={"email": EMAIL, "password": PASSWORD},
)
# Subsequent calls carry it without any manual header work.
orders = session.get("https://example.com/api/v2/orders").json() Where it goes wrong: a CSRF token. Many login forms require a token that was embedded in the login page — so the real chain is GET the page, scrape the token, POST it with the credentials. If your login POST returns 403 with a body mentioning CSRF, that is what happened. Look one request earlier in the HAR.
Pattern 2: Bearer tokens and JWTs
The dominant pattern in modern SPAs. A login call returns a token in the JSON body, and the frontend attaches it as Authorization: Bearer <token>.
How to spot it: an Authorization header starting with Bearer. If the value has three dot-separated segments, it is a JWT and you can decode it.
Decoding a JWT is not cracking it — the payload is base64, deliberately readable. It tells you when the token expires, which is the single most useful fact for planning a reproduction:
# Decode the payload segment (second of three).
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq
# Typical payload:
# {
# "sub": "user_1a2b3c",
# "scope": "orders:read profile:read",
# "iat": 1753027200,
# "exp": 1753030800 <-- 3600s lifetime
# } An exp one hour after iat tells you a captured token is useless tomorrow and that there must be a refresh mechanism somewhere in the trace.
What it takes to reproduce: find the request that issued the token — the one whose response body contains it — and replay that. It is usually a POST to something like /oauth/token, /api/auth/login, or /session.
Pattern 3: Refresh tokens
Once you have found the token issuer, you will often find a second one: a call that trades a long-lived refresh token for a new short-lived access token.
How to spot it: in a long session, look for a repeated call to the same auth endpoint that appears every N minutes, whose response contains a new access token. Its request body will contain something like grant_type=refresh_token. Sorting your HAR chronologically makes this pattern obvious — it is the request that keeps reappearing.
What it takes to reproduce: a token store with expiry awareness. Refresh proactively rather than waiting for a 401 — you avoid a whole class of race conditions:
import time, requests
class TokenStore:
def __init__(self, refresh_token):
self._refresh_token = refresh_token
self._access_token = None
self._expires_at = 0
def get(self):
# Refresh 60s early to avoid using a token that expires mid-flight.
if time.time() > self._expires_at - 60:
self._refresh()
return self._access_token
def _refresh(self):
r = requests.post(
"https://example.com/oauth/token",
data={
"grant_type": "refresh_token",
"refresh_token": self._refresh_token,
},
)
r.raise_for_status()
payload = r.json()
self._access_token = payload["access_token"]
self._expires_at = time.time() + payload["expires_in"]
# Some servers rotate the refresh token on every use. If you ignore
# the new one, your next refresh fails and you are logged out.
if "refresh_token" in payload:
self._refresh_token = payload["refresh_token"] That last detail catches people constantly. Refresh token rotation means the server invalidates the old refresh token each time it issues a new one. If you keep reusing the original, the second refresh fails — and many servers treat a reused refresh token as evidence of theft and revoke the entire session.
Pattern 4: OAuth 2.0 with PKCE
The most involved pattern, and the one where reading a HAR pays off most, because the flow spans multiple redirects that are easy to lose track of.
PKCE (Proof Key for Code Exchange) exists so a public client — a SPA or mobile app that cannot keep a secret — can do an OAuth code exchange safely. The chain is:
- The client generates a random
code_verifierand derivescode_challenge = base64url(sha256(code_verifier)). - It redirects to the authorization endpoint with
code_challengeandcode_challenge_method=S256. - The user authenticates; the server redirects back with a
code. - The client POSTs
codeplus the originalcode_verifierto the token endpoint. - The server recomputes the challenge, compares, and issues tokens.
How to spot it in a HAR: a request to an /authorize endpoint with code_challenge and code_challenge_method in the query string, followed by a redirect carrying ?code=, followed by a POST to a token endpoint.
What it takes to reproduce: you must generate your own verifier — the captured one is single-use and already spent.
import base64, hashlib, secrets
def make_pkce_pair():
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
digest = hashlib.sha256(verifier.encode("ascii")).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
return verifier, challenge
verifier, challenge = make_pkce_pair()
# Send challenge on the /authorize redirect, and keep verifier
# to send with the code exchange in step 4. The state parameter matters too: it is CSRF protection for the redirect, and many servers reject a token exchange whose state does not match the one issued.
The stop signs
Some things you find in a trace are deliberate signals that programmatic access is not on offer.
Request signing (HMAC). A header carrying a hash over the method, path, body, and a timestamp means the signing key and algorithm live in the site’s JavaScript. Extracting it from minified, often obfuscated code is a large effort, and its presence is an explicit statement about intended use.
Device or integrity attestation. Headers tied to a browser fingerprint or an attestation API are designed specifically to make non-browser clients fail.
Aggressive short expiries — a 60-second token — combined with a signing requirement generally means the same thing.
On your own systems, these are implementation details you already have the keys to. On someone else’s, they are the clearest possible indication to stop and use the official API, or ask.
Scope: what this is for
The techniques here are ordinary debugging skills. Where you point them determines whether you are doing engineering or creating a problem for yourself.
Clearly fine: your own product’s API, your own authenticated account on a service, systems you have written authorization to test, and public data. Reverse-engineering your own auth flow is genuinely one of the better security exercises available — you will frequently find a token with a longer lifetime or broader scope than anyone intended.
Not fine: automating a third-party platform in ways its terms prohibit. Terms of service are enforceable, platforms do pursue both users and vendors, and the fact that a request is technically reproducible says nothing about whether you are permitted to make it.
Frequently asked questions
Why does my captured request work in the browser but not in my script?
Usually a header you dismissed as noise. Origin, Referer, and User-Agent are all commonly validated. Diff your outgoing request against the HAR entry field by field.
How do I tell which headers actually matter? Remove them one at a time and replay. Tedious by hand, which is why TraceMiner’s sandbox exists — but the method is the same either way.
The token is in localStorage, not a cookie. Does that change anything?
Only where it is stored. It still arrives via an Authorization header, and the issuing call is still in the trace.
What if login requires MFA? Then the flow is not fully automatable by design, which is the point of MFA. Some services issue long-lived API tokens for programmatic use — that is the supported path, and it is the one to take.
TraceMiner reads the whole chain out of a HAR — including the token exchange and refresh calls that are easy to miss — and lets you replay each step before you build on it. Start with a HAR.
Published blog cards
Browse other published topics from the blog library.
Debugging Production With Two HAR Files: Working vs Broken
It works for you and fails for one customer. Capture a HAR from each, diff them, and the cause is usually obvious within minutes. A practical method.
Read blog → AI AgentsGive Your AI Agent an API Instead of a Browser: HAR to MCP Server in 10 Minutes
Browser automation makes agents read pages like humans — slow, expensive, and brittle. Here is how to turn a HAR file into an MCP server your Claude Code agent calls directly.
Read blog → EngineeringWhat is a HAR file and How it is structured
HTTP Archive (HAR) format files contain network request data in JSON format to track website performance. Learn its structure and how to analyze it with examples.
Read blog →