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 nullBump 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.