接入 Cloudflare OAuth Client 最佳实践
最近自己把一个第三方应用接到 Cloudflare 自管 OAuth Client,从创建客户端到走通授权码的实践记录。
约四千六百字·读约十四分钟 · English
最近把一个第三方应用接到 Cloudflare 的自管 OAuth Client 上,才发现控制台里那些字段不是「填了就能过」。客户端类型选错、redirect_uri 差一个斜杠、过早把 private 升成 public,后面每一层报错都会指到别的地方。
这套东西解决的是:让你的应用以用户身份去调 Cloudflare API,而不是把一枚长期 API Token 塞进客户端。用户在 dash.cloudflare.com 上点同意之后,你拿到 access token,需要续期再拿 refresh token,然后用它们访问 Workers、账号、分析这类接口。
它不是「用 Cloudflare 账号登录你的产品」那种登录按钮。openid、profile、email 这些 Identity scope 确实能拿到用户资料,但授权页真正在问的是:这个应用能对哪些 Cloudflare 资源做什么。用户点同意,是在授权账号权限,不是在用 Cloudflare 当你的 IdP。
官方文档也写死了:第三方客户端只支持 Authorization Code。Client Credentials、Implicit、Resource Owner Password、Device Authorization 都没有。想绕开浏览器,没有第二条路。
端点都挂在 dash.cloudflare.com 下面:
| 用途 | URL |
|---|---|
| OpenID 配置 | https://dash.cloudflare.com/.well-known/openid-configuration |
| JWKS | https://dash.cloudflare.com/.well-known/jwks.json |
| 授权 | https://dash.cloudflare.com/oauth2/auth |
| 换 token | https://dash.cloudflare.com/oauth2/token |
| 吊销 | https://dash.cloudflare.com/oauth2/revoke |
| 登出 | https://dash.cloudflare.com/oauth2/logout |
| 用户信息 | https://dash.cloudflare.com/oauth2/userinfo |
下面按接入顺序写:先选类型,再对照创建页把字段改对,然后走一遍授权码,最后把原生 App 和常见的坑单独拿出来。
先选对客户端类型
创建页上有一栏 Token Authentication Method。它决定的是:换 token 的时候,你的应用怎么证明自己是这个 client。常见就两种。
机密客户端带一份 client_secret。去 /oauth2/token 兑换时,除了授权码,还要出示这份 secret。控制台里对应 client_secret_basic 和 client_secret_post,差别只是 secret 放在 HTTP Basic 里还是放在表单字段里。前提是 secret 只能留在服务器上,用户看不到、也拿不走。
公开客户端没有 secret。控制台里对应的选项是 None (PKCE)。因为安装包和前端代码都会被拆开看,再发一份固定 secret 等于公开发布。它改用 PKCE:每次登录现场生成一段随机的 code_verifier,授权时只把派生出来的 code_challenge 交给 Cloudflare,兑换时再出示原来的 verifier。Cloudflare 要求 challenge 方法必须是 S256。
对上自己的应用之后,选择就清楚了:
| 你的应用 | 用哪种客户端 | Token Authentication Method | PKCE |
|---|---|---|---|
| 服务端 Web / 后端 | 机密客户端 | client_secret_basic 或 client_secret_post | 可选 |
| SPA、移动、桌面、CLI | 公开客户端 | None (PKCE) | 必须,而且必须是 S256 |
只有选了 client_secret_basic 或 client_secret_post,才会生成 client_secret。创建成功后它会当场显示一次,离开页面就再也看不到,丢了只能轮转。这份 secret 只能留在服务端,放进环境变量或密钥管理,不要下发到浏览器。
SPA、移动、桌面、CLI 不要选这两项,保持 None (PKCE)。这样就不需要配置 client_secret。
创建 Client 的关键字段
控制台路径是:选账号 → Manage Account → OAuth clients → Create client。Cloudflare 账号要带 Super Administrator、Administrator,或者 OAuth Client Write。也可以走 API:POST /accounts/{account_id}/oauth_clients,对应 token 需要 OAuth Clients Write。
点 Create client 之后,这一页的标题是 Configure OAuth client。右侧三步写得很清楚:当前是 Configure OAuth client,后面是 Select permission scopes、Choose optional scopes。标题下面有一段原文:
All clients are considered private and cannot be made public until the required fields are filled. Some optional fields are required for public clients.
新建客户端默认是 private。必填项没齐之前,不能改成 public。这一页上标成 optional 的字段,private 阶段可以空着;以后要升 public,官方要求先齐 Client name、Logo、Client URL、Scopes,并且完成 Client URL 的域名验证。

