Why I was even looking
I liked the platform, and I wanted to use its public API for a couple of small side projects. So I did the usual thing: opened the network tab, watched the requests the site was making, and started rebuilding the calls I needed.
They didn't work. Every request the site sent carried a header I didn't recognize, a 64-character hash, and it was different every single time. Leave it off and the API just ignored you. No cookie, no bearer token, no login screen. Just this one weird header sitting between me and a 200.
And that's the kind of thing I can't let go of. So the side projects got shelved, and the actual project became: okay, what is this thing, and where's it coming from?
Finding the function
First, an honest disclaimer: the bundle was minified, not obfuscated. Big difference, and it's why none of this was impressive. Minification renames everything to t, e, r and so on, but the logic still runs the way it was written, and the strings are all still there in plain text. Including the header name.
So my big hacker move was Ctrl+F. I searched the bundle for the header name and landed basically on top of the function that built the signature. Tiny thing, all single-letter variables, but you can read it fine:
let s = (t, e, n) => {
let i = new URL(t).pathname
, s = (0, r.sha256)(`${i}:${e}:${n}`);
return (0, r.sha256)(`${n}:${s}`)
}Three args, two hashes, and one line I kept coming back to: new URL(t).pathname. It gets handed the whole URL and then immediately throws away everything except the path. Remember that part.
So yeah, not a story about me being clever. The code basically ratted itself out. Readable logic plus a header named in plain English meant there was nothing really to reverse, and that's already the first thing wrong with the whole idea, though it took me a bit to see it that way.
Watching it run
Reading the function tells you what it probably does. I wanted to actually watch it, so I dropped a breakpoint on it and just let the site do its thing.
This is where the design did me a favor. It signs every request, which means the function goes off constantly on its own. I didn't have to poke at any specific endpoint, I just sat there and the site fed me call after call, each one frozen with everything laid out: the full URL going in, the path it pulled out, the timestamp, the key, and the hash that came out the other end.
Watching those real values is what took it from "I'm pretty sure" to actually knowing. Every single call, the only piece of the URL that made it into the hash was the path. The query string, whatever was in it, just never showed up. So the thing that's supposed to guard the requests was also quietly handing me, one call at a time, a full list of what it did and didn't care about.
Reading the scheme
Once the breakpoint was feeding me live values, it wasn't hard to piece together.
It's a double SHA-256, chained. Takes the path and a Unix timestamp, mixes in a static key, hashes it, then hashes that result with the key one more time. Whatever comes out is the header. The timestamp gets sent in its own header too, so the server can run the same math and check.
Squint and it looks like HMAC. It's not. HMAC is built the way it is for actual reasons; this is just stuff glued together and hashed, homemade. But honestly the crypto isn't the problem. Two decisions around it are.
First one's the key. It's read from a client-side env var that the framework itself flags as public. If you've used Next.js you know the NEXT_PUBLIC_ prefix, it means "yes, ship this to the browser on purpose." So the secret that's supposedly authorizing every request isn't a secret at all. Every visitor gets a copy. Nobody leaked anything, they shipped it and called it a key.
Second one's the part I actually care about, and it's that new URL(t).pathname from earlier. The runtime values backed it up: it signs the path and tosses everything else. The query never touches the hash.
Sit with that for a second, because it's worse than it sounds. Hit the same endpoint twice in the same one-second window and you get the exact same signature, no matter what's in the query. The server sees path, timestamp, key, all good, and has no idea the query is different. Page numbers, filters, ranges, whatever, you can change all of it and the signature doesn't budge. It's not really signing the request. It's signing the route, kind of, for about a second.
Confirming it
Figuring out how something works and actually pulling it off are two different things, and only the second one counts. So I built the two headers myself, in my own little client, no browser involved, and threw them at the real API.
Worked first, well, it worked. The server took a signature I made completely outside their app and treated it like the real thing. That's the whole point. Not "I think this is how it goes," but the server nodding along to a request I put together from scratch.
What this says to the defensive side
Nothing here involved breaking any crypto. SHA-256 is fine, it did its job. The whole thing fell apart on design, which is actually the useful part, because design is the part you get to control.
A secret the client has to carry isn't a secret. That's just how it works: if the signing happens in the browser, the key has to be in the browser, and if it's in the browser, I can read it. These folks didn't even pretend otherwise, the variable says public right on it. Signing on the client can slow down a lazy scraper, sure. But against someone who actually wants in with their own client, it does nothing, because there's no asymmetry anywhere. Their app and my script are holding the exact same cards.
Then there's what you sign. Sign the whole request, not just a corner of it. The path-only thing is the mistake I find more interesting, because you can trip over it even with a proper server-side secret. If the query isn't in the signature, the query isn't protected, simple as that. Signing half the request feels like integrity without actually being it. Anything you left out is free real estate.
And a timestamp isn't a nonce, even though it kind of looks like one. If now() is the only thing changing, then two identical requests in the same window hash to the same signature, and replaying inside that window is trivial. What actually fixes it is a real nonce, handed out by the server, good for one use, which costs you a round trip. Which is probably exactly what they were trying to dodge.
If you want request signing that actually holds up, it looks roughly like this: a secret that never lands in the browser, a signature that covers the whole request including query and body, and a one-time nonce from the server instead of just reading the clock. It's more work than a client-side hash. That extra work is the security. The cheap version looks like auth in a screenshot and behaves like a speed bump in practice.
Closing
The part that stuck with me isn't that it broke. It's that the reasons it broke are exactly what you'd jot down if someone asked you to review the design before shipping. Public key, half a signature, a clock instead of a nonce. None of it is exotic. I just happened to be reading the list from the wrong side.
Anyway. I never did get around to that side project.