如何集成 GitHub 登录
从 GitHub OAuth Web application flow 出发,说明如何用服务端完成授权码换 token、校验 state,并创建本站登录态。
很多网站不再自建完整的用户名密码系统,而是接入第三方登录。对开发者工具、技术社区、开源项目后台这类产品来说,GitHub 登录是很常见的选择。
不过“接入 GitHub 登录”容易被误解成前端拿到 GitHub access token 后存在浏览器里。这个做法能跑通 demo,但不适合作为默认实践。
更稳妥的结构是:浏览器只负责跳转和接收回调,服务端负责校验 state、用授权码换取 token、向 GitHub 拉取用户身份,然后创建自己系统的登录态。前端拿到的是本站 session,而不是 GitHub token。
English version: How to Implement Login with GitHub Safely
OAuth 流程怎么分工
GitHub OAuth App 的 Web application flow 大致是三步:
- 用户从你的站点跳转到 GitHub 授权页。
- GitHub 授权后带着临时
code和state跳回你的回调地址。 - 服务端用
code换取 access token,再用 token 请求 GitHub API 获取用户身份。
这里最关键的是两点:
client_secret只能放在服务端。state必须校验,用来防止 CSRF 和错误会话串联。
如果只是做“用 GitHub 身份登录本站”,OAuth App 已经够用。如果需要更细粒度的仓库权限、安装到组织、以应用身份执行自动化任务,就应该优先评估 GitHub App。
创建 GitHub OAuth App
在 GitHub 里进入:
Settings -> Developer settings -> OAuth Apps -> New OAuth App
需要填写几个关键字段:
Application name:应用名称。Homepage URL:站点首页地址。Authorization callback URL:授权回调地址,比如https://example.com/auth/github/callback。
创建完成后会得到:
Client ID:可以出现在授权 URL 里。Client Secret:必须只保存在服务端,通常放在环境变量或密钥管理系统里。
本地开发时可以把 callback 配成:
http://localhost:3000/auth/github/callback
线上环境要使用 HTTPS。
推荐架构
一个比较清晰的登录链路是:
浏览器点击 GitHub 登录
-> 服务端生成 state 和 code_verifier
-> 服务端把 state/code_verifier 写入 HttpOnly 临时 cookie 或 session
-> 服务端重定向到 GitHub 授权页
-> GitHub 回调 /auth/github/callback?code=...&state=...
-> 服务端校验 state
-> 服务端用 code 换 access token
-> 服务端请求 GitHub /user 和 /user/emails
-> 服务端创建或更新本地用户
-> 服务端写入本站 session cookie
-> 浏览器回到业务页面
这里也可以使用 PKCE。GitHub 文档已经把 code_challenge 和 code_verifier 标为强烈推荐。即使服务端应用已经有 client_secret,PKCE 仍然能降低授权码被截获后的风险。
发起授权请求
下面用 Express 写一个示例。真实项目里可以把临时数据放进 Redis、数据库 session 或加密 cookie。
import crypto from 'node:crypto';
import express from 'express';
import cookieParser from 'cookie-parser';
const app = express();
app.use(cookieParser());
const clientId = process.env.GITHUB_CLIENT_ID;
const clientSecret = process.env.GITHUB_CLIENT_SECRET;
const redirectUri = 'http://localhost:3000/auth/github/callback';
const isProduction = process.env.NODE_ENV === 'production';
function base64url(buffer) {
return buffer
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
function createCodeChallenge(verifier) {
return base64url(crypto.createHash('sha256').update(verifier).digest());
}
app.get('/auth/github/start', (req, res) => {
const state = base64url(crypto.randomBytes(32));
const codeVerifier = base64url(crypto.randomBytes(32));
const codeChallenge = createCodeChallenge(codeVerifier);
res.cookie('github_oauth_state', state, {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 10 * 60 * 1000,
});
res.cookie('github_oauth_code_verifier', codeVerifier, {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 10 * 60 * 1000,
});
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
scope: 'read:user user:email',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
res.redirect(`https://github.com/login/oauth/authorize?${params}`);
});
scope 不要贪多。只需要登录身份时,常见选择是:
read:user:读取基础用户资料。user:email:读取用户邮箱,尤其是主资料里的email为空时。
不要为了登录直接申请 repo 这类高权限 scope。权限越大,用户越警惕,token 泄露后的风险也越大。
处理 GitHub 回调
GitHub 回调时会带上 code 和 state。服务端必须先检查 state 是否和自己之前保存的一致,不一致就终止流程。
app.get('/auth/github/callback', async (req, res) => {
const { code, state } = req.query;
if (!code || !state) {
return res.status(400).send('Missing OAuth code or state');
}
if (state !== req.cookies.github_oauth_state) {
return res.status(400).send('Invalid OAuth state');
}
const tokenResponse = 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: clientId,
client_secret: clientSecret,
code: String(code),
redirect_uri: redirectUri,
code_verifier: req.cookies.github_oauth_code_verifier,
}),
});
const tokenData = await tokenResponse.json();
if (!tokenResponse.ok || tokenData.error) {
return res.status(401).json({
message: 'GitHub authorization failed',
error: tokenData.error,
});
}
const accessToken = tokenData.access_token;
const githubUser = await fetchGitHubUser(accessToken);
const emails = await fetchGitHubEmails(accessToken);
const primaryEmail =
emails.find((email) => email.primary && email.verified)?.email ??
githubUser.email;
const user = await upsertUserFromGitHub({
githubId: githubUser.id,
login: githubUser.login,
name: githubUser.name,
avatarUrl: githubUser.avatar_url,
email: primaryEmail,
});
const sessionId = await createSession(user.id);
res.clearCookie('github_oauth_state');
res.clearCookie('github_oauth_code_verifier');
res.cookie('session_id', sessionId, {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
});
res.redirect('/dashboard');
});
请求 GitHub API 时使用 Authorization: Bearer:
async function fetchGitHubUser(accessToken) {
const response = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github+json',
},
});
if (!response.ok) {
throw new Error('Failed to fetch GitHub user');
}
return response.json();
}
async function fetchGitHubEmails(accessToken) {
const response = await fetch('https://api.github.com/user/emails', {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github+json',
},
});
if (!response.ok) {
return [];
}
return response.json();
}
upsertUserFromGitHub() 和 createSession() 取决于自己的业务系统。常见做法是:
- 用 GitHub user id 绑定本地用户,而不是只用 login。login 可能改名,id 更稳定。
- 保存头像、昵称、邮箱等展示字段。
- 用自己的 session 或 JWT 管理本站登录态。
- GitHub token 如果后续不需要调用 GitHub API,就不要长期保存。
前端应该做什么
前端只需要把用户带到服务端的登录入口:
<a href="/auth/github/start">Continue with GitHub</a>
或者按钮点击后跳转:
document.querySelector('#github-login').addEventListener('click', () => {
window.location.href = '/auth/github/start';
});
前端不需要知道 client_secret,也不应该把 GitHub access token 存进 localStorage。浏览器侧只持有本站自己的登录态 cookie。
常见坑
没有校验 state
state 是 OAuth 登录里最容易被省略、也最不该省略的字段。它应该是不可猜测的随机字符串,并且和当前登录发起方绑定。回调时如果不一致,流程必须终止。
把 token 返回给前端
GitHub access token 代表用户授权。把它返回给前端并存入 localStorage,会扩大 XSS 后的损失。除非是纯前端应用且做了专门设计,否则更推荐服务端持有 token,并给浏览器发本站 session。
用 login 当唯一身份
GitHub 用户名可以修改。数据库绑定用户时应该优先使用 GitHub user id。
scope 申请过大
登录通常不需要仓库权限。权限申请越大,授权页面越吓人,也越难通过用户信任。
忽略邮箱为空
GitHub 用户资料里的 email 可能为空。需要邮箱时,要通过 user:email scope 调 /user/emails,并优先选择已验证的主邮箱。
总结
GitHub 登录的核心不是在前端拼一个授权 URL,而是把 OAuth 的安全边界放对:
- 前端负责跳转。
- 服务端保存
client_secret。 state用来绑定登录请求和回调。- 授权码在服务端换 token。
- token 用来向 GitHub 确认用户身份。
- 本站登录态由自己的 session 系统管理。
这样接入后,GitHub 只是身份提供方,真正的账号体系仍然掌握在自己的应用里。