12k
All articles

如何添加 GitHub 登录

使用Node和Express添加GitHub登录。通过state和PKCE构建OAuth流程,安全交换授权码,获取已验证邮箱并刷新令牌。

OpenReplay Team
OpenReplay Team
如何添加 GitHub 登录

要添加 GitHub 登录(Sign in with GitHub),需要将用户重定向到 https://github.com/login/oauth/authorize,在回调 URL 处接收一个临时 code,然后在服务器端向 https://github.com/login/oauth/access_token 发送 POST 请求,用该 code 换取访问令牌。

你可能一边开着 OAuth 应用设置页面,一边开着路由处理代码,而大多数教程会把整件事都交给某个 SDK。本指南则在 Node 中使用 fetch 和 Express 构建服务端流程,不依赖任何 OAuth 库。底层模型可参阅 All about OAuth 2.0。本文涵盖 state 与 PKCE、邮箱为 null 的问题、权限范围(scope),以及 GitHub 于 2026 年 8 月 14 日发布的重定向 URI 和令牌刷新相关变更。

核心要点

  • GitHub 的授权码在签发 10 分钟后过期,且换取令牌的步骤必须在服务器端执行,因为它需要用到 client secret。
  • 在服务器端生成 state,将其存储在 httpOnly Cookie 中;如果回调返回的值与之不一致,则中止回调。这可以防御登录 CSRF。
  • 当用户没有设置公开邮箱时,GET https://api.github.com/user 会返回 email: null,因此需要申请 user:email 权限,并使用 GET /user/emails 中标记为主邮箱且已验证的条目。
  • 自 GitHub 2026 年 8 月 14 日更新起,一个 OAuth 应用最多可注册 10 个重定向 URI,且新应用默认签发 8 小时有效期的访问令牌和 6 个月有效期的刷新令牌。

注册 GitHub OAuth 应用

在 Settings → Developer settings → OAuth Apps 下创建一个 OAuth 应用,然后复制 client ID 并生成 client secret。GitHub 的注册指南列出了所有字段的说明。

自 2026 年 8 月 14 日的版本发布起,一个 OAuth 应用最多可以配置 10 个重定向 URI,通过 Add callback URL 按钮添加即可。这样,用一个应用就能同时覆盖生产环境、预览部署和本地开发。本地开发时,请注册 http://127.0.0.1:3000/auth/github/callback。localhost 也能用,但 GitHub 的回环地址指南指出,OAuth RFC 推荐使用 127.0.0.1 字面地址。

通配符匹配现在是每个重定向 URI 各自的设置项。如果你的应用在 2026 年 8 月 3 日之前只有一个回调 URL,GitHub 已为其开启通配符匹配,以保持原有的重定向行为。除非你能控制该 URL 下的所有子域名和路径,否则请将其关闭。

重定向到 GitHub 的授权端点

登录按钮指向一个服务端路由。该路由生成 state 和 PKCE verifier,将两者存入短期有效的 Cookie,然后重定向到 GET https://github.com/login/oauth/authorize。

import crypto from 'node:crypto';
import express from 'express';
import cookieParser from 'cookie-parser';

const app = express();
app.use(cookieParser());

const { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_REDIRECT_URI } = process.env;
// GITHUB_CLIENT_SECRET is read only here, on the server. Never ship it to the browser.
const isProd = process.env.NODE_ENV === 'production';

const tempCookie = {
  httpOnly: true,
  secure: isProd,
  sameSite: 'lax',
  maxAge: 10 * 60 * 1000, // matches the 10-minute code lifetime
  path: '/auth/github',
};

app.get('/auth/github', (req, res) => {
  const state = crypto.randomBytes(32).toString('base64url');
  const verifier = crypto.randomBytes(32).toString('base64url'); // 43 chars
  const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');

  res.cookie('gh_state', state, tempCookie);
  res.cookie('gh_verifier', verifier, tempCookie);

  const params = new URLSearchParams({
    client_id: GITHUB_CLIENT_ID,
    redirect_uri: GITHUB_REDIRECT_URI, // one value per environment
    scope: 'read:user user:email',
    state,
    code_challenge: challenge,
    code_challenge_method: 'S256',
  });
  res.redirect(`https://github.com/login/oauth/authorize?${params}`);
});

