不喜欢广告? 无广告 今天

OAuth 2.0 流程 授权码、PKCE 和客户端凭据

更新于

开发者指南:如何选择合适的OAuth 2.0流程。涵盖授权码(网页应用)、PKCE(单页应用和移动应用)以及客户端凭据(服务器到服务器)的使用,包含实际代码示例以及后期可能引发问题的常见错误。

OAuth 2.0 流程:授权码、PKCE 和客户端凭据 1
广告 移除?

三种流程涵盖了95%种真实的OAuth 2.0使用场景。规范定义了更多内容,但其余的已被弃用、属于边缘情况或两者兼有。在开始时选择正确的流程,可以避免在认证需求变更时进行痛苦的重构。

流程何时使用是否涉及用户?是否需要客户端密钥?
授权码具有后端服务器的网页应用是的是 —— 保留在服务器上
授权码 + PKCE单页应用、移动应用,或任何公开客户端是的
客户端凭据服务器到服务器(无用户)是 —— 保留在服务器上

授权码流程

这是具有后端服务器的网页应用的标准流程。访问令牌和客户端密钥永远不会触碰浏览器——令牌交换在服务器端完成。这才是整个流程的要点。

步骤1:重定向用户

构建一个授权URL并将其重定向到浏览器:

GET https://accounts.example.com/oauth/authorize
  ?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=https://yourapp.com/callback
  &scope=openid+profile+email
  &state=RANDOM_CSRF_TOKEN

state 值是您重定向的CSRF防御。为每个流程生成一个全新的随机字符串,存储在会话中,并在用户返回时验证。如果跳过此检查,攻击者可以利用自己的授权码引导用户完成流程——静默地将用户的账户与攻击者的身份关联。

步骤2:处理回调

授权服务器将重定向回您的 redirect_uri 并附带一个短期的授权码:

GET https://yourapp.com/callback?code=AUTH_CODE&state=SAME_STATE_YOU_SENT

验证 state 与您存储的内容匹配。然后在服务器端交换该授权码。

步骤3:用授权码换取令牌(仅在服务器端)

POST https://accounts.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=AUTH_CODE
&redirect_uri=https://yourapp.com/callback
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET

响应:

{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "def50200a12b3c...",
  "scope": "openid profile email"
}

将两个令牌都保留在服务器端。当 access_token 过期(检查 expires_in),使用 refresh_token 获取新的令牌,而无需再次让用户登录。

PKCE —— 适用于无法保存密钥的客户端

单页应用或移动应用没有安全的地方存放 client_secret。任何人都可以打开开发者工具并从您的JavaScript包中找到它。任何人都可以反编译您的APK。PKCE(代码交换的证明密钥,发音为“pixy”)通过一次性加密挑战解决了这个问题——无需共享密钥。

该流程与授权码流程相同,有两个新增内容:一个 code_verifier (您生成的随机字符串) 和一个 code_challenge (验证器的SHA-256哈希值,Base64URL编码)。您在交换令牌时提前发送挑战,然后在交换时证明您持有验证器。

步骤1:生成验证器和挑战

function generateCodeVerifier() {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return base64url(array);
}

async function generateCodeChallenge(verifier) {
  const data = new TextEncoder().encode(verifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return base64url(new Uint8Array(digest));
}

function base64url(buffer) {
  return btoa(String.fromCharCode(...buffer))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

// Usage
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
// Store codeVerifier in memory — NOT localStorage

code_verifier 存储在内存中——一个模块作用域变量,而不是localStorage。您将在令牌交换时发送它。

步骤2:授权请求——添加挑战

GET https://accounts.example.com/oauth/authorize
  ?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=https://yourapp.com/callback
  &scope=openid+profile
  &state=RANDOM_CSRF_TOKEN
  &code_challenge=BASE64URL_SHA256_OF_VERIFIER
  &code_challenge_method=S256

步骤3:令牌交换——使用验证器代替客户端密钥

POST https://accounts.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=AUTH_CODE
&redirect_uri=https://yourapp.com/callback
&client_id=YOUR_CLIENT_ID
&code_verifier=ORIGINAL_VERIFIER_YOU_GENERATED

授权服务器会对验证器进行哈希处理,并将其与您在步骤2中发送的挑战进行比对。任何截获了授权码的攻击者都无法知道验证器是什么——他们无法使用它进行交换。

值得了解的是:OAuth 2.1(OAuth 2.0的正在进行现代化版本)要求所有涉及重定向的流程都必须使用PKCE。如果您正在编写新代码,无论提供商当前是否要求,都应使用PKCE。

客户端凭据——无需用户,问题不大

后台作业、微服务调用其他微服务、定时任务调用API——这些都不涉及用户。客户端凭据是正确的流程:服务直接使用其客户端ID和密钥进行身份验证。

POST https://accounts.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
&scope=api:read api:write

响应:

{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 86400
}

没有刷新令牌——当它过期时,只需请求一个新的令牌。常见的错误是每次API调用都调用令牌端点。缓存令牌,在每次请求前检查过期时间,仅在即将过期时重新获取。每天(或每小时,取决于 expires_in)仅请求一次令牌,而不是每次请求都请求一次:

let cachedToken = null;
let tokenExpiresAt = 0;

async function getAccessToken() {
  // Refresh 30 seconds before actual expiry
  if (cachedToken && Date.now() < tokenExpiresAt - 30_000) {
    return cachedToken;
  }

  const res = await fetch('https://accounts.example.com/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: process.env.CLIENT_ID,
      client_secret: process.env.CLIENT_SECRET,
      scope: 'api:read api:write',
    }),
  });

  const data = await res.json();
  cachedToken = data.access_token;
  tokenExpiresAt = Date.now() + (data.expires_in * 1000);
  return cachedToken;
}

