你在生产环境点 Google 或 GitHub 登录,走完授权页后浏览器跳回的是 http://localhost:3000/auth/callback(你看到的是空白页),或者跳到一个完全不相关的域,又或者 provider 直接拦下来报 Error 400: redirect_uri_mismatch。这是 OAuth 流程里最高频的配置坑:你应用发出的 redirect_uri 跟 provider 后台允许列表里的那一项对不上。
最快修复: 点登录,打开 DevTools 的 Network 面板,把 provider 收到的 redirect_uri= 值复制出来(URL-decode 一下),原样贴进 provider 的 allowed-callback 列表。Google 是在 Cloud Console → APIs & Services → Credentials → 你的 OAuth client → Authorized redirect URIs 里加。保存、等 Google 生效几分钟、用无痕窗口重试。绝大多数情况这一步就把 mismatch 清掉了;下面的内容是给”这步没解决”以及”想让它别再犯”准备的。
先判断:你的 provider 到底匹配得多严?
不是所有 provider 都用同一套匹配规则,“一切都逐字符精确”这种老说法只对一部分成立。动手改条目之前,先把你的 provider 对到它真实的行为上——这就是你所在的”桶”。
| Provider | 匹配行为(截至 2026 年 6 月) | 通配 / 子路径 |
|---|---|---|
| Google OAuth 2.0 | 逐字符精确:协议、host、端口、路径、尾斜杠全都得一致 | 不支持通配;localhost 端口可任意,公网 URL 不行 |
| GitHub OAuth App | host + 端口精确;路径只要是已注册 callback 的子目录即可;允许子域 | 没有 *,但 /callback/staging 能命中 /callback 的注册 |
| Auth0 | Allowed Callback URLs 里每一项都逐字符精确 | 生产 tenant 不支持通配 |
| Clerk | 逐字符精确;URL 没注册的话,OAuth 会走完但不会创建 session | 原生 / 自定义 scheme 的 URL 要单独注册 |
| Supabase Auth | Redirect URLs 支持 glob 通配(*、**、?);Site URL 是默认兜底 | 允许 ** globstar;但底层 OAuth client 的 URI 仍是精确的 |
实操结论:在 Google、Auth0、Clerk 上,你是在抓一个字符的差别。在 GitHub 上你宽松一些(一个 callback 能覆盖子路径),在 Supabase 上 preview 可以用通配——但你在 Supabase 里向 Google/GitHub 注册的那个 OAuth client URI 依然是精确的。
常见原因
按命中率从高到低。
1. 生产 callback URL 不在允许列表
最高频。你在 dev 阶段加了 http://localhost:3000/auth/callback,部署上生产后从没把 https://yourdomain.com/auth/callback 加进去。
如何判断:provider 错误页会显示 redirect_uri_mismatch 以及它收到的那个 URI,拿它跟后台的允许列表对一下。
2. 代码里硬编码了 localhost
const redirectUri = "http://localhost:3000/auth/callback"; // 永远是 dev URL
部署后这行不会变,生产请求里发出去的还是 localhost。
如何判断:grep 一下 codebase 有没有硬编码的 localhost 或固定域名。
3. 协议或尾斜杠不匹配
https://example.com/cb 和 https://example.com/cb/(多一个斜杠)不相等,http:// 和 https:// 也不相等。在 Google 上这会直接失败。
如何判断:DevTools → Network 看请求里的 redirect_uri=,跟允许列表逐字符 diff。
4. www vs 裸域
https://www.example.com/cb 和 https://example.com/cb 不相等。如果你站点两个都对外服务、但列表里只加了一个,从另一个发起就失败。(GitHub 是例外——它忽略子域,所以 www 和裸域命中同一个 callback。)
如何判断:你的站点有没有 www → 裸域的重定向,或者反过来?
5. preview / staging 的 URL 没注册
Vercel preview 用的是会变的子域,比如 myapp-git-feature-team.vercel.app。在逐字符精确的 provider 上,preview 的 OAuth 必然失败,除非你专门处理(见 Step 4)。
如何判断:只在 preview deployment 失败,生产正常。
6. provider 剥掉了路径或 query
某些流程(老旧的 SAML / SSO)会忽略 path、只匹配 origin。你在 redirect URI 上挂的 ?from=signup 被剥掉,于是跳到了 / 而不是 /auth/callback。
如何判断:跳回的是 / 而不是你写的 callback 路径。
最短修复路径
Step 1:抓 provider 实际收到的 redirect_uri
点登录,打开 Network 面板,找到发给 provider authorize 端点的那个请求:
https://accounts.google.com/o/oauth2/v2/auth?
client_id=...&
redirect_uri=https%3A%2F%2Fyourdomain.com%2Fauth%2Fcallback&
...
把 redirect_uri= 的值复制下来并 URL-decode(%3A 还原成 :,%2F 还原成 /)。这串解码后的字符串,正是 provider 看到并校验的内容。
Step 2:原样加进 provider 的允许列表
把解码后的字符串一字不改贴到对应字段。截至 2026 年 6 月各家后台路径:
Google Cloud Console:
APIs & Services -> Credentials -> (你的 OAuth 2.0 Client ID) -> Authorized redirect URIs
GitHub:
Settings -> Developer settings -> OAuth Apps -> (你的 app) -> Authorization callback URL
Auth0:
Applications -> (你的 app) -> Settings -> Allowed Callback URLs
Supabase:
Authentication -> URL Configuration -> Site URL + Redirect URLs
Clerk:
Configure -> (SSO connection / OAuth application) -> Redirect URLs
保存。Google 的改动通常几分钟生效,偶尔会拖到几小时;GitHub、Auth0、Supabase、Clerk 基本即时。
Step 3:用 env 驱动 base URL,绝不硬编码
// Next.js 示例
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL; // 永远不要 fallback 到 localhost
if (!baseUrl) throw new Error("NEXT_PUBLIC_SITE_URL not set");
const redirectUri = `${baseUrl}/auth/callback`;
然后在 host 平台(Vercel / Netlify / Railway)按环境分别配:
Local (.env.local): NEXT_PUBLIC_SITE_URL=http://localhost:3000
Preview: NEXT_PUBLIC_SITE_URL=(动态,见 Step 4)
Production: NEXT_PUBLIC_SITE_URL=https://yourdomain.com
一个隐蔽的坑:env 缺失时直接报错(上面的 throw)远好过悄悄 fallback 到 localhost——后者恰恰就是生产环境那个”跳回 localhost 空白页”的症状。
Step 4:处理 preview deployment
每个 Vercel preview 子域都不同,固定一条注册不可能全覆盖。按你的 provider 选方案:
Supabase(preview 首选):
加一条 glob 形式的 Redirect URL,比如
https://*-<你的-team-slug>.vercel.app/**
Supabase 的 Redirect URLs 支持 * ** ?
Google / Auth0 / Clerk(不支持通配):
用 process.env.VERCEL_URL 拼出每次部署的 origin,
然后只注册一个固定的 preview alias,而不是每个随机子域:
const baseUrl = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: process.env.NEXT_PUBLIC_SITE_URL;
注册一个固定 alias,比如
https://your-app-git-main-team.vercel.app/auth/callback
GitHub:
一个 callback URL 能覆盖子路径和子域,所以一条
https://your-app.vercel.app/auth/callback 往往就够 staging 用了。
Step 5:无痕窗口端到端验证
1. 用一个全新的无痕窗口打开生产 URL
2. 点登录
3. Network 面板:确认 redirect_uri 是生产 URL
4. 走完 provider 授权页
5. 跳回页面后,确认 session cookie 已落上
6. 刷新一次,确认登录态保持
任何一步失败,先把 console 和 network 的错误抓下来,再去改别的。
Step 6:协议 / 尾斜杠精确对齐
如果还失败,把已注册的 URI 和请求里的 URI 并排打出来,逐字 diff:
echo "registered: https://example.com/auth/callback" # 后台里注册的
echo "in request: https://example.com/auth/callback/" # Network 面板里的(注意尾斜杠)
在 Google 上,差一个字符——尾斜杠、http vs https、一个 www——就足以让它失败。
如何确认已修好
在生产域、干净的无痕会话里,下面三点同时成立就算修好了:
- authorize 请求里的
redirect_uri跟某条已注册项完全一致(不再redirect_uri_mismatch)。 - 授权后落在你真正的 callback 路径上,而不是
/或 localhost 空白页。 - session cookie 已设置,并能扛过一次硬刷新。
如果只剩 preview 部署还失败、生产正常,那就是原因 5——回到 Step 4。
预防建议
- 把允许列表当成 deploy checklist 的一项:新增环境就意味着要加新的 redirect URI。
- 代码里禁止硬编码
localhost和固定域名;CI 加一条 lint,扫到就让构建失败。 - 所有环境统一用一个 env 变量名(
NEXT_PUBLIC_SITE_URL),行为才一致。 - preview 提前规划:要么用 Supabase 通配,要么在逐字符精确的 provider 上固定一个 alias。
- 上线前一定要在生产域跑一遍完整登录,别只在本地测。
- 给允许列表的每条加注释(哪个环境、谁加的、什么时候加的),审计起来快。
- 定期清理列表里已下线的 dev / staging URI——残留项是 open redirect 安全隐患。
常见问题
为什么本地正常、生产就挂?
你的代码或 env 变量在生产里仍然解析成 http://localhost:3000,或者你压根没把生产 callback 加进允许列表。在生产域的 Network 面板里抓出 redirect_uri,跟已注册的对一下。
我已经把 URL 加了,Google 还是报 redirect_uri_mismatch。
通常三个原因:Google 还没生效(等几分钟,有时更久)、你改错了 OAuth client ID、或者有一个字符的差别——最常见是尾斜杠或 http vs https。按 Step 6 逐字 diff 两串。
尾斜杠真有那么要紧?
在 Google、Auth0、Clerk 上,是的——…/callback 和 …/callback/ 是两条不同的项。GitHub 宽松些,因为它会匹配已注册 callback 的子路径。
Vercel preview URL 能用通配吗?
只有支持通配的 provider 才行。Supabase 的 Redirect URLs 允许 https://*-team.vercel.app/** 这类 glob;Google、Auth0、Clerk 不行——改成注册一个固定的 preview alias。
OAuth 成功了,但我没登录态 / 没 session。 跳转落到了你 session 代码不运行的地方;或者(在 Clerk 上)这个 redirect URL 没注册,于是 provider 返回了但没创建 session。确认你落在精确的 callback 路径上,并在 DevTools → Application → Cookies 里确认 cookie 已设置。
provider 把我的 ?from=signup query 参数丢了。
某些流程只匹配 origin、会剥掉 path/query。别把状态挂在 redirect URI 上;用 OAuth 的 state 参数(或在服务端按 state 存意图),在 callback handler 里再读回来。