GitHub 的授权参数仅支持一种 PKCE 方法 S256,并拒绝 plain。challenge 必须是 43 个字符的 SHA-256 值,而对 SHA-256 摘要进行 base64url 编码恰好得到这个结果。如果你的用户经常拥有多个 GitHub 账号,可以添加 prompt=select_account,让 GitHub 始终显示账号选择器。

校验 state 以防御登录 CSRF

state 参数是一个无法被猜测的随机值。由你的服务器生成,并存储在会话或 httpOnly Cookie 中。当 GitHub 重定向回来时,将其与回调中的 state 进行比较。如果两者不一致,则终止登录,且不要用 code 换取令牌。

如果缺少这项检查,你的应用就会暴露在登录 CSRF 风险之下——这种攻击会让受害者登录到攻击者的账号中:

  1. 攻击者使用自己的 GitHub 账号发起你应用的 GitHub 登录流程。
  2. 攻击者批准授权,但在回调执行前停下,保留下有效的 code。
  3. 攻击者设法让受害者的浏览器加载 https://yourapp.com/auth/github/callback?code=ATTACKER_CODE,例如通过隐藏图片或自动跳转链接。
  4. 你的服务器用该 code 换取令牌,并以攻击者的身份为受害者完成登录。更糟的是,如果受害者此前已经登录,服务器会把攻击者的 GitHub 身份关联到受害者的账号上。
  5. 此后受害者保存的任何内容都会落入攻击者控制的账号。而关联的身份还能让攻击者日后以受害者身份登录。

有了 state,第 4 步就会失败。受害者的浏览器要么没有 gh_state Cookie,要么持有的值与攻击者的不一致,因此服务器会拒绝该请求。PKCE 又增加了一层绑定:code 只有与发起流程的浏览器中存储的 code_verifier 一起使用才有效。RFC 6749 正是为此目的定义了 state,而 OAuth 2.0 安全最佳当前实践建议所有客户端都使用 PKCE。OAuth 安全最佳实践中关于 state 和 PKCE 的指南对此有更深入的探讨。

function statesMatch(received, stored) {
  if (typeof received !== 'string' || typeof stored !== 'string') return false;
  const a = Buffer.from(received);
  const b = Buffer.from(stored);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Cookie 属性同样重要。HttpOnly 防止脚本读取该值,Secure 限制其仅通过 HTTPS 传输。SameSite=Lax 则确保 GitHub 以顶层导航方式重定向回你的站点时,Cookie 仍能被携带。由于只有一个 gh_state Cookie,用户在第二个标签页发起登录会覆盖第一个,因此应显示清晰的重试提示,而不是一个空白的错误页。

处理回调并换取令牌

回调会校验 state,清除临时 Cookie,然后携带 Accept: application/json 请求头,将 code POST 到 https://github.com/login/oauth/access_token。如果缺少该请求头,GitHub 的令牌端点会返回表单编码格式的响应体。code 在签发 10 分钟后过期,因此应立即进行兑换。

app.get('/auth/github/callback', async (req, res) => {
  const { code, state } = req.query;
  const { gh_state: savedState, gh_verifier: verifier } = req.cookies;

  res.clearCookie('gh_state', { path: '/auth/github' });
  res.clearCookie('gh_verifier', { path: '/auth/github' });

  if (typeof code !== 'string' || !verifier || !statesMatch(state, savedState)) {
    return res.status(400).send('Sign-in failed. Please try again.');
  }

  const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
    method: 'POST',
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      client_id: GITHUB_CLIENT_ID,
      client_secret: GITHUB_CLIENT_SECRET,
      code,
      redirect_uri: GITHUB_REDIRECT_URI, // must match the authorize request
      code_verifier: verifier,
    }),
  });
  const token = await tokenRes.json();
  if (token.error || !token.access_token) {
    return res.status(400).send('Sign-in failed. Please try again.');
  }

  const profile = await getGitHubProfile(token.access_token);
  const user = await upsertUserByGitHubId(profile); // key on profile.id
  await saveGitHubTokens(user.id, token);           // server-side only
  await createSession(res, user.id);                // your own session cookie
  res.redirect('/');
});

