要添加 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 风险之下——这种攻击会让受害者登录到攻击者的账号中:
- 攻击者使用自己的 GitHub 账号发起你应用的 GitHub 登录流程。
- 攻击者批准授权,但在回调执行前停下,保留下有效的
code。 - 攻击者设法让受害者的浏览器加载
https://yourapp.com/auth/github/callback?code=ATTACKER_CODE,例如通过隐藏图片或自动跳转链接。 - 你的服务器用该 code 换取令牌,并以攻击者的身份为受害者完成登录。更糟的是,如果受害者此前已经登录,服务器会把攻击者的 GitHub 身份关联到受害者的账号上。
- 此后受害者保存的任何内容都会落入攻击者控制的账号。而关联的身份还能让攻击者日后以受害者身份登录。
有了 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和 PKCES256,并在回调时对两者进行校验。 - 授权请求与令牌请求中发送相同的
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 会撤销其中一个较旧的令牌。当存储的令牌失效时,请让用户重新走一遍登录流程。