开发者实际犯的错误

将令牌存储在localStorage中

任何XSS漏洞——无论是您自己的代码、依赖项还是标签管理器脚本——都可以读取localStorage中的所有内容。对于单页应用:将访问令牌存储在内存中(一个模块作用域变量,在刷新时消失)。当您有后端可以设置时,使用httpOnly Cookie存储刷新令牌。JavaScript无法读取httpOnly Cookie。

使用隐式流程

隐式流程将令牌直接返回到URL片段(#access_token=...)。这些令牌最终会出现在浏览器历史记录、服务器访问日志和Referer头中。它在RFC 9700中已被弃用。对于新代码,没有理由使用隐式流程。应使用PKCE。

跳过状态验证

在此处粘贴 .github/workflows/*.yml 内容 state 在回调时进行验证,攻击者可以构造一个重定向URL,使用自己的授权码完成OAuth流程。结果是,您的用户账户被链接到攻击者的身份。为每个流程生成一个全新的状态,存储在会话中,并在回调时验证。

将客户端密钥放入前端代码中

浏览器中不存在所谓的“安全密钥”。最小化不会隐藏它。混淆也无法保护它。如果您的运行环境是浏览器或移动应用,您有一个 公开客户端 ——使用PKCE并完全省略客户端密钥。这不是一个变通方法;这是规范为公开客户端设计的工作方式。

未主动处理令牌过期

每个访问令牌都有一个 expires_in 过期时间。如果您将令牌持有到401错误失败,然后重新认证,用户会遇到神秘错误。在每次请求前检查过期时间,提前刷新(在过期前30秒是一个合理的缓冲),并处理刷新令牌本身过期的罕见情况。

在工作过程中检查令牌

大多数OAuth提供商将JWT作为访问令牌发放。负载是Base64URL编码的,无需私钥即可读取——只有签名需要私钥来验证。当您在调试流程并希望查看令牌中的声明、范围或过期时间时,只需将令牌粘贴到 JWT 解码器.

如果在手动测试PKCE时需要验证一个Base64URL编码的 code_challenge 解码结果, Base64解码器 支持标准和URL安全变体。

简明版

一个关键问题决定了正确的流程:您的运行环境是否有安全的地方来保存密钥?

  • 后端服务器 → 授权码或客户端凭据(密钥保留在服务器上)
  • 浏览器或移动应用 → 授权码 + PKCE(完全没有密钥)
  • 没有用户参与 → 客户端凭据

PKCE适用于所有涉及重定向的流程——即使提供商目前不强制要求,使用它也没有任何缺点。OAuth 2.1将强制要求这一点。

想要享受无广告的体验吗? 立即无广告

安装我们的扩展

将 IO 工具添加到您最喜欢的浏览器,以便即时访问和更快地搜索

添加 Chrome 扩展程序 添加 边缘延伸 添加 Firefox 扩展 添加 Opera 扩展

记分板已到达!

记分板 是一种有趣的跟踪您游戏的方式,所有数据都存储在您的浏览器中。更多功能即将推出!

广告 移除?
广告 移除?
广告 移除?

新闻角 包含技术亮点

参与其中

帮助我们继续提供有价值的免费工具

给我买杯咖啡
广告 移除?