令牌兑换必须在服务器端完成,因为它需要 client secret,而该密钥绝不能泄露到浏览器。兑换成功后,签发你自己的会话 Cookie,并将 GitHub 令牌保存在服务器端。JWT 认证中 Cookie 与 localStorage 的对比一文讨论了不同存储方式的取舍。最后的 res.redirect('/') 会将 code 和 state 从地址栏中移除,这样用户按下”后退”键时就无法重放已使用过的 code。

GitHub 登录失败时的会话回放可以呈现完整的操作序列:点击、跳转 GitHub、回调、错误页。借助这一序列,可以区分 state 不匹配与 code 过期两类问题。请在所有会话回放、分析或日志工具中配置对 code 和 state 查询参数的脱敏处理。

获取用户信息及已验证的邮箱

使用令牌调用 GET https://api.github.com/user 来识别用户。当用户没有设置公开邮箱时,已认证用户端点会返回 email: null。此外,OAuth 令牌还需要 read:user 权限才能获取私有的用户信息响应。

const ghHeaders = (accessToken) => ({
  Authorization: `Bearer ${accessToken}`,
  Accept: 'application/vnd.github+json',
  'User-Agent': 'your-app-name',
});

async function getGitHubProfile(accessToken) {
  const userRes = await fetch('https://api.github.com/user', { headers: ghHeaders(accessToken) });
  if (!userRes.ok) throw new Error(`GET /user failed: ${userRes.status}`);
  const user = await userRes.json();

  let email = user.email;
  if (!email) {
    const emailsRes = await fetch('https://api.github.com/user/emails', { headers: ghHeaders(accessToken) });
    if (emailsRes.ok) {
      const emails = await emailsRes.json();
      email = emails.find((e) => e.primary && e.verified)?.email ?? null;
    }
  }
  return { id: user.id, login: user.login, name: user.name, email };
}

在拥有 user:email 权限的情况下,GET /user/emails 会返回所有邮箱地址及其 primary 和 verified 标志。请选择两者均为 true 的条目,并且绝不要基于未验证的邮箱地址关联账号。应以数字 id 作为用户的唯一标识,因为登录名和邮箱都可能变更。GitHub REST API 会拒绝任何不带 User-Agent 请求头的请求,所以请自行设置。GitHub 还建议每次获取新令牌时都重新确认用户身份,因为用户可能在授权页面上切换了账号。

只申请所需的最小权限范围

GitHub 登录只需要两个 OAuth 权限范围:read:user 和 user:email。GitHub 的权限范围参考文档对每个范围都有定义,而你申请的每一项权限,用户都会在 GitHub 的授权同意页面上看到。

你需要的内容权限范围
个人资料:id、登录名、姓名、头像read:user
邮箱地址user:email
仓库内容repo(完全访问权限,包括私有仓库)

如果仅为登录就申请 repo,等于要求用户交出其私有代码的读写权限,很多用户会在这一步放弃。如果后续某个功能确实需要仓库访问权限,请在那时单独发起一次授权流程。GitHub 对每个”用户、应用和权限范围”组合最多允许 10 个令牌,且每小时最多签发 10 个新令牌。如果回调陷入循环并触及该每小时上限,GitHub 会中止流程并向用户显示重新授权提示。

刷新即将过期的令牌

要刷新 GitHub 令牌,需向令牌端点 POST grant_type=refresh_token。使用刷新令牌后,该刷新令牌及旧的访问令牌都会失效,因此在发起下一次调用前务必保存新的令牌对。启用过期令牌后,访问令牌在 8 小时后过期,未使用的刷新令牌在 6 个月后过期。自 2026 年 8 月 14 日的变更起,新应用默认采用此行为。现有应用可以通过添加 offline_access 权限范围针对单次登录启用该功能(不会额外弹出授权同意提示),也可以在应用设置中直接启用。

