DEV Community

Cover image for Firebase Admin SDK won't run on Cloudflare Workers, so I replaced it with fetch + WebCrypto
Toma Okugawa
Toma Okugawa

Posted on

Firebase Admin SDK won't run on Cloudflare Workers, so I replaced it with fetch + WebCrypto

TL;DRfirebase-admin cannot run on Cloudflare Workers (protobufjs generates code at runtime). Everything I actually needed is plain HTTP: verify ID tokens with firebase-auth-cloudflare-workers, mint a service-account access token with WebCrypto RS256, use Firestore REST v1 (beginTransaction / batchGet / commit) for real transactions, and Identity Toolkit REST to delete users. About 300 lines, running in production, source on GitHub.

I'm 17, I study at a technical college on a small island in Japan's Seto Inland Sea, and I run a paid membership video platform in production: Okugawa Lab. It's a Next.js 16 app deployed to Cloudflare Workers through OpenNext, with Firebase Authentication and Cloud Firestore behind it. Last week I open-sourced the whole thing (toma-okugawa/okugawa-lab).

This post is about the single biggest wall I hit while building it, and the ~300 lines that got me over it.

The wall

firebase-admin does not run on Cloudflare Workers. It's not a configuration problem. Somewhere under the SDK sits protobufjs, which generates code from strings at runtime, and workerd forbids that outright:

EvalError: Code generation from strings disallowed for this context
Enter fullscreen mode Exit fullscreen mode

There are no flags to flip. If you need server-side Firebase on Workers, you don't get the Admin SDK.

What I actually needed was smaller than the SDK anyway:

  1. Verify Firebase ID tokens sent by the browser
  2. Read and write Firestore, with real transactions (I sell access as serial codes, and redeeming one must be atomic)
  3. Delete a user's Auth account when they delete their own data

Each of those is an HTTP API. So that's what I used.

1. Verifying ID tokens

For token verification there's already a solid library: firebase-auth-cloudflare-workers. It uses only Web-standard APIs and has zero dependencies. It wants a KeyStorer to cache Google's public keys; the docs use Workers KV, but an in-memory store scoped to the isolate is enough for a small site:

import { Auth, type KeyStorer } from "firebase-auth-cloudflare-workers";

class MemoryKeyStorer implements KeyStorer {
  private value: string | null = null;
  private expiresAt = 0;
  async get<T>(): Promise<T | null> {
    if (this.value === null || Date.now() > this.expiresAt) return null;
    return JSON.parse(this.value) as T;
  }
  async put(value: string, ttlSeconds: number) {
    this.value = value;
    this.expiresAt = Date.now() + ttlSeconds * 1000;
  }
}

const auth = Auth.getOrInitialize(PROJECT_ID, new MemoryKeyStorer());
const decoded = await auth.verifyIdToken(bearerToken); // throws on invalid
Enter fullscreen mode Exit fullscreen mode

One gotcha that cost me an afternoon: the library converts its JwtError into a FirebaseAuthError before throwing, and a failure to fetch Google's public keys comes out through the same generic branch. So you can't tell "your token is bad" (401) from "Google is unreachable" (503) by error class. I check the message prefix Error fetching public keys and map that to a 503, and everything else to a 401. Not elegant, but it keeps a valid session from being logged out because of a transient outage.

2. A service-account access token with WebCrypto

Everything else needs an OAuth2 access token for the service account. The Admin SDK does this for you; without it, you sign a JWT yourself and exchange it. Workers has crypto.subtle, which is all you need:

const encoder = new TextEncoder();

function b64url(data: Uint8Array | string): string {
  const bytes = typeof data === "string" ? encoder.encode(data) : data;
  let bin = "";
  for (const b of bytes) bin += String.fromCharCode(b);
  return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

function pemToArrayBuffer(pem: string): ArrayBuffer {
  const body = pem
    .replace(/-----BEGIN PRIVATE KEY-----/, "")
    .replace(/-----END PRIVATE KEY-----/, "")
    .replace(/\s+/g, "");
  const bin = atob(body);
  const bytes = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
  return bytes.buffer;
}

async function getAccessToken(sa: ServiceAccount, scope: string): Promise<string> {
  const now = Math.floor(Date.now() / 1000);
  const header = b64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
  const claims = b64url(JSON.stringify({
    iss: sa.client_email,
    scope,
    aud: "https://oauth2.googleapis.com/token",
    iat: now,
    exp: now + 3600,
  }));
  const signingInput = `${header}.${claims}`;

  const key = await crypto.subtle.importKey(
    "pkcs8", pemToArrayBuffer(sa.private_key),
    { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["sign"],
  );
  const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, encoder.encode(signingInput));
  const jwt = `${signingInput}.${b64url(new Uint8Array(signature))}`;

  const res = await fetch("https://oauth2.googleapis.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
      assertion: jwt,
    }),
  });
  const data = (await res.json()) as { access_token: string; expires_in: number };
  return data.access_token;
}
Enter fullscreen mode Exit fullscreen mode

In the real code the token is cached per scope until 60 seconds before expiry. I deliberately keep two tokens: one with the Firestore scope (https://www.googleapis.com/auth/datastore) and one with the Identity Toolkit scope. The code path that reads and writes documents never holds a token that could delete accounts. Least privilege costs nothing here.

3. Firestore over REST, with transactions that are actually transactions

The part I expected to be painful turned out to be the best part. Firestore's REST v1 API exposes beginTransaction, batchGet, commit, and rollback directly. A transaction looks like this:

const base = `https://firestore.googleapis.com/v1/projects/${PROJECT_ID}/databases/(default)`;

async function fs(path: string, body?: unknown) {
  const token = await getAccessToken(sa, "https://www.googleapis.com/auth/datastore");
  return fetch(`${base}${path}`, {
    method: body ? "POST" : "GET",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });
}

// 1. begin (pass the previous id on retry: Firestore prioritizes retries, like the SDK does)
const { transaction } = await (await fs("/documents:beginTransaction",
  retryOf ? { options: { readWrite: { retryTransaction: retryOf } } } : {})).json();

// 2. read inside the transaction
const rows = await (await fs("/documents:batchGet", {
  documents: [`${base}/documents/serialCodes/${code}`, `${base}/documents/users/${uid}`],
  transaction,
})).json();

// 3. decide, then commit all writes atomically
await fs("/documents:commit", {
  transaction,
  writes: [
    {
      update: { name: `${base}/documents/serialCodes/${code}`,
                fields: { status: { stringValue: "used" }, usedBy: { stringValue: uid } } },
      updateMask: { fieldPaths: ["status", "usedBy"] },
      currentDocument: { exists: true },
    },
    {
      update: { name: `${base}/documents/users/${uid}`,
                fields: { premiumUntil: { timestampValue: newExpiry.toISOString() } } },
      updateMask: { fieldPaths: ["premiumUntil"] },
    },
  ],
});
Enter fullscreen mode Exit fullscreen mode

Things worth knowing that the docs don't shout about:

  • batchGet can abort too. Firestore transactions use pessimistic locking on the server side, so a lock conflict can surface at the read step, not only at commit. Wrap the whole thing in the retry loop, not just the commit.
  • ABORTED arrives as HTTP 409. I classify errors by the gRPC status name in the response body (ABORTED, UNAVAILABLE, RESOURCE_EXHAUSTED, INTERNAL) plus HTTP 409/429/500/503 as retryable, which mirrors what the official transaction runner retries.
  • Deleting a field, not nulling it. Put the field path in updateMask.fieldPaths and leave it out of fields. That removes it. Writing nullValue keeps the field around with a null in it, which is not the same thing when a privacy law asks you to erase personal data.
  • runQuery is a streaming method. Over HTTP/JSON you get an array of RunQueryResponse, and some elements carry no document at all (progress/end markers). Filter them out or you'll have a very confusing undefined.
  • Writes without a transaction are still atomic. A single commit applies its writes atomically and in order. You only need beginTransaction when a write depends on something you read.

4. Deleting an Auth user

Same pattern, different API. Identity Toolkit REST:

const res = await fetch(
  `https://identitytoolkit.googleapis.com/v1/projects/${PROJECT_ID}/accounts:delete`,
  {
    method: "POST",
    headers: { Authorization: `Bearer ${identityToolkitToken}`, "Content-Type": "application/json" },
    body: JSON.stringify({ localId: uid }),
  },
);
Enter fullscreen mode Exit fullscreen mode

I treat USER_NOT_FOUND as success so the whole deletion flow is idempotent: if anything fails halfway, the user just presses the button again. And I delete Firestore data before the Auth account, never after; if you delete Auth first and Firestore fails, the person can no longer log in to retry.

Two traps outside Firebase

These bit me on the same project and belong in the same post:

OpenNext bakes .env.local into your Worker bundle. @opennextjs/cloudflare writes every env value into .open-next/cloudflare/next-env.mjs at build time, so a naive deploy uploads your service-account private key in plaintext, even if the runtime value comes from wrangler secret. I run a small script between build and deploy that strips non-public values and then greps the bundle for the removed strings, failing the deploy if any survive. (scripts/strip-build-secrets.mjs)

Never log the raw error object. A FirestoreError's message includes the response body, and the response body includes document names, which in my case are serial codes and user ids. On a route that erases personal data, that would copy the data you just deleted into Cloudflare's logs. Log the error class and stage; nothing else.

What this costs

Nothing beyond Firebase itself. No KV namespace, no Durable Objects, no extra packages except the token verifier. Token exchange happens once an hour per isolate; everything else is one fetch per operation.

The full implementation is src/lib/firebase/admin-rest.ts in the repository, and the serial-code redemption route that uses the transaction is src/app/api/redeem/route.ts. The repo is AGPL-3.0, runs with zero configuration in a preview mode, and I'm happy to take issues and PRs in English or Japanese. If you've solved the same problem differently, especially the token-cache question across isolates, I'd genuinely like to hear it.


I'm Toma Okugawa, a student researcher at NIT Yuge College in Japan. Besides this platform I work on pose estimation and low-light image enhancement; the rest of what I do is on GitHub and t-okugawa.dev.

Top comments (5)

Collapse
 
crdtcto profile image
Kane Lim

This is a really strong example of adapting a backend architecture to the execution environment instead of trying to force a Node-oriented SDK into Cloudflare Workers.

The most interesting part for me is that you reduced the Firebase Admin surface area to exactly what the application needed: WebCrypto for service-account JWT signing, REST APIs for Firestore/Identity Toolkit, and a lightweight verifier for Firebase ID tokens. That keeps the Worker compatible with Web APIs while preserving important server-side guarantees.

A few details especially stand out:

• Transaction semantics: treating beginTransaction → batchGet → commit as one retryable unit is critical. Retrying only the commit would miss conflicts that occur during reads.

• Least privilege: separating Firestore and Identity Toolkit OAuth scopes is an excellent security boundary. The code performing normal document operations doesn't need account-deletion capability.

• Deletion ordering: removing application data before the Auth account is a thoughtful recovery strategy. Idempotent deletion plus retryable failures makes the privacy workflow much more robust.

• Secret handling: the OpenNext build-time environment behavior is probably the most important operational warning in the entire post. A secret accidentally embedded in a generated Worker bundle is a much bigger problem than an SDK incompatibility. Build-artifact scanning should definitely be part of CI/CD.

• Error handling: avoiding raw Firebase/Firestore errors in logs is another detail that is easy to overlook. Error messages can unintentionally turn observability systems into secondary data stores.

For the token-cache question, I would treat the in-memory isolate cache as an optimization rather than a correctness mechanism. Isolate lifetime and distribution aren't guarantees, so the design should remain correct when every request performs a fresh key/token fetch. A shared cache can improve efficiency, but it shouldn't become a dependency for authentication correctness.

Also, ~300 lines replacing a large server SDK isn't really the main achievement here. The more valuable result is that you've made the trust boundaries, permissions, retry behavior, and failure modes explicit rather than hiding them behind an abstraction.

At 17, getting these production concerns right—atomic redemption, least privilege, secret leakage prevention, idempotent deletion, and failure classification—is genuinely impressive engineering.

I'd be interested in following how this evolves, particularly around distributed token/key caching and concurrency under real production load.

Collapse
 
t_okugawa profile image
Toma Okugawa

Thank you, Kane — this is a more precise summary of the design than I managed to write myself.

On the token cache: agreed, and that's how it's built. Nothing depends on the cache existing. A cold isolate signs a fresh JWT and exchanges it (one round trip to Google), and the public-key store simply refetches the JWKS. Both are correct on every request; the cache only removes latency. The one thing I haven't done is dedupe concurrent cold starts inside an isolate — two simultaneous requests can both mint a token. It's harmless (last write wins, both tokens are valid), but a promise-keyed map would clean it up.

For distributed caching I've deliberately stayed away from KV for access tokens: they're bearer secrets with a one-hour life, and storing them elsewhere buys a few hundred milliseconds per isolate at the cost of a new place they can leak. The JWKS is different — it's public, so the Cache API keyed by URL would be a reasonable next step if key fetches ever showed up in the latency profile.

The real concurrency risk in this system isn't tokens, it's a hot document: every AI request touches the same per-model daily quota document in a Firestore transaction. Serial-code redemption is fine (each code is its own document), but that quota counter is the first thing I'd move to a Durable Object under real load. Traffic is small today, so I have no honest numbers yet — when I do, that will be the follow-up post.

And thank you for the framing about explicit trust boundaries; that's a better one-line description of the codebase than "300 lines".

Collapse
 
crdtcto profile image
Kane Lim

I am glad to hear that my answer was of great help to you.
I would appreciate it if you could share your telegram username so that we can continue to cooperate.

Thread Thread
 
t_okugawa profile image
Toma Okugawa

Thanks, but I keep technical discussions public — here in the comments or in the GitHub issues. I'm not moving to private channels.

Thread Thread
 
crdtcto profile image
Kane Lim

I am not saying about technical discussion.
just i wanna collaborate with you.
please contact me you will be interested
t_g_coolsoftdev