--- doc_id: XhBloryvDBJp description: "用 Firebase Authentication 给 Next.js 加 FastAPI 的 Web App 接邮箱密码和 Google 等第三方登录: OAuth 回调和各家 token 都交给 Firebase, 后端只在本地验证一种 Firebase ID Token, 加新的登录方式后端不用改. 是一篇带最小代码的原理讲解和方案记录." tags: [firebase, authentication, oauth, fastapi, nextjs] created_at: "2026-11-06" updated_at: "2026-11-06" --- # 用 Firebase Authentication 接第三方登录: 后端只需要验证一种 token 我在做自己的一个 Web App, 前端 Next.js, 后端 Python FastAPI, 都部署在 Vercel 上. 这类 App 绕不开用户系统: 核心是邮箱加密码注册登录, 再加上 "用 Google 登录" 这种第三方登录. 问题出在第三方这块. Google, Facebook, Apple 各自都有开放的库, 但每家库的维护质量和接口标准都不一样. 今天接一个, 明天再接一个, 每家一套代码, 到后面根本维护不过来. 所以我要的不是 "怎么接 Google", 而是一个能把所有登录方式收拢到一处的方案. 研究下来我选了 Google 的 Firebase Authentication, 理由就三个: 简单, 好用, 免费. 这篇记的是它为什么能把这件事变简单, 以及前后端最少要写什么. 它不是市面上唯一的方案, 这篇也不做横向对比. 写这篇的时候, 我已经把 Google 登录从头到尾跑通了, 但还没上生产, 所以生产环境的坑这里没有. 结论先放这: 让 Firebase 站在你的 App 和各家 OAuth 提供商中间. 回调, 换 token, 刷新 token 都归它, 你的后端只做一件事, 在本地验证一个 Firebase ID Token. ## 自己接 OAuth, 每多一家就多一套回调和 token 先看不用 Firebase 时要干多少活. 以 "用 Google 登录" 为例, 你得先去 Google Cloud Console 注册 OAuth 应用, 拿到 Client ID 和 Client Secret, 配好回调 URL. 然后前端自己拼授权 URL (`client_id`, `redirect_uri`, `scope`, 防 CSRF 的 `state`), 把用户跳到 Google. 最重的活在后端. 你要部署一个回调接口接住 Google 跳回来的请求, 拿授权码 (`code`) 去换 access token, 验证 Google 给的 ID Token, 提取用户信息, 在数据库里建或更新用户, 再签发自己的 session 或 JWT, 最后把用户跳回前端. 用户登录之后, 你还得存 access token 和 refresh token, 写刷新逻辑, 处理过期. 这还只是一家. 多接几家, 每一步都会分叉: | 差异 | Google | Apple | GitHub | | :--- | :--- | :--- | :--- | | 协议 | 标准 OAuth 2.0 / OIDC | 有自己的规矩, 比如 client secret 要用 JWT 签出来 | OAuth 2.0, 回调参数格式不一样 | | 回调端点 | `/auth/google/callback` | `/auth/apple/callback` | `/auth/github/callback` | | 用户信息 | `sub`, `email`, `name`, `picture` | 可能藏起真实邮箱, 给一个私有中继邮箱 | 数据结构完全是另一套 | 我当时算过一笔账: 要同时支持三种第三方登录, 后端大概要写 500 到 1000 行代码, 还不算各种边界情况. 说白了, 痛点有四个: 回调服务器得自己养, 每家的特殊逻辑得自己扛, token 的生命周期得自己管, 每加一种登录方式都是一次新的开发. ## Firebase 站在中间: 回调归它接, 后端只收一种 ID Token Firebase Auth 的设计思路, 就是把自己做成 OAuth 流程的代理加翻译层. 两种架构放在一起看就清楚了: ```mermaid flowchart LR subgraph 自己接 direction LR F1[前端] --> P1[Google / Apple / GitHub] P1 --> CB[你的回调服务器
每家一个端点] CB --> DB1[(你的数据库
存各家 token)] end subgraph 用 Firebase direction LR F2[前端 + Firebase SDK] --> FA[Firebase Auth] FA --> P2[Google / Apple / GitHub] F2 -- Firebase ID Token --> BE[你的后端
只验证 token] end ``` 它具体接走了这几件事: - 回调服务器. 所有提供商都回调到 Firebase 的服务器, 你一个 `/auth/callback` 都不用写. - 统一的前端 API. 不管是 Google, Apple 还是 GitHub, 前端都是 `signInWithPopup(auth, provider)`, 换的只是 `provider` 对象. - 统一的用户数据. 各家五花八门的用户信息被整理成同一个 `User` 对象: `uid`, `email`, `displayName`, `photoURL` 等. - token 的生命周期. 各家的 access token 和 refresh token 都不经过你, Firebase SDK 在 token 快过期时自己刷新. - 统一的身份凭证. 用户不管用哪种方式登录, 最后拿到的都是一个由 Firebase 签名的 Firebase ID Token (JWT). 最后一条是关键. 你的后端从此不认识 Google, 也不认识 Apple, 它只认识 Firebase ID Token 这一种东西. ## 一次 Google 登录, 从点按钮到后端拿到 uid 下面这张时序图是我跑通的那条路径. 注意 Google 回调的对象是 Firebase, 不是你的后端: ```mermaid sequenceDiagram participant U as 用户 participant FE as Next.js 前端 (Firebase SDK) participant FA as Firebase Auth 服务器 participant G as Google OAuth participant BE as FastAPI 后端 U->>FE: 点 "用 Google 登录" FE->>FA: 打开 popup, 进 Firebase 的认证页 FA->>G: 重定向到 Google 授权页 U->>G: 登录, 同意授权 G->>FA: 带着授权码回调 Firebase FA->>G: 用授权码换 token, 验证 Google 的 ID Token FA->>FA: 建或更新用户, 签发 Firebase ID Token FA->>FE: popup 用 postMessage 把结果交回前端 FE->>BE: 请求带 Authorization: Bearer BE->>BE: 本地验证签名, 取出 uid 和 email BE->>FE: 返回业务数据 ``` 整条链路里, 你写的代码只有两头: 前端调一次 SDK, 后端验一次 token. 授权码换 token, 验证 Google 的 token, 管 OAuth token 的生命周期, 全在 Firebase 那边. ### 前端: 初始化, 登录, 带 token 调后端 前端用 `firebase` 这个 npm 包的模块化 API. 先初始化一次, 配置项在 Firebase Console 的项目设置里能找到: ```typescript // lib/firebase.ts import { getApps, initializeApp } from "firebase/app"; import { getAuth } from "firebase/auth"; const app = getApps()[0] ?? initializeApp({ apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, }); export const auth = getAuth(app); ``` `getApps()[0] ??` 是为了防止 Next.js 开发模式热更新时重复初始化. 登录和调后端这两件事放在客户端组件里: ```typescript // lib/auth-client.ts, 只在 "use client" 的组件里用 import { GoogleAuthProvider, signInWithPopup } from "firebase/auth"; import { auth } from "./firebase"; export async function signInWithGoogle() { const result = await signInWithPopup(auth, new GoogleAuthProvider()); return result.user; // uid, email, displayName, photoURL } export async function callApi(path: string) { // getIdToken() 在 token 快过期时会自己刷新, 不用自己管 const token = await auth.currentUser?.getIdToken(); return fetch(path, { headers: { Authorization: `Bearer ${token}` }, }); } ``` 每次调后端都现取 `getIdToken()`, 不要把 token 存进 localStorage 自己管. SDK 本身就在维护它, 自己存一份只会多出一个过期的副本. ### 后端: 一个 FastAPI 依赖项 后端用 `firebase-admin`. 把验证写成一个依赖项, 所有要登录的接口挂上它就行. 服务账号的 JSON 从 Firebase Console 下载, 下面的写法是把它整段放进 Vercel 的环境变量里. 我自己实际放在 AWS SSM Parameter Store 里, 见 {ref}`qNAAcNEeLuxS`: ```python # -*- coding: utf-8 -*- import json import os import firebase_admin from fastapi import Depends from fastapi import FastAPI from fastapi import HTTPException from fastapi.security import HTTPAuthorizationCredentials from fastapi.security import HTTPBearer from firebase_admin import auth from firebase_admin import credentials cred = credentials.Certificate(json.loads(os.environ["FIREBASE_SERVICE_ACCOUNT_JSON"])) firebase_admin.initialize_app(cred) app = FastAPI() bearer = HTTPBearer() def current_user( creds: HTTPAuthorizationCredentials = Depends(bearer), ) -> dict: try: claims = auth.verify_id_token(creds.credentials) except Exception: raise HTTPException(status_code=401, detail="invalid Firebase ID token") if not claims.get("email_verified"): raise HTTPException(status_code=403, detail="email not verified") return claims @app.get("/api/user") def get_user(user: dict = Depends(current_user)): return {"uid": user["uid"], "email": user.get("email")} ``` 验证通过后, `verify_id_token` 返回 token 里的声明 (claims), `uid` 就是这个用户在 Firebase 里的唯一 ID. 拿到它之后, 就是正常的业务逻辑: 在自己的数据库里按 `uid` 查用户, 没有就建. `email_verified` 那两行不是可有可无的. 邮箱没验证过的账号, 后端一律挡在外面, 这是防 "别人抢先用你的邮箱注册" 的关键一环, 见 {ref}`XhBloryvDBJp-pre-account-hijacking`. ## 后端只验证不管理, 用户表里只存 firebase_uid 这一条是 Firebase 方案省事的根本. 自己接 OAuth 时, 后端不只是验证, 还得管理: 存各家的 access token 和 refresh token, 记过期时间, 每次请求前检查过没过期, 过期了拿 refresh token 去换. 用户表会长成这样: ```text users - id - email - google_access_token - google_refresh_token - google_token_expires_at - apple_access_token - apple_refresh_token - github_access_token - ... ``` 用 Firebase 之后, 后端只剩三步: 从请求头里拿 token, 验证, 用里面的 `uid` 和 `email`. OAuth token 一个都不存, 刷新逻辑一行不写. 用户表瘦成这样: ```text users - id - firebase_uid - email - name - created_at ``` 验证是无状态的, 不用在服务端存 session. 这对 Vercel 这种 serverless 部署很合适: 每个请求都是独立的函数调用, 本来就不好存状态. ## 本地验签为什么可信, 以及它防不住什么 只验证 token 就够了吗? 够, 因为 Firebase ID Token 是用 RSA 非对称签名 (RS256) 签的. Firebase 用自己的私钥签, 私钥只在 Firebase 手里. 公钥是公开的, 谁都能下载. 你的后端下载公钥, 缓存起来, 在本地验签名, 不用每次都去问 Firebase. 说白了, 公钥只能验章, 不能盖章. 攻击者就算拿到公钥, 也造不出一个能通过验证的 token. token 里的内容被改了一个字, 签名就对不上. 所以篡改和伪造都挡得住, 再加上 HTTPS, 中间人也拿不到明文. 因为是本地计算, 验证很快. 量级大致如下 (没有专门测过): | 场景 | 耗时 | | :--- | :--- | | 第一次验证, 要先下载公钥 | 公钥下载约 100 到 200 ms, 验签约 1 到 5 ms | | 之后的验证, 公钥已缓存 | 约 1 到 5 ms | 公钥不是永久缓存的. `firebase-admin` 按公钥接口返回的 `Cache-Control` 里的 `max-age` 决定什么时候重新下载 (以 2026-11 的官方文档为准). 但有两件事它防不住, 要心里有数: - 被撤销的 token. `verify_id_token` 默认只查签名和过期时间, 不查这个 token 有没有被撤销. 比如你在后台禁用了一个用户, 他手里那个还没过期的 token 照样能用. 要查撤销, 得传 `check_revoked=True`, 代价是每次验证都要访问一次 Firebase, 本地验签的速度优势就没了. - 重放. Firebase ID Token 的有效期是 1 小时. 它挡重放的办法只有过期: 1 小时内, 偷到 token 的人照样能用. 所以别把 token 写进日志, URL 和前端存储. 我的 App 现在没有 "立刻踢人下线" 的需求, 所以用默认的本地验证. 什么时候有了, 再给敏感接口单独开 `check_revoked`. ## 加一个新登录方式: 前端换一行, 后端不动 有了上面这套, 加 GitHub 登录就是三处改动. 前端换一个 provider, 其他代码不变: ```typescript import { GithubAuthProvider, signInWithPopup } from "firebase/auth"; import { auth } from "./firebase"; await signInWithPopup(auth, new GithubAuthProvider()); ``` Firebase Console 里, 在 Authentication 的登录方式里启用 GitHub, 填上 GitHub OAuth App 的 Client ID 和 Secret, 保存. 具体菜单以 [Firebase 官方文档](https://firebase.google.com/docs/auth/web/github-auth) 为准. 后端一行不改. 用户不管用什么方式登录, 到后端都是同一种 Firebase ID Token, `verify_id_token` 一视同仁. 这正是我一开始想要的: 登录方式可以一直加, 后端代码不跟着长. ## 用之前要知道的三件事: 邮箱密码, 同邮箱撞号, 成本 邮箱密码登录也走同一套. Firebase Auth 除了各家 OAuth, 还支持邮箱密码, 手机短信验证码, 匿名登录, 以及接自己的认证系统. 不管哪种, 最后发出来的都是同一种 Firebase ID Token, 邮箱密码和 Google 登录在后端可以走同一个 `current_user`. 这是一种做法. 我自己的 App 原来就有一套邮箱密码登录, 所以邮箱密码放在自己的用户表里, 只让 Firebase 管第三方登录, 具体见 {ref}`qNAAcNEeLuxS`. 用户的查看, 禁用, 重置密码, 在 Firebase Console 里都有现成的界面. 同一个邮箱换一种方式登录, 不一定会自动合并. 常见的说法是 "Firebase 会自动处理账号合并", 这话说得太满. Firebase 确实支持把多种登录方式关联到同一个账号, 但在默认的 "一个邮箱只能对应一个账号" 设置下, 用户先用邮箱密码注册, 再用同一个邮箱的 Google 账号登录, 结果要看原来那个账号的邮箱验证过没有. 验证过的, 前端会收到 `auth/account-exists-with-different-credential` 这个错误, 要让用户先用原来的方式登录, 再调 `linkWithCredential` 把新的方式关联上, 这段逻辑得自己写. 没验证过的, 而且邮箱是 `@gmail.com`, Google 登录会直接顶替掉原来的密码登录方式, 不报错. 这个行为是故意的, 下一节讲为什么. 成本 (以 2026-11 的 [Firebase 定价页](https://firebase.google.com/pricing) 为准): 邮箱密码和各家 OAuth 登录, 每月 5 万个月活用户以内免费, 超出部分按 Google Cloud Identity Platform 的价格算. 手机短信验证码不在免费额度里, 只有按量付费的 Blaze 计划能用, 每条短信都收钱. 对我这种还没上线的 App 来说, 5 万月活远远够用, 所以算下来就是免费. (XhBloryvDBJp-pre-account-hijacking)= ## 别人抢先用你的邮箱注册, 也偷不走你的账号 这是一种有名字的攻击, 叫 pre-account hijacking (抢注后劫持). 说白了就是占坑: 黑客知道你的邮箱, 而你还没在这个 App 注册. 他先用你的邮箱, 配上他自己设的密码, 注册一个邮箱密码账号. 过几天你来了, 点 "用 Google 登录". 如果系统看邮箱一样, 就把你的 Google 登录并进他建的那个账号, 那这个账号就同时认两把钥匙: 你的 Google, 和他的密码. 他从此能用密码登进你的账号, 看你的数据. 如果邮箱密码也放在 Firebase 里 (就是本篇上面的写法), 这件事成不了. 原因是两道闸, 一道在 Firebase, 一道在后端. 第一道, Firebase 不会让两把钥匙并存. Firebase 把登录方式分成可信和不可信 ([官方文档](https://firebase.google.com/docs/auth/users)). 没验证过邮箱的邮箱密码账号, 算不可信, 因为谁都能填任何人的邮箱. Google 对 `@gmail.com` 邮箱算可信, 因为这个邮箱就是 Google 自己发的, 能用它登 Google, 就证明是邮箱的主人. 在默认的 "一个邮箱一个账号" 设置下, 可信的登录方式来了, 会顶替掉不可信的那个. 也就是说, 你一用 Google 登录, 黑客设的那个密码就从账号上被摘掉了, 他的钥匙作废, 账号归你. 第二道, 后端不给没验证邮箱的账号干活. 上面 `current_user` 里查了 `email_verified`, 不是 `true` 就回 403. 黑客注册时填的是你的邮箱, 验证邮件发到你的收件箱, 他收不到, 所以他的账号从头到尾都是 "邮箱未验证". 在你出现之前, 他登进去也调不动任何一个接口, 你的数据库里也不会出现他的用户记录. 这一道闸补的是第一道的缝: Firebase 顶替登录方式时, `uid` 不变. 如果后端不挡未验证的账号, 黑客就可以在你出现之前往这个 `uid` 下面塞东西 (改个资料, 绑个支付方式), 等你接手的时候这些东西还在. 后端挡住了, 他就什么都塞不进去. 邮箱密码注册这一侧, 前端要配合做两件事: 注册完马上发验证邮件, 用户点完链接后强制刷新一次 token, 新 token 里的 `email_verified` 才会变成 `true`. ```typescript import { createUserWithEmailAndPassword, sendEmailVerification, } from "firebase/auth"; import { auth } from "./firebase"; export async function signUp(email: string, password: string) { const { user } = await createUserWithEmailAndPassword(auth, email, password); await sendEmailVerification(user); } // 用户说 "我已经点了验证链接" 之后调用 export async function refreshAfterVerify() { await auth.currentUser?.reload(); await auth.currentUser?.getIdToken(true); // true: 不用缓存, 拿带新声明的 token } ``` 这套防护有它的边界, 得说清楚: - Google 只对 `@gmail.com` 算可信. 你要是用公司邮箱这类别的域名登 Google, Firebase 不会顶替, 你会收到 `auth/account-exists-with-different-credential`, 登不进去. 但黑客也没得到什么: 他的账号没验证邮箱, 照样被后端挡着. 你可以用 "忘记密码" 把密码重置邮件发到自己的收件箱, 重设密码, 再把 Google 关联上. 安全没问题, 只是体验差一点. Apple 登录永远算可信, 和 gmail 一样. - 不要打开 "允许同一个邮箱建多个账号" 这个设置, 也不要在后端按 `email` 去认人. 这两件事只要做了一件, 两个不同的 `uid` 就可能被你自己的代码当成同一个人, 前面两道闸都白搭. 后端认人只认 `uid`, 用 `email` 之前先看 `email_verified`. - "可信登录方式会顶替未验证的密码" 这条, 官方文档用的例子是 Facebook 被 Google 顶替, 没有直接写邮箱密码的情况. 社区里有不少开发者报过 "用 Google 登录把我的密码删了", 说明行为就是这样 (以 2026-11 为准). 这个场景我没有实际测过, 用这种写法的话, 上线前要测一次. ```{note} 即使第一道闸因为上面的原因没生效, 只要第二道闸在, 黑客拿着一个未验证邮箱的账号就什么也做不了. 所以真正不能省的是后端那两行 `email_verified` 检查. ``` 反过来, 如果邮箱密码不放在 Firebase, 而是放在你自己的用户表里, Firebase 那道闸就不存在了: 它根本不知道你的密码账号. 这时候防线全在你的后端, 落在 "Google 登录时按 email 找到老账号要不要合并" 这一步. 我自己的 App 就是这种架构, 具体怎么守, 见 {ref}`qNAAcNEeLuxS-link-by-email`. ## 适用边界, 以及还没上生产所以不知道的事 Firebase Auth 适合的场景, 和我自己的情况基本重合: 要快速上线, 团队很小 (就我自己), 要支持好几种第三方登录, 又不想在认证上花太多时间. 它的核心思路是: 认证是基础设施, 交给专门的服务, 自己只管验证结果. 这几种情况它就不太合适: - 强监管行业 (金融, 医疗), 合规要求认证系统必须自建自管. - 公司已经有成熟的 SSO, 再引入一套只会多一个用户来源. - 需要拿各家的 access token 去调提供商的 API (比如读用户的 Google Drive). Firebase 的主流程不替你保存和刷新这些 token, 你得自己想办法. - 完全离线的环境. 登录和拿公钥都要联网. 还有一件和部署方式直接相关的事. 现在的浏览器 (Chrome, Firefox, Safari) 都在挡第三方存储, `signInWithRedirect` 在 Firebase Hosting 以外的地方 (比如 Vercel) 默认会坏. 我用的 `signInWithPopup` 不受这个影响, 代价是弹窗可能被浏览器拦截, 手机上的体验也差一点. 将来要换成跳转式登录, 得按 [官方的几种方案](https://firebase.google.com/docs/auth/web/redirect-best-practices) 处理, 比如在 Vercel 上把 `/__/auth/` 反向代理到 `firebaseapp.com` (以 2026-11 为准). 最后, 这篇写的时候 App 还没上生产, 没有真实用户. 弹窗在不同浏览器上被拦的比例, 同邮箱撞号在真实用户里有多常见, Apple 登录的配置要踩哪些坑, 这些我现在都不知道. 上线之后再回来补.