October 28, 2026
Google’s Session Cookie Carried Its Own Signing Key
A signature only proves something while the key stays out of the attacker’s reach. In a $90k Google bug, the key was packed inside the…

By Tinguyen
8 min read
A signature only proves something while the key stays out of the attacker's reach. In a $90k Google bug, the key was packed inside the cookie it signed.
The line that stopped me in Simon Koeck's writeup was short. He'd taken a value out of a Google session cookie, tried it as the Flask secret key, and the signature checked out.
"The signing key was stored inside the object it signs."
The writeup is called I Found a $90k Remote Code Execution Vulnerability in Google, and the title earns its click. The bug sat in DevSite, the shared platform behind developers.google.com, firebase.google.com, developer.android.com and a few other Google documentation sites. Google paid $90,000 for it.
I won't redo the exploit. Koeck explains it well, and it's his work. What I keep thinking about is the design underneath. Every piece of it looks reasonable in a code review, and every piece passes its tests. Put together, they let a stranger run code on Google's servers.
A wax seal only means something while the stamp stays locked in a drawer. Once you ship the stamp inside the envelope, the seal just looks official.
Some background first, for anyone who hasn't used Flask.
Flask's default session isn't stored on the server. It's stored in the user's browser as a cookie. The Flask docs say so plainly: the session is "implemented on top of cookies for you and signs the cookies cryptographically." That means a user "could look at the contents of your cookie but not modify it, unless they know the secret key used for signing."
The whole design rests on that last clause.
The signing comes from a library called itsdangerous. It serializes the session, adds an HMAC computed with the app's SECRET_KEY, and sends the result to the browser. When the cookie comes back, Flask recomputes the HMAC and compares. If they match, the server assumes it wrote this data and nobody has touched it.
That's a fine design. Lots of production apps run on it, and they aren't wrong to.
But look at what the signature doesn't promise. It doesn't hide anything, since the contents are just encoded, not encrypted. It doesn't make the contents safe to run. It answers one question: was this made by someone who holds the key?
If anyone can hold the key, the signature is decoration.
The DevSite cookie was set during an OAuth sign-in flow, on an endpoint that handed out sessions even to visitors who weren't logged in. According to Koeck, the session held a pickled OAuth2WebServerFlow object from Google's oauth2client library, which the project's own README marks as deprecated.
That flow object carried the OAuth client credentials, client secret included, in plaintext. Decode the cookie and the secret is right there.
And that same client secret was the Flask SECRET_KEY.
So anyone who visited the page got a cookie, and the cookie contained the exact value needed to forge more cookies. The stamp was in the envelope.
Now the second ingredient.
Out of the box, Flask serializes sessions as tagged JSON. JSON is boring in a good way. Parse it and you get strings, numbers, lists and dicts. A forged JSON session can lie to you. It can claim is_admin: true, and that's a bad day. What it can't do is make your interpreter run code just by being parsed.
Pickle is different, and Python doesn't hide that. The official pickle docs open with a warning: "The pickle module is not secure. Only unpickle data you trust." They add that "it is possible to construct malicious pickle data which will execute arbitrary code during unpickling."
That isn't a quirk of an old version. It's how pickle works. It rebuilds objects by calling functions, and the data gets to pick which functions.
So a pickled session wasn't the Flask default. At some point somebody chose it, or a library chose it for them. Storing a whole OAuth flow object in the session is the kind of thing that saves you a database table on a Friday afternoon. I don't know the actual history and I won't pretend to.
I can guess how it felt, though, because I've made calls that felt exactly like this one.
It's signed. Only we can make these cookies. So only our own data ever reaches pickle.loads.
All three sentences are true as long as the key stays secret. Koeck puts it almost the same way: pickle.loads is only "safe" as long as nobody else can forge a valid cookie. The whole safety argument hung on one variable, and that variable was printed inside the cookie.
What bothers me most is that no single ingredient is the bug.
A signed cookie session with a protected key is fine. Pickle on data that really only came from your own process is fine; the Python docs say trusted data is okay. Reusing a credential is sloppy, but on its own it's usually a cleanup ticket, not a $90k payout.
What breaks is the chain. Pickle was only safe because of the signature. The signature only meant something because of the key. The key was sitting in the cookie, readable by anyone.
Each layer leaned on the one below it, and the bottom one was never solid.
When I look at my own stack, I don't usually think in chains. I think in parts. Is the session secure? It's signed, so yes. Is the deserializer safe? It only reads our data, so yes. I check each box on its own and each one comes back green.
What I don't ask enough is what that check is standing on, and how far the damage goes if it gives way.
Here, it went all the way down. Koeck forged a cookie with a pickle payload that called time.sleep, then watched the server response stretch to match: about three seconds when he asked for three, about five when he asked for five. He stopped there. No data touched, nothing destructive. A sleep timer was enough to prove that a leaked signing key had turned into remote code execution.
Google's bounty shows how seriously they took it: $75,000 for the RCE tier, times 1.2 for report quality. The report went in on August 6, 2026, was triaged the next day, and the fix shipped on October 7.
There's a phrase I keep coming back to here: blast radius.
Every secret has one. If my database password leaks, the blast radius is my database. If my payment provider's API key leaks, it's my payment account. Those are awful days, but they have edges, and I can picture them.
A session signing key feels smaller. In most apps the honest blast radius is "someone can pretend to be a user, or edit what's in their session." That's serious, but it stops at whatever the session holds.
Put pickle on the read path and the radius becomes "someone can run code on the server." The key didn't change. The cookie format barely changed. A decision made somewhere else, maybe much later and by someone else, quietly turned a medium-risk secret into a critical one.
Nobody re-rated the key when that happened, because nobody knew it needed re-rating.
That's a cost I don't see discussed much. You don't pay for a decision when you make it. You pay later, when some other decision changes what the first one means.
I run Garther, an event-booking platform in Ho Chi Minh City. We're small. We don't have a security team the size of Google's, and we certainly don't have a bounty budget with five figures in it.
So when I read something like this, my first question isn't "how did Google miss this?" Google employs some of the best security people around, and it still shipped. That should worry me more than it reassures me.
The question I actually care about is this one: where in my own system is a decision safe only because of an assumption nobody wrote down?
I don't have a clean answer yet. But I know what shape I'm looking for.
A config value that's "secret" only because nobody has read the repo carefully. A default that holds only while one library stays on its current version. An internal endpoint that's fine because it isn't exposed, until someone puts a proxy in front of it. One credential doing two jobs because it was already in the environment. A deserializer that's safe because its input is trusted, where "trusted" means "signed," and "signed" means "the key is where I think it is."
That last chain is the one Koeck walked down. Every link looked like protection. Only the key was actually holding anything back, and it was in the cookie.
Here's what's changed in how I think about building, after reading this. It isn't advice for anyone else. It's just where I've ended up.
I've stopped treating "signed" as "safe to run." A signature tells me where data came from. It doesn't tell me the data is harmless. If a piece of code would be dangerous on forged input, I want to fix the code, not bet everything on forgery being impossible. In practice that means keeping sessions dumb: an ID, a timestamp, a few flags. If I need a rich object, I store a reference and load the object from the database. It's a bit slower and much easier to reason about. JSON can lie to me, but it can't take over the process.
I've started thinking of a secret's risk as something that depends on what sits downstream of it. When I write down what a key protects, the list has to include what the code does after it trusts anything signed with that key. That list changes every time someone touches the read path, so the rating can't be set once and forgotten.
I want each key to have exactly one job. The DevSite bug didn't need a leaked .env file or a stolen laptop. A credential that belonged to the OAuth flow got reused as the session key, and then the OAuth flow got stored in the session. Each step probably looked harmless. Separate keys for separate purposes, kept in a secrets store away from code and data, would have broken the chain at the first link. Google's fix did that, among other things: it rotated the secrets, gave Flask its own key, moved the OAuth flow server-side, and swapped pickle for data-only formats. Four fixes for one bug, because there were four links.
And I've gotten more suspicious of leaving framework defaults, especially security-related ones. Flask uses JSON for sessions on purpose. When I move away from a default like that, I want to be able to say in one sentence what I'm giving up. If I can't, I probably shouldn't move.
There's one part of this story I find comforting.
The bug was found by someone outside Google. It was reported, triaged within a day, paid out, and fixed. That's the system working. Bounty programs exist because no team can see every chain in its own code. Someone who doesn't share your assumptions starts at the other end and pulls until something gives.
That's roughly what I want from code review, too. Not just "does this function look right," but "what is this function trusting, and should it be?"
That's a harder question to ask in a review. You have to read past the diff into what the diff leans on. You have to say something that sounds a bit paranoid in a friendly PR thread: what happens if this input wasn't made by us?
I don't think I've asked that often enough. When a value came from our own cookie, our own queue, or our own cache, I've treated it as ours. Most of the time it is.
"Most of the time" is exactly what the pickled session was counting on.
I keep picturing that envelope. Wax pressed, everything looks proper, and the brass stamp sitting inside next to the letter.
Nobody involved did something obviously reckless. The seal was real and the letter was real. Someone packed the stamp in the wrong place, and someone else built a process that opened any envelope with a valid seal and did whatever the letter said.
That's what this bug is to me. It isn't about one bad line. It's about trust passed down a chain until the last link was doing something the first link never signed up for.
So the rule I'm keeping for myself is simple. When I trust something, I want to know what that trust rests on, and I want it written down where I'll see it again. When someone changes what happens downstream, that's the moment to check whether the original trust still holds.
A signature tells me where something came from. I'm trying to stop reading it as permission to run whatever's inside.