图 1:Configure OAuth client。
这一页从上到下是:Client Name、Response Type、Grant type、Token Authentication Method、Redirect (Callback) URLs、Client URL (optional),再下面有一栏收起的 Advanced options。填完点 Continue,进入第二步选权限。下面按字段说该怎么改。
Client Name
给自己看的名字,同意页上用户也会看到。先起一个能分清环境的名字,比如 myapp-dev,不要和生产混用同一个 client。
Response Type:只留 Code
图 1 里这一栏已经是 Code。保持这样就对。下拉里还能加上 Token、ID Token,那两项对应 Implicit:令牌会出现在 URL 的 #fragment 里。浏览器从来不会把 fragment 发给服务器。只要回调要经过你的后端,或者经过一个 HTTPS 中转,这条路就走不通——服务端只能看到 query string。
官方也不再支持 Implicit。不要把 Token 和 ID Token 加回去。留着它们不会多一条捷径,只会在授权页或兑换 token 时报错。
Grant type:Authorization Code,要续期再加 Refresh Token
图 1 里这一栏已经是 Authorization Code, Refresh Token。Authorization Code 必须留着。如果希望用户不用反复点同意,Refresh Token 也可以留,后面选 scope 时还要带上 offline_access。不需要静默续期,就把 Refresh Token 去掉。
openid、offline_access 这类协议 scope,会跟着你选的 Response Type 和 Grant type 自动增减,不用自己拼。
Token Authentication Method
这一栏就是上一节说的客户端类型。图 1 里默认是 None (PKCE)。
- 后端、服务端 Web:改成
client_secret_basic或client_secret_post。 - SPA、移动、桌面、CLI:保持
None (PKCE)。
选了带 secret 的两项,创建成功后会当场显示 client_secret。离开页面就再也看不到,丢了只能轮转。
Redirect (Callback) URLs
占位符是 https://example.com/callback。改成你真正要接回调的地址。官方只接受 https://,myapp://oauth/callback 这种自定义 scheme 会被拒绝,这是原生 App 接入时最硬的一条限制。登出回调用同一套规则,不过它不在这一页。
桌面和 CLI 可以把 http://127.0.0.1:<port>/callback 这类回环地址直接填在这里,不必再架中转。但不要由此推论「任意 http 都行」:指向公网 IP 或普通域名的 http://,不该出现在这个字段里。
登记进去的每一条都是允许列表。授权请求里的 redirect_uri 必须和其中一条完全一致,包括 scheme、host、端口、路径,以及有没有尾斜杠。兑换 token 时还要再传一次,而且必须和授权那一次是同一个字符串。
Client URL(optional)
这一页上标了 optional。private 客户端可以先空着。以后要升 public,这里必须填,而且域名要通过 TXT 验证。路径以后还能改,域不行,所以不要随便写一个以后用不到的域名。
点 Continue 之后进入第二步 Select permission scopes,第三步是 Choose optional scopes。Scope 的名字很容易写错:不要发明冒号分隔的写法,account:read 会被拒,正确形态是 account.read 这种点号分隔。这些名字和 API Token 的权限一一对应,至少选一个。
默认全部都是 required,用户在同意页必须一次接受。只有放进 optional scopes 的权限,用户才能单独拒绝,而且它必须是已选 scopes 的子集。这就是向导第三步要做的事。
官方文档里的同意页就是这个样子:上面一块是 Required,用户没法关掉;下面 Additional access 才是可以点 Edit Permissions 的可选权限。

