Why firebase-admin Breaks on Cloudflare Workers — and the 300-Line Replacement
By Arash Latifi
firebase-admin dies on workerd because protobufjs generates code at runtime. The fix: plain fetch + WebCrypto for JWT, Firestore REST with real transactions.
TL;DR — The 30-second version:
firebase-admincrashes on Cloudflare Workers becauseprotobufjsinside it usesnew Functionto generate code at load time — and Workers (workerd) blockseval/Functionentirely. The build passes, production falls over on the first real request. Fix: ditch the SDK, replace it with ~300 lines of plainfetch+ WebCrypto. Three jobs, three endpoints: 1) verify ID tokens withfirebase-auth-cloudflare-workers, 2) mint your own access token viacrypto.subtle(RS256), 3) talk to Firestore over its REST API with realbeginTransaction/commit. That's it — the rest of this post is the how and why.
You have a Next.js 16 app on Cloudflare Workers via OpenNext. Everything's humming — until you add one innocent line:
and production greets you with:
No flag fixes it. nodejs_compat doesn't save you. And the cruelest part: the build succeeds. The error only shows up on the first real request — exactly where it hurts most.
A 17-year-old in Japan hit the same wall running a paid video platform and solved it in production with ~300 lines of fetch + WebCrypto — no SDK. The result is actually cleaner than what it replaces. Let's walk through it like we're fixing it together over coffee.
Why it breaks — a kitchen analogy
Think of firebase-admin as a chef who writes recipes on the fly — literally generating functions from strings with new Function(...). On Node.js, nobody stops him. There's a whiteboard, there are markers, write whatever you want.
Cloudflare Workers (workerd) is a different kitchen — a sterile, high-security V8 isolate. The rule on the wall says: "No writing recipes mid-service. For security and cold-start speed, eval and new Function are banned." The chef walks in, tries to write, hits a locked whiteboard. Service stops.
Under the hood, that's protobufjs — the library firebase-admin depends on. At import time it builds functions for every protobuf message. Not a Firebase bug, just a hidden assumption: "I always run on Node where anything goes."
The sticky note for your wall: Any server SDK that pulls in protobufjs, grpc, or new Function will die on the edge. Ten-second check:
If you find it, you know it'll blow up on Workers — before production tells you.
The practical fix: you only need HTTP
Here's the liberating truth — you probably only need three things from firebase-admin. All three have a plain HTTP equivalent:
| What you need | The SDK way | The REST way |
|---|---|---|
| Verify an ID token | admin.auth().verifyIdToken | firebase-auth-cloudflare-workers |
| Read/write Firestore | admin.firestore() | firestore.googleapis.com |
| Delete a user | admin.auth().deleteUser | identitytoolkit .../accounts:delete |
No heavy SDK. Just fetch. Let's do it step by step.
Step 1: Verify tokens without the SDK
The library firebase-auth-cloudflare-workers was built for exactly this — zero dependencies, only web-standard APIs. It just needs a place to cache Google's public keys. For a small-to-medium site, in-memory inside the isolate is plenty:
That's it. No protobufjs, no 400KB bundle. Like bringing a screwdriver instead of the whole toolbox.
Step 2: Mint an access token with WebCrypto (RS256)
To talk to Firestore you need to prove who you are. In Node you'd use crypto.createSign; on Workers you use crypto.subtle — same idea, different API.
Analogy: you're writing a letter, stamping it with your private seal, and exchanging it at Google's counter for an entry pass.
Pro tip from the trenches: Cache this token at module scope — mint once per isolate, reuse for an hour. Minting inside the request handler adds a round-trip to Google on every single request and doubles your latency.
Step 3: Real Firestore transactions over REST
Say you're selling serial activation codes — two people must not redeem the same code. The SDK gives you transaction; REST gives you the same thing in three fetches:
beginTransaction → read the doc → commit with currentDocument.exists
That last precondition is the magic — a compare-and-swap: "only write if it still exists as I saw it." If someone else grabbed it first, the commit fails and you know you lost the race.
Simple, transparent, and genuinely atomic — not a simulation.
Pitfalls that cost real hours (so you don't pay them)
- The space after
Bearer.Authorization: ***without the space silently returns 401. Check the template literal first — it's always the template literal. - Cache the token at module scope. Each isolate mints once and reuses for an hour. Minting inside the handler doubles latency on every request.
- Never drop
currentDocument. Without it,commitis a blind write. The transaction only means something with the precondition. - Retry the whole transaction on
ABORTED, not just the commit call. wrangler devis not optional. "Passes on Node, fails on isolate" is exactly where this class of bug lives. Always test on workerd.
What it costs — and when not to do it
Real numbers: firebase-admin adds ~400KB before gzip to a Next.js bundle and still won't boot on Workers even with nodejs_compat. The REST version is ~300 lines and a few KB. Latency improves too — one fetch per operation, token minted once an hour.
Honest caveat — this is not the default answer. If your app runs on Node or Vercel Serverless, keep firebase-admin. The SDK's wins are real: automatic batching, retries, a type-safe query builder, and onSnapshot for realtime. What you build here is single-purpose CRUD.
Make the switch when:
- the runtime is an isolated edge environment (Workers / Deno Deploy / Vercel Edge)
- you need a handful of specific operations, not the whole API surface
- server bundle size matters (cold starts under traffic spikes)
If none of those apply, don't build your own SDK.
Security: where the private key lives
The thing everyone skips once the bug is fixed: don't keep PRIVATE_KEY_PEM in a plain env var. On Workers, store it with:
so the value travels over stdin and never lands in the repo.
And watch the newlines! The most common cause of invalid_grant is that the key was stored with the literal two characters \n instead of real newlines — then importKey fails on a PEM that still has escaped newlines. If you get invalid_grant and everything looks right, check first:
If you see \\n instead of an actual line break, that's your bug.
Why this matters beyond Firebase
If you deploy Next.js to Vercel or Cloudflare, the pattern repeats everywhere: @supabase/supabase-js is edge-safe, stripe needs care around node:crypto, and anything touching fs or child_process is out. The default move is always the same — check the REST API first, reach for the SDK second. Plain HTTP means no bundle bloat, no cold-start penalty, no runtime surprises, and code that ports unchanged between Workers, Vercel and Deno.
For how the Next.js caching layer behaves on these same runtimes, read Next.js 15 Cache and PPR. The deployed Vercel example is in DashView, and if you want to ask a technical question, my about page has the details.