Bridging Firebase Auth to a legacy JWT backend, without account takeovers
Adding Google and Apple sign-in to an existing email/password API sounds trivial. The account-linking edge cases are where it gets dangerous.
Penspace's backend predated its social logins: a classic Express API issuing its own JWTs, with users keyed by Mongo _id. When Google and Apple sign-in arrived via Firebase Auth, I had two identity systems that needed to agree about who a user is. This is a common migration, and most write-ups skip the part where you can get it badly wrong.
The bridge pattern
The client signs in with Firebase and sends the Firebase ID token to one endpoint. The server:
- Verifies the token with
firebase-admin: never trust a decoded-but-unverified token - Looks up the user by
firebaseUid - If none exists, attempts to link by email to an existing account
- If still none, provisions a user atomically (upsert, not find-then-create)
- Re-issues a backend JWT with the Mongo
_idassub
Step 5 is the point of the pattern: every existing service, middleware and query keeps working unchanged, because downstream nothing knows Firebase exists. The bridge is one file; the rest of the API stays Firebase-agnostic.
The takeover trap
Step 3 hides the vulnerability that matters. Firebase will happily mint a token for an unverified email; some identity providers don't verify at all. If you link by email unconditionally, an attacker can create a Firebase account with victim@example.com, sign in, and inherit the victim's account on your backend.
The rule: only link when Firebase reports email_verified: true. Google sign-ins arrive verified; email/password Firebase accounts don't until confirmed. If the email isn't verified, you create a fresh, unlinked account instead: mildly annoying for a legitimate user, existential for a compromised one.
Two more guards worth stealing:
- Status checks live in the bridge. A banned or soft-deleted user (
status != 1) gets rejected even with a perfectly valid Firebase token. Firebase knows authentication; your database knows authorization. - Cap live sessions. The server keeps an allowlist of active JWTs per user (five, in my case). Logout revokes; a stolen token can be invalidated server-side; and a takeover doesn't survive a password reset.
Atomicity, because mobile
Mobile clients retry. The first cold-start after install can fire the bridge twice concurrently. A find-then-create sequence gives you duplicate users with the same firebaseUid and a support ticket you can't reproduce. One atomic upsert keyed on firebaseUid closes the race for free.
None of this is exotic; it's maybe sixty lines of middleware. But auth bridges are load-bearing code: boring, easy to get 90% right, and the last 10% is the difference between a login system and an account-takeover kit.