图 2:同意页先列出必选权限,可选权限放在 Additional access 里。
点进 Edit Permissions 之后,还可以按 Read only / Full access 过滤,或者直接搜某个 scope。这就是为什么创建时别把什么都勾成 required——勾成 optional,用户才有机会在这一页关掉。

图 3:可选权限可以按项关掉,Required 那一项会一直留着。
当前用不到的权限就不要勾。勾进 Required 的,用户在同意页关不掉。
先 private,再决定要不要 public
新客户端默认是 private:只有创建它的那个 Cloudflare 账号里的成员能完成授权。内部工具、联调、平台自己的管理登录,用这个就够了。这一点和图 1 标题下那段原文一致。
要让任意 Cloudflare 用户都能授权,必须提升为 public。提升之前要满足:
- Client name、Logo、Client URL 都填齐
- 至少有一个不是 identity 的 scope
- Client URL 的域名通过 TXT 验证。记录值要带
cloudflare_oauth_client_publisher=这个前缀。Cloudflare 轮询最长大约两天,超时了可以点 Restart
升成 public 之后不能再降回去。 验证过的域名也不能改成另一个域,路径可以改,域不行。所以先用 private 把整条链路跑通,再决定要不要升。
授权码流程怎么走
一次完整登录是五步,对应下图。哪一步漏了,后面的报错多半会指错方向。
图 4:应用、浏览器、Cloudflare 之间的授权码流程。公开客户端用 code_verifier 换 token,机密客户端用 client_secret。
- 应用自己生成
state(用来防 CSRF),以及 PKCE 需要的一对值:先准备一段高熵随机串作为code_verifier,再算code_challenge = BASE64URL(SHA256(verifier))。 - 用浏览器打开授权页:
GET https://dash.cloudflare.com/oauth2/auth
?response_type=code
&client_id=<client_id>
&redirect_uri=<登记过的那一条>
&scope=openid profile email offline_access account.read
&state=<随机值,应用自己保存>
&code_challenge=<S256(code_verifier)>
&code_challenge_method=S256
- 用户同意之后,Cloudflare 会 302 到
redirect_uri?code=…&state=…,通常还会带上 RFC 9207 规定的iss。 - 先核对
state,再核对iss。后者是为了防止 mix-up:确认发来授权码的,就是你以为的那台授权服务器。然后拿code去/oauth2/token兑换——公开客户端带上code_verifier,机密客户端带上client_secret。 - access token 和 refresh token 只放在这个平台真正能保密的地方。服务端用密钥库,iOS 用 Keychain,不要写进
UserDefaults、LocalStorage,更不要打进日志。
公开客户端的兑换请求大致是这样:
POST https://dash.cloudflare.com/oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=<回调拿到的 code>
&redirect_uri=<必须与授权时完全一致>
&client_id=<client_id>
&code_verifier=<发起时生成的那条>
code 只能用一次,而且活得很短。之后续期用 grant_type=refresh_token。用户退出时要调 /oauth2/revoke,不要只删本地那一份——本地删了,对面那枚 refresh token 往往还活着。
原生 App 的 HTTPS 墙
Cloudflare 不接受 myapp://,也不提供 Device Authorization。所以原生 App 必须先有一个真实的 HTTPS 地址把回调接住,再由这个地址把 code 和 state 送回 App。整条认证链路仍是图 4,只是第 3 步的 redirect_uri 先落到中转,再 302 进 App。
常见做法是自己架一个只做 302 的中转:把 https://<你的域>/oauth/callback/<appId> 登记到 Cloudflare 的 redirect_uris,中转再跳到 myapp://oauth/callback。中转不要保存 code,不要兑换 token,也不要持有 secret。state 校验和 PKCE 兑换,仍然应该在设备上完成。
接线时其实是三个不同的 URL。混用这三处,是原生接入失败最常见的原因:
| 填在哪里 | 填什么 | 示例 |
|---|---|---|
Cloudflare 的 redirect_uris | HTTPS 中转地址 | https://relay.example.com/oauth/callback/k7m2xq9vn4bt3wp8 |
| 中转上登记的「回调地址」 | App 最后要跳回去的地方 | myapp://oauth/callback |
ASWebAuthenticationSession.callbackURLScheme | 只填 scheme 名 | myapp |
iOS 还要在 Info.plist 的 CFBundleURLTypes 里声明 CFBundleURLSchemes。漏了这一步,系统不知道该由谁接手 myapp://,中转的 302 就会落空。
用 ASWebAuthenticationSession 时,建议打开 prefersEphemeralWebBrowserSession,少一次共享 Cookie 带来的账号串用。退出时同样要调 /oauth2/revoke,不要只清 Keychain。
常见的坑
1. redirect_uri 对不上,得到 invalid_grant
授权请求里的 redirect_uri,必须和创建客户端时登记的某一条完全一样,兑换 token 时还要再传一次,而且必须和授权那一次是同一个字符串。scheme、主机、端口、路径、有没有尾斜杠都不能差。原生 App 如果授权时填的是 HTTPS 中转,兑换时却写成 myapp://…,token 端点会失败。授权码只能用一次,失败后再拿同一条 code 去换,一样会失败。先对两次请求里的 redirect_uri,再查 code_verifier 是不是发起授权时生成的那一条。
2. Response Type 加回了 Token / ID Token
图 1 里默认已经是 Code。官方给第三方客户端只支持 Authorization Code,不支持 Implicit。下拉里如果再勾上 Token 或 ID Token,令牌会进 URL 的 #fragment。浏览器不会把 fragment 发给服务器,后端和中转都看不见。不要加回去。
3. SPA、移动、桌面、CLI 还选了带 secret 的两项
官方要求这类应用走 Authorization Code + PKCE:Token Authentication Method 选 None (PKCE),challenge 方法用 S256,不要用 plain。选了 client_secret_basic 或 client_secret_post,才会生成 client_secret;这类应用藏不住它。
4. 以为 private 客户端谁都能授权
新客户端默认是 private。官方写明:只有创建它的那个 Cloudflare 账号里的成员能完成授权。要让任意 Cloudflare 用户都能授权,必须先升成 public。升上去之后不能再改回 private。
5. 对方账号关了 Public OAuth App access
同意页上如果选不到对方那个账号,先让对方管理员看 Manage Account → Members → Settings → Public OAuth App access。官方说这个开关会限制 OAuth 应用访问该账号的资源,跟你这边的 client 字段无关。
6. 回调回来不校验 state
state 本来就会出现在授权 URL 和回调 URL 里,用来防 CSRF。发起授权时自己存一份随机值,比如服务端 session 或 App 内存;回调回来必须对得上。不要用固定值,也不要空着不传。
7. 回调里有 iss 却不核对
RFC 9207 用 iss 标明这枚授权码是哪台授权服务器发的。回调里如果带了这个参数,把它和 https://dash.cloudflare.com/.well-known/openid-configuration 里的 issuer 对一下。现在这个值是 https://dash.cloudflare.com,以后以配置为准,不要在代码里写死。
8. 日志里打印了 code 或 token
code 和 token 都是凭据。中转和回调处理只记结果、错误码,不要记 code、state 和 token,也不要打进 APM 或错误上报。
9. 用 GET 去换 token,或者把机密客户端的兑换放到浏览器里
token 端点只接受 POST。机密客户端的兑换要在服务器上做,不要把 client_secret 带到前端。公开客户端可以在设备上换,但不要把 token 响应打到浏览器控制台然后忘了删。
10. 轮转了 secret,却忘了删旧的
官方允许每个客户端同时有两个 secret:先创建新的,客户端改用新的,再删旧的。接口返回里的 has_rotated_secret 为 true 时,必须先删掉旧的,才能再转一次。
11. 域名验证超时,就当成配置错了
升 public 之前,Client URL 的域名要做 TXT 验证。记录值必须原样带上 cloudflare_oauth_client_publisher= 这个前缀。Cloudflare 会轮询这条记录,直到找到,或者两天后超时。超时了先在客户端菜单里点 Restart verification,或者对同一个 client_uri 再发一次 PATCH。这不等于域名写错了。
12. 回调路径上开了 Challenge
原生 App 的回调如果要经过你自己的 HTTPS 中转,路径上不要开 Managed Challenge、Bot Fight 这类会打断跳转的验证。ASWebAuthenticationSession 里一旦弹出挑战页,授权就会停在半路。
最佳实践
只勾当前功能用得到的 scope。官方说这些名字和 API Token 权限对应,至少选一个;默认都是 required,用户在同意页关不掉。能放到 optional 的,就不要做成 required。
开发、预发、生产用三个 client,各自登记自己的 redirect_uris。不要把开发环境的回调地址写到生产 client 上。
选了 client_secret_basic 或 client_secret_post 时,官方只会在创建或轮转时显示一次 client_secret,离开页面就看不到。立刻写进密钥管理,不要放进聊天记录、issue 或截图。选了 None (PKCE) 就不需要配置 client_secret。
官方要求每次登录都生成一对新的 PKCE:code_verifier 按 RFC 7636 用加密安全的随机数,长度 43 到 128 个字符;challenge 方法用 S256。单测可以用 RFC 7636 附录 B 的测试向量:
verifier = dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
challenge = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
redirect_uri 必须和登记的那一条完全一致。路径改了,已经发出去的授权请求会对不上。已经对外登记过的回调地址,不要轻易改。
联调先用 private。官方写明只有本账号成员能完成授权,这样失败时先查自己的代码和字段。
用户退出时调 https://dash.cloudflare.com/oauth2/revoke,不要只删本地那一份 token。用户也可以在控制台的 Manage OAuth authorizations 里自己吊销。
观测只记授权结果和错误码。不要把 code、state 和 token 写进日志。
授权、token、jwks、issuer 以 https://dash.cloudflare.com/.well-known/openid-configuration 为准,不要在代码里写死。
排障对照
| 现象 | 先查什么 |
|---|---|
| 授权页直接失败,或同意页选不到对方账号 | 客户端还是 private 吗?对方关了 Public OAuth App access 吗?scope 名字是不是带了冒号? |
| 回调 404,或者提示「无法完成」 | redirect_uri 登记了吗?中转的 appId 写对了吗?应用是不是被停用了? |
invalid_grant | 两次 redirect_uri 是不是同一个字符串?verifier 对得上吗?code 是不是已经用过了? |
| 换到了 token,调 API 却 403 | access token 的 scope 覆盖那个接口了吗?用户对目标账号有对应权限吗? |
| 刷新失败 | 创建时勾了 Refresh Token,请求里也带了 offline_access 吗?refresh token 是不是已经被吊销了? |
| iOS 302 之后没有反应 | Info.plist 声明了 scheme 吗?callbackURLScheme 是不是只填了名字? |
参考
- Create your OAuth client(2026-08-20 更新:支持的 grant、PKCE、private / public、域名验证、secret 轮转)
- Authorizing an application(同意页上 Required / Additional access 怎么展示)
- Integrate your OAuth client with Cloudflare(端点列表)
- Choose OAuth scopes for Wrangler and the Cloudflare API MCP server(文中同意页截图的出处)
- OAuth Clients API(字段约束:visibility 只能升不能降,scope 用点号分隔)
- RFC 6749(Authorization Code)、RFC 7636(PKCE)、RFC 9207(
iss)
Mttao GitHub ↗
探索技术与生活的智慧