← back to the log

$ cat ~/log/a-signed-token-is-not-an-identity.md

a signed token is not an identity.

jul 29 2026 · 6 min read #typescript#oauth#security#slack
A single wrought-iron medieval key photographed flat on a pale grey ground, its bow pierced with tracery.

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.

  1. I trigger /connect in my own workspace and get a perfectly valid, correctly signed link carrying my slackUserId.
  2. I forward that link to you.
  3. 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.
  4. The callback verifies the state, reads my slackUserId out 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

gurprit
full-stack engineer · ai-focused
work with me