async function refreshGitHubToken(refreshToken) {
  // No refresh token means a non-expiring token: nothing to refresh.
  if (!refreshToken) return null;

  const res = await fetch('https://github.com/login/oauth/access_token', {
    method: 'POST',
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      client_id: GITHUB_CLIENT_ID,
      client_secret: GITHUB_CLIENT_SECRET,
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
    }),
  });
  const data = await res.json();
  if (data.error === 'bad_refresh_token') return { reauth: true };
  if (data.error) throw new Error(data.error);

  return {
    accessToken: data.access_token,
    refreshToken: data.refresh_token,
    expiresAt: Date.now() + data.expires_in * 1000,
  };
}

刷新过程中无法更改权限范围。返回 bad_refresh_token 意味着用户必须重新登录。不要假定一定会返回 refresh_token:某些版本的 GitHub Enterprise Server 会忽略 offline_access,并签发永不过期的令牌。

应该使用 OAuth 应用还是 GitHub App?

如果只是实现 GitHub 登录,一个申请 read:user user:email 的 OAuth 应用就足够了,尽管 GitHub 总体上更推荐使用 GitHub App 而非 OAuth 应用。GitHub 的对比页面详细列出了两者的全部差异。

OAuth 应用GitHub App
权限模型粗粒度权限范围细粒度权限
仓库选择权限范围覆盖的所有仓库由用户选择仓库
令牌有效期新应用默认 8 小时过期;旧应用除非启用,否则为长期有效短期有效
GitHub 的推荐支持首选

如果你的产品需要对仓库执行操作,请选择 GitHub App。如果只用于身份认证,上述 OAuth 应用流程就足够了。

安全检查清单

  • 每次请求都发送 state 和 PKCE S256,并在回调时对两者进行校验。
  • 授权请求与令牌请求中发送相同的 redirect_uri。
  • 关闭不需要的通配符匹配。
  • 将 client secret 和 GitHub 令牌保存在服务器端,只向浏览器提供你自己的会话 Cookie。
  • 完成令牌兑换后从回调页重定向离开。
  • 处理令牌响应中不包含 refresh_token 的情况。

GitHub 登录归根结底就是三个请求:授权、兑换、获取用户信息。而安全性体现在围绕它们的各种细节中:由服务器持有的 state、PKCE verifier、永不离开后端的密钥,以及经过确认已验证的邮箱。接下来,打开你的 OAuth 应用设置,添加预览环境和 127.0.0.1 回调 URL,关闭通配符匹配,然后上线上述三个路由。

常见问题

为什么 GitHub 的令牌端点会返回 bad_verification_code?

当你发送的 code 不正确、已过期,或与授权请求中签发的 code 不匹配时,GitHub 会返回 bad_verification_code。常见原因是 code 已超过 10 分钟,或者 code 已经被兑换过,例如用户按了"后退"键或回调被请求了两次。该 code 无法恢复,请重新发起登录流程以获取新的 code。

GitHub OAuth 中出现 redirect_uri_mismatch 的原因是什么?

当你发送的 redirect_uri 与 OAuth 应用中注册的回调 URL 不匹配时,GitHub 会返回 redirect_uri_mismatch。请准确发送已注册的 URL,或者省略该参数,此时 GitHub 会使用应用的默认回调地址。授权请求和令牌请求中应发送相同的值。GitHub 将 127.0.0.1 和 localhost 视为不同的主机,因此请注册本地服务器实际使用的那一个。

用户退出我的应用后,其 GitHub 令牌会被撤销吗?

不会。结束你自己的会话后,GitHub 令牌和授权依然有效。要撤销单个令牌,请调用 DELETE /applications/{client_id}/token。要移除该用户的所有令牌以及应用授权,请调用 DELETE /applications/{client_id}/grant。这两个端点都使用以 client_id 和 client_secret 进行的 Basic 认证,并在请求体中携带 access_token。用户也可以在其 GitHub 账号设置中撤销访问权限。

永不过期的 GitHub OAuth 令牌也会失效吗?

会。GitHub 会自动撤销连续一年未使用的 OAuth 令牌。当用户撤销你的应用授权,或令牌被推送到公开仓库或 gist 时,令牌也会失效。如果同一个"用户、应用和权限范围"组合累积了超过 10 个令牌,GitHub 会撤销其中一个较旧的令牌。当存储的令牌失效时,请让用户重新走一遍登录流程。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.