$ cat ~/log/a-signed-token-is-not-an-identity.md
a signed token is not an identity.
Prism is a Slack agent I’ve been building, and it needs to read your Google Calendar to know which meetings to prep you for. That means an OAuth handoff — you click a link in Slack, you consent at Google, and we store a refresh token against your account.
The link carries a signed state token. It’s HMAC-signed with a timing-safe comparison, it
expires after fifteen minutes, and nobody can forge it. It was still enough to let me steal your
calendar, and it was live in shipped code.
In this article we’ll go through the attack, why the textbook CSRF fix does nothing about it, and what actually closed the hole.
What the token carries
Here’s the whole verification path:
// src/lib/state.ts
export function verifyState(token: string): StatePayload {
const [data, sig] = token.split('.')
if (!data || !sig) throw new Error('malformed state token')
const expected = createHmac('sha256', KEY).update(data).digest('base64url')
const a = Buffer.from(sig)
const b = Buffer.from(expected)
if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw new Error('bad state signature')
}
const obj = JSON.parse(Buffer.from(data, 'base64url').toString()) as SignedPayload
if (typeof obj.exp !== 'number' || obj.exp < Date.now()) {
throw new Error('state token expired')
}
return { teamId: obj.teamId, slackUserId: obj.slackUserId }
}
Have a proper look at that before reading on. There is nothing wrong with the crypto. The HMAC is right, the comparison is timing-safe, the expiry is checked.
The problem is the last line. The token is a bearer value asserting which Slack user to attach a calendar to, and there is nothing anywhere binding it to whoever actually authenticates at Google.
The attack
so here’s what that buys an attacker.
- I trigger
/connectin my own workspace and get a perfectly valid, correctly signed link carrying myslackUserId. - I forward that link to you.
- You click it. You log into your Google account. You grant calendar access — the consent screen shows your email, everything looks exactly as it should.
- The callback verifies the state, reads my
slackUserIdout of it, and dutifully stores your calendar refresh token on my row.
I now read your calendar for as long as that refresh token lives.
The signature was never the problem. The token was valid the entire time and every check passed. It just described the wrong side of the transaction.
The fix that doesn’t work
The textbook fix for CSRF on an OAuth callback is a cookie-bound nonce — put a random value in a cookie, put the same value in the state, and require them to match on the way back.
I traced through it and it does nothing here. You run the entire flow in your own browser: you receive the link, you click it, you consent, you land on the callback. A cookie nonce would be set and read in the same session and round-trip perfectly happily.
Nothing is being replayed across sessions, which is what a cookie nonce defends against. The token is simply being handed to the wrong person, and it stays internally consistent the whole way through. this one took me a while to accept.
I had a correct mitigation for a named attack class. The named attack class wasn’t the one I had.
The fix that does
Stop taking identity from the token, and take it from whoever actually authenticated at Google.
We added the openid email scope, pull the verified email out of the id_token Google returns,
and refuse to store anything unless it matches the Slack user’s email:
// src/google/callback.ts
const user = await deps.getUserBySlack(teamId, slackUserId)
if (!user) throw new Error('No matching user — DM the bot once first, then reconnect.')
const googleEmail = await deps.getGoogleEmail(tokens.id_token)
if (googleEmail.trim().toLowerCase() !== user.email.trim().toLowerCase()) {
throw new Error(
`This Google account (${googleEmail}) does not match your Prism email (${user.email}). ` +
`Connect the Google account for ${user.email}.`,
)
}
await deps.setGoogleRefreshToken(user.id, tokens.refresh_token)
Run the attack again. Your Google account comes back as you@corp.com, my Prism email is
something else, the callback throws before setGoogleRefreshToken is ever reached, and nothing
is written anywhere. The theft fails.
The id_token is the load-bearing part. It’s signed by Google and it attests to who actually sat
in front of the consent screen, which is the one fact the state token can never carry, because
we’re the ones who wrote the state token.
The adjacent one we found on the way
While we were in there we caught a second problem in the same callback. Google’s incremental auth
can hand back a reconnect carrying only openid+email, silently dropping the Calendar scope. You
end up holding a valid token that cannot read a calendar, and the symptom is a Home tab that’s
mysteriously empty forever.
So we refuse to store a token that doesn’t carry the scope we asked for, and fail loudly with an instruction rather than quietly with an empty tab:
// src/google/callback.ts
// Guard against the incremental-auth drop: a reconnect can come back with only
// openid+email (Calendar silently omitted), leaving a token that can't read the
// calendar. Refuse to store it so the failure is loud, not a silent empty Home.
if (!(tokens.scope ?? '').split(' ').includes(CALENDAR_SCOPE)) {
throw new Error(
'Google did not grant Calendar access. Reconnect and make sure the Calendar permission stays checked.',
)
}
What I’d take from it
A signature answers is this token authentic. it does not answer is the person in front of me the person this token is about. Any flow that links two accounts together needs that second answer, and it has to come from the identity provider’s own attestation, not from data you put into the token yourself.
The other half of this is how it was found. It wasn’t a test. Every step in that flow passes its tests, individually and together, because every step is doing what it was written to do. It took a red team of the install spec — someone whose entire job for an afternoon was to try to steal from it.
if you have an auth flow you’ve never handed to somebody with that instruction, that’s where I’d start.
Further reading
- OAuth 2.0 Security Best Current Practice (RFC 9700) — the general shape of this problem, and a lot of neighbours you probably also have
- Validating an ID token — Google’s own docs on the mechanism the fix leans on
- Using OAuth 2.0 for Web Server Applications — the incremental auth behaviour behind the second bug
- Slack OAuth with the Node SDK — our callback
ended up mirroring how
InstallProviderbinds identity