Google Sign-In on a Worker, Without a Library

Verifying Google ID tokens with Web Crypto on Cloudflare Workers, and why the session ended up as a signed cookie instead of a KV row.

I needed a login for the admin side of this site. One user — me — with a second tier for anyone I want to show it to without letting them break things.

The obvious move is an auth library. On Cloudflare Workers the obvious move is unavailable: there is no Node runtime, no crypto module, no filesystem, no long-lived process to hold a session table in memory. What there is: fetch, crypto.subtle, and the standard Web APIs.

That turns out to be enough. The whole thing is 258 lines.

The flow

Two routes. /auth/google starts it:

app.get('/auth/google', c => {
  const origin = new URL(c.req.url).origin
  const state = crypto.randomUUID()
  setCookie(c, 'oauth_state', state, authCookieOpts(c, 60 * 10))
  return c.redirect(getGoogleAuthUrl(c.env, state, origin))
})

/auth/callback finishes it: check the state, trade the code for tokens, verify the ID token, mint a session.

The state parameter is the CSRF defence. Google hands it back untouched; if it doesn't match the cookie, the callback didn't originate from a login this browser started.

I know exactly how load-bearing that check is, because for a few hours it wasn't there:

// Verify state matches to prevent CSRF
// const savedState = getCookie(c, 'oauth_state');
// if (!state || state !== savedState) {
//     return c.json({ success: false, error: 'Invalid state parameter' }, 400);
// }

I commented it out to isolate a redirect-URI problem, fixed the redirect, and re-enabled it the same afternoon. The commit is called re-enable CSRF state check and I left the name deliberately blunt. A security control that gets switched off to debug something else is the single easiest thing in a codebase to forget about.

Verifying the ID token

Google returns an ID token: a JWT signed with RS256 using a key from a rotating set. Trusting it means verifying the signature against Google's published keys — not decoding the payload and believing it.

const res = await fetch('https://www.googleapis.com/oauth2/v3/certs')

const cc = res.headers.get('Cache-Control') ?? ''
const maxAge = parseInt(cc.match(/max-age=(\d+)/)?.[1] ?? '3600', 10)

const { keys } = (await res.json()) as { keys: JWK[] }

const keyMap: Record<string, CryptoKey> = {}
for (const jwk of keys) {
  const key = await crypto.subtle.importKey(
    'jwk',
    jwk,
    { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
    false,
    ['verify']
  )
  keyMap[jwk.kid] = key
}

Two details in there matter more than they look.

The cache TTL comes from Google, not from me. Google publishes a Cache-Control header on that endpoint. Reading max-age off the response means the cache expires when Google says it should, rather than on a number I picked and would never revisit.

A key I don't recognise is a cache-miss, not an error. Google rotates signing keys. If a token arrives with a kid that isn't in the cached map, the right response is to assume my cache is stale, drop it and fetch once more:

let keys = await getGooglePublicKeys()
let publicKey = keys[header.kid]

if (!publicKey) {
  cachedKeys = null
  keys = await getGooglePublicKeys()
  publicKey = keys[header.kid]
}

if (!publicKey) throw new Error(`No matching public key for kid: ${header.kid}`)

Without that retry, every rotation locks everyone out until the TTL happens to expire.

The cache lives in module scope. On Workers that means per-isolate, which is exactly the right granularity — no binding, no coordination, and a cold isolate just pays one extra fetch.

Claims, then identity

Signature verified is not the same as safe. Four more checks:

if (payload.iss !== 'https://accounts.google.com') throw new Error('Invalid token issuer')
if (payload.aud !== env.GOOGLE_CLIENT_ID) throw new Error('Invalid token audience')
if (payload.exp < Math.floor(Date.now() / 1000)) throw new Error('Token expired')
if (payload.email_verified !== true) throw new Error('Google account email is not verified')

The audience check is the one people skip. Without it, a validly-signed Google token issued for any other application is accepted here. Google signed it, so the signature is fine — it just wasn't meant for me.

Only then does the email become an identity:

const email = payload.email.toLowerCase()
const allowed = env.ALLOWED_EMAILS.split(',').map(e => e.trim().toLowerCase())
const role: Role = allowed.includes(email) ? 'admin' : 'reader'

Anyone with a Google account can sign in. Only the allowlist gets admin. Everyone else becomes a reader — which is a feature, not a leftover: it is how I hand someone a working tour of the dashboard without handing them the delete button.

The session: KV, then no KV

The first version stored sessions in a KV namespace. wrangler.toml had this:

[[kv_namespaces]]
binding = "SESSION"

I deleted it a week later. The commit is four lines, all removals, and it is my favourite change in the whole project.

A session here carries an email, a name, a subject and a role. That is small, it is not secret, and it changes only at login. Storing it server-side buys revocation — which for a one-user admin panel I can get by rotating a secret — and costs a KV read on every single request, plus a binding, plus eventual-consistency questions I'd rather not think about.

So the session became a signed cookie: the same JWT shape, HMAC-SHA256 instead of RSA, verified with the secret rather than a lookup.

const key = await crypto.subtle.importKey(
  'raw',
  new TextEncoder().encode(env.SESSION_SECRET),
  { name: 'HMAC', hash: 'SHA-256' },
  false,
  ['sign']
)

Stateless sessions have one genuinely nasty failure mode, and it is worth naming: when the payload shape changes, old cookies still verify. The signature is valid — they were minted by the same secret — so a session from last month sails through with fields the new code expects and doesn't find. If role is missing and the code reads user.role !== 'admin', an old admin silently becomes a reader. Or worse, depending on which way the check runs.

The fix is a version number that is checked, not just stored:

export const SESSION_VERSION = 1

// ...
if (payload.ver !== SESSION_VERSION) return null
if (payload.role !== 'admin' && payload.role !== 'reader') return null

Bump it and every outstanding cookie is dead. Everyone re-logs in. That is a two-second inconvenience for one person, and it is the difference between a shape change being a non-event and being a privilege bug.

What this bought

No auth dependency to track, no session store, no vendor between me and Google. The parts I had to write myself — signature verification, key rotation, the state check — are the parts I would have had to understand anyway the first time something broke.

The parts I got for free were the ones I'd have paid for: crypto.subtle is in the runtime, fetch is in the runtime, and the whole thing runs at the edge with no cold start worth measuring.

Next: the dashboard this is protecting, and why the Astro site and the Worker share a single hostname.