如何通过 Express 透明代理 Supabase auth v1,实现 OAuth 白标品牌展示?
Express 可以转发 Supabase Auth 请求,并保持 OAuth、PKCE 流程完整。不过,仅靠改写 Location,无法可靠地让 Google/X 显示自定义域名。
要让 OAuth 回调使用自定义域名,Supabase Auth 必须认可这个域名,并用它生成回调地址;身份提供方也必须登记同一个地址。如果托管项目仍以 xyz.supabase.co 作为对外认证地址,反向代理只能改变请求入口,无法保证整个认证流程都使用 api.mydomain.com。Google/X 最终显示什么名称和域名,还取决于各自的应用品牌配置和验证规则。
先区分两个回调地址
一次 Supabase OAuth 登录通常经过以下路径:
应用
→ Supabase /auth/v1/authorize
→ Google/X 授权页面
→ Supabase /auth/v1/callback
→ 应用自己的登录回调页面
其中有两个容易混淆的回调地址:
| 地址 | 用途 |
|---|---|
https://xyz.supabase.co/auth/v1/callback | 身份提供方把授权结果交给 Supabase |
https://app.mydomain.com/auth/callback | Supabase 完成处理后,把用户送回应用 |
signInWithOAuth() 的 options.redirectTo 控制第二个地址。修改它或 Supabase 的 Site URL,都不能直接把身份提供方使用的 /auth/v1/callback 换成自定义域名。即使浏览器从 api.mydomain.com 发起登录,Google/X 收到的 redirect_uri 也未必使用这个域名。
为什么字符串替换容易出问题
Supabase 返回的 Location 可能指向身份提供方,同时在查询参数中携带 Supabase 回调地址。例如,下面的 URL 为便于阅读省略了编码:
https://accounts.google.com/...?
client_id=...
&redirect_uri=https://xyz.supabase.co/auth/v1/callback
&state=...
对它执行这样的字符串替换,可能会改到嵌套的 redirect_uri:
location.replace('xyz.supabase.co', 'api.mydomain.com')
URL 编码不一定会隐藏域名中的普通字符。替换后要让登录正常完成,需要同时满足以下条件:
- 身份提供方已登记新的回调地址。
- Supabase 能通过新地址接收回调,并恢复登录上下文。
- Supabase 向身份提供方交换授权码时,使用与授权请求一致的
redirect_uri。
仅修改浏览器收到的响应头,无法保证最后一个条件成立。因此,授权页面可能正常打开,随后却报 redirect_uri_mismatch 或授权码交换失败。
OAuth 流程跳转到 Google/X 是正常行为。排查时应检查其中的回调地址,不必要求每次跳转都停留在代理域名下。
changeOrigin 和 xfwd 分别解决什么问题
| 配置 | 作用 | 使用边界 |
|---|---|---|
changeOrigin: true | 将发给上游的 HTTP Host 改为目标主机 | 访问托管 Supabase 时通常应保留目标项目的 Host |
secure: true | 校验上游 HTTPS 证书 | 应保持开启;它不控制 OAuth 校验 |
xfwd: true | 添加 X-Forwarded-* 请求头 | 上游是否信任、如何使用这些头,由上游配置决定 |
changeOrigin 不会修改 OAuth 参数中的 redirect_uri,也不会自动重写浏览器的 Origin。
开启 xfwd 同样不能自动启用自定义域名。即使发送以下请求头,托管 Supabase 仍可能使用预设的对外认证地址,或者拒绝不被认可的主机信息:
X-Forwarded-Host: api.mydomain.com
X-Forwarded-Proto: https
服务端仍需配置域名,不能靠伪造转发头来替代。
如果 Express 前面还有负载均衡器,需要限定可信代理,并在入口清理、重建转发头。Express 的 trust proxy 与代理中间件的 xfwd 是两套配置,不能互相替代。
Express 中可以怎样处理重定向
如果只是为了统一 API 入口,可以仅改写目标为 Supabase 上游的重定向,保留身份提供方 URL 及其查询参数。
下面的示例使用 http-proxy-middleware v3 配置。旧版本的 onProxyRes、logLevel 写法不能直接与这套 API 混用。
import express from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
const app = express();
const upstream = new URL('https://xyz.supabase.co');
const publicOrigin = new URL('https://api.mydomain.com');
const supabaseProxy = createProxyMiddleware({
target: upstream.origin,
// 挂在根路径,保留 /auth/v1、/rest/v1 前缀。
pathFilter: (pathname) =>
['/auth/v1', '/rest/v1'].some(
(prefix) =>
pathname === prefix || pathname.startsWith(`${prefix}/`)
),
changeOrigin: true,
secure: true,
followRedirects: false,
// 此例使用托管项目原有的上游身份,不通过转发头声明新域名。
xfwd: false,
on: {
proxyReq(proxyReq) {
// xfwd: false 不会主动清除客户端已经提交的转发头。
for (const name of [
'forwarded',
'x-forwarded-for',
'x-forwarded-host',
'x-forwarded-proto',
'x-forwarded-port',
]) {
proxyReq.removeHeader(name);
}
},
proxyRes(proxyRes) {
const location = proxyRes.headers.location;
if (typeof location !== 'string') return;
try {
const destination = new URL(location, upstream.origin);
// 只修改最外层 URL,不替换查询参数中的其他 URL。
if (destination.origin === upstream.origin) {
destination.protocol = publicOrigin.protocol;
destination.host = publicOrigin.host;
proxyRes.headers.location = destination.href;
}
} catch {
// 无法解析时保留上游响应。
}
},
},
});
// 放在 express.json() 等读取请求体的中间件之前。
app.use(supabaseProxy);
这段代码保留请求路径,只在限定范围内改写重定向,同时保留身份提供方 URL 中的原始 redirect_uri,不能单独实现 OAuth 白标。
示例没有转发真实客户端 IP。如果业务依赖该信息做限流或审计,应在可信入口生成相应请求头,不能直接信任客户端提交的值。部署时还需要配置对外 HTTPS 和跨域访问策略。
代理会不会破坏 PKCE 和 state
正常转发不会破坏 PKCE。PKCE 校验的是 code_verifier 与先前提交的 code_challenge 是否对应,不要求请求直连某个域名。
配置时要注意 flowType 的大小写:
const supabase = createClient(
'https://api.mydomain.com',
SUPABASE_PUBLISHABLE_KEY,
{
auth: {
flowType: 'pkce',
storageKey: 'myapp-auth',
},
}
);
await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: 'https://app.mydomain.com/auth/callback',
},
});
正确名称是 flowType,不是 flowtype。这里的 storageKey 用于显式固定客户端存储名称;如果已有线上会话,修改它时需要考虑迁移。
代理和客户端需要满足以下要求:
- 发起登录和处理应用回调时,能够读取同一个 PKCE verifier。浏览器存储受 origin 限制;SSR 则需要正确传递相应 Cookie。
- 代理完整保留请求方法、请求体、查询参数和认证头,尤其是授权码交换使用的
/auth/v1/token请求。 state按原值传递,不要解码后重新拼接、替换其中的域名或自行生成新值。
这里还有两段授权码交换需要区分:一段是身份提供方向 Supabase 返回授权码,另一段是 Supabase 向应用返回供 PKCE 会话交换使用的授权码。应用端启用 PKCE,无法修复前一段的回调地址不一致问题。
如果使用 Cookie,还需检查 Domain、Path、Secure 和 SameSite。使用浏览器本地存储的客户端则应重点检查 verifier 的保存位置,不能只排查 Cookie。
按这个顺序检查配置
先在浏览器开发者工具中保留网络日志,找到前往 Google/X 的授权请求,检查解码后的 redirect_uri。
如果它仍然是下面的地址,说明 Supabase 还在使用项目原域名:
https://xyz.supabase.co/auth/v1/callback
此时继续调整普通的 Location 改写,无法可靠解决品牌展示问题。
如果准备使用以下回调地址,需要同时完成后面的配置:
https://api.mydomain.com/auth/v1/callback
- 让 Supabase Auth 认可该域名,并据此生成回调地址。
- 在身份提供方登记完全匹配的回调 URI,包括协议、主机、路径和尾部斜杠。
- 将应用的
redirectTo地址加入 Supabase 的 Redirect URLs 允许列表。 - 检查完整流程,确认应用成功交换会话,并验证刷新会话是否正常。
排查时避免把完整授权码、state、verifier 或 token 写入日志。
不想为自定义域名升级,可以考虑哪些方案
如果主要希望 Google 页面显示应用名称和 Logo,可以先完善 Google OAuth 应用的品牌信息,并完成所需验证。具体页面显示应用名称、回调域名还是其他文案,由 Google 决定。X 也有自己的规则,无法保证两者展示一致。
Google 登录还可以考虑另一种接入方式:在自有站点通过 Google Identity Services 获取 ID token,再调用 Supabase 的 signInWithIdToken() 建立会话。需要正确匹配 Google Client ID,并按所选流程处理 nonce。这会改变登录接入方式,也不能直接套用到 X。
如果必须控制认证服务的公开域名,可以考虑自托管 Supabase/Auth,在部署端配置对外地址,同时承担升级、密钥、邮件和认证安全等运维工作。若只迁移 Auth、保留托管数据库,还必须解决 JWT 信任关系与 RLS 用户身份映射,不能假设新服务签发的 token 可以直接使用。
Express BFF 也可以自行管理 OAuth 和应用会话,但这涉及认证架构调整。如果 BFF 仍然调用同一个托管 Supabase 的 signInWithOAuth(),回调域名问题依然存在。
继续使用托管 Supabase Auth 时,受支持的自定义域名机制通常更容易维护。具体套餐要求和附加费用以控制台为准,不能假设升级 Pro 就自动包含这项能力。
备注:内容仅供参考。
