Originally published on August 27, 2026. Republished after an accidental deletion — the content is unchanged.
TL;DR —
firebase-admincannot run on Cloudflare Workers (protobufjs generates code at runtime). Everything I actually needed is plain HTTP: verify ID tokens withfirebase-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
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:
- Verify Firebase ID tokens sent by the browser
- Read and write Firestore, with real transactions (I sell access as serial codes, and redeeming one must be atomic)
- 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
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;
}
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"] },
},
],
});
Things worth knowing that the docs don't shout about:
-
batchGetcan abort too. Firestore transactions use pessimistic locking on the server side, so a lock conflict can surface at the read step, not only atcommit. 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.fieldPathsand leave it out offields. That removes it. WritingnullValuekeeps the field around with a null in it, which is not the same thing when a privacy law asks you to erase personal data. -
runQueryis a streaming method. Over HTTP/JSON you get an array ofRunQueryResponse, and some elements carry nodocumentat all (progress/end markers). Filter them out or you'll have a very confusingundefined. -
Writes without a transaction are still atomic. A single
commitapplies its writes atomically and in order. You only needbeginTransactionwhen 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 }),
},
);
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 (0)