Sign in with GitHub を実装する方法
NodeとExpressでGitHubログインを追加。stateとPKCEを使ったOAuthフロー、安全なコード交換、認証済みメールの取得、トークン更新を解説します。
Sign in with GitHub を実装するには、ユーザーを https://github.com/login/oauth/authorize にリダイレクトし、コールバック URL で一時的な code を受け取り、サーバーから https://github.com/login/oauth/access_token に POST してその code をアクセストークンと交換します。
一方のタブで OAuth App の設定画面を、もう一方のタブでルートハンドラーを開いている状況でも、多くのチュートリアルは処理全体を SDK に任せてしまいます。本ガイドでは、OAuth ライブラリを使わず、Node 上で fetch と Express を使ってサーバー側のフローを構築します。基盤となるモデルについては All about OAuth 2.0 を参照してください。本記事では、state と PKCE、メールアドレスが null になる問題、スコープ、そして GitHub が 2026 年 8 月 14 日にリリースしたリダイレクト URI とトークンリフレッシュに関する変更について解説します。
重要なポイント
- GitHub の認可コードは発行から 10 分で失効します。また、コード交換にはクライアントシークレットが必要なため、必ずサーバー側で実行する必要があります。
stateはサーバーで生成して httpOnly Cookie に保存し、返ってきた値が異なる場合はコールバック処理を中止します。これによりログイン CSRF を防げます。- ユーザーが公開メールアドレスを設定していない場合、
GET https://api.github.com/userはemail: nullを返します。そのためuser:emailスコープを要求し、GET /user/emailsからプライマリかつ検証済みのエントリを使用します。 - GitHub の 2026 年 8 月 14 日のアップデート以降、1 つの OAuth App に最大 10 件のリダイレクト URI を登録できるようになり、新規アプリではデフォルトで有効期限 8 時間のアクセストークンと有効期限 6 か月のリフレッシュトークンが発行されます。
GitHub OAuth App を登録する
Settings → Developer settings → OAuth Apps から OAuth App を作成し、クライアント ID をコピーしてクライアントシークレットを生成します。各フィールドの詳細は GitHub の登録ガイドに記載されています。
2026 年 8 月 14 日のリリース以降、1 つの OAuth App に最大 10 件のリダイレクト URI を登録できるようになりました。追加は Add callback URL ボタンから行います。これにより、本番環境、プレビューデプロイ、ローカル開発を 1 つのアプリでまかなえます。ローカル開発用には http://127.0.0.1:3000/auth/github/callback を登録してください。localhost でも動作しますが、GitHub のループバックに関するガイダンスでは、OAuth の RFC が 127.0.0.1 リテラルを推奨していることが指摘されています。
ワイルドカードマッチングは、リダイレクト URI ごとの設定項目になりました。2026 年 8 月 3 日より前にコールバック URL が 1 件のみだったアプリについては、従来のリダイレクト動作を維持するため、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 が存在しないか、攻撃者の値と一致しない Cookie しかないため、サーバーはリクエストを拒否します。PKCE はさらにもう一段階の紐付けを加えます。code は、フローを開始したブラウザに保存された code_verifier と組み合わせた場合にのみ機能します。RFC 6749 は state をまさにこの目的のために定義しており、OAuth 2.0 Security Best Current Practice はすべてのクライアントに PKCE を推奨しています。より詳しくは、Securing OAuth best practices の 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 が 1 つだけの場合、2 つ目のタブでサインインを開始すると 1 つ目の値が上書きされます。そのため、何も表示しないエラーではなく、再試行を促す明確なメッセージを表示するようにしてください。
コールバックを処理して code を交換する
コールバックでは state を検証し、一時的な Cookie を削除したうえで、Accept: application/json を指定して code を https://github.com/login/oauth/access_token に POST します。このヘッダーがない場合、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('/');
});
この交換処理にはクライアントシークレットが必要であり、シークレットは決してブラウザに渡してはならないため、必ずサーバー側で実行してください。交換が成功したら、独自のセッション Cookie を発行し、GitHub のトークンはサーバー側で保持します。保存方法のトレードオフについては Cookies vs localStorage for JWT auth で解説しています。最後の 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 は新しいトークンを取得するたびにユーザーを確認することを推奨しています。
必要最小限のスコープを要求する
Sign in with GitHub に必要な OAuth スコープは read:user と user:email の 2 つだけです。各スコープの定義は GitHub のスコープリファレンスに記載されており、要求したスコープはすべて GitHub の同意画面でユーザーに表示されます。
| 必要なもの | スコープ |
|---|---|
| プロフィール:id、login、name、avatar | read:user |
| メールアドレス | user:email |
| リポジトリのコンテンツ | repo(プライベートリポジトリを含むフルアクセス) |
サインインのために repo を要求すると、ユーザーに非公開コードへの読み書きアクセスを委ねるよう求めることになり、多くのユーザーがその時点で離脱してしまいます。後の機能でリポジトリへのアクセスが必要になった場合は、そのタイミングで別のフローを実行してください。GitHub では、ユーザー・アプリ・スコープの組み合わせごとにトークンは最大 10 個まで、新規トークンの発行は 1 時間あたり 10 個までに制限されています。コールバックのループによってこの 1 時間あたりの上限に達すると、GitHub は処理を停止し、ユーザーに再認可のプロンプトを表示します。
有効期限付きトークンをリフレッシュする
GitHub のトークンをリフレッシュするには、トークンエンドポイントに grant_type=refresh_token を POST します。リフレッシュトークンを使用すると、そのリフレッシュトークン自体と古いアクセストークンの両方が無効になるため、次の呼び出しを行う前に新しいペアを保存してください。有効期限付きトークンを有効にしている場合、アクセストークンは 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 App と GitHub App のどちらを使うべきか
GitHub は一般的に OAuth App よりも GitHub App を推奨していますが、Sign in with GitHub のみが目的であれば、read:user user:email を要求する OAuth App で十分です。詳しい違いは GitHub の比較ページで確認できます。
| OAuth App | GitHub App | |
|---|---|---|
| 権限モデル | 広範なスコープ | きめ細かな権限 |
| リポジトリの選択 | スコープが対象とするすべてのリポジトリ | ユーザーがリポジトリを選択 |
| トークンの有効期間 | 新規アプリはデフォルトで 8 時間で失効。既存アプリは有効化しない限り長期間有効 | 短期間 |
| GitHub の推奨 | サポート対象 | 推奨 |
プロダクトがリポジトリに対して操作を行う場合は GitHub App を選択してください。アイデンティティの確認が目的であれば、上記の OAuth App フローで十分です。
セキュリティチェックリスト
- すべてのリクエストで
stateと PKCES256を送信し、コールバックで両方を検証する。 - 認可リクエストとトークンリクエストで同じ
redirect_uriを送信する。 - 不要なワイルドカードマッチングを無効にする。
- クライアントシークレットと GitHub のトークンはサーバー側で保持する。ブラウザに渡すのは独自のセッション Cookie のみとする。
- 交換処理の後、コールバックから別のページへリダイレクトする。
refresh_tokenを含まないトークンレスポンスに対応する。
Sign in with GitHub は、認可、交換、ユーザー情報の取得という 3 つのリクエストに集約されます。セキュリティを左右するのは、それらを取り巻く細部です。すなわち、サーバーが保持する state、PKCE の verifier、バックエンドから決して外に出ないシークレット、そして検証済みであることを確認したメールアドレスです。次のステップとして、OAuth App の設定を開き、プレビュー環境と 127.0.0.1 のコールバック URL を追加し、ワイルドカードマッチングを無効にしたうえで、上記の 3 つのルートをリリースしましょう。
よくある質問
GitHub のトークンエンドポイントが bad_verification_code を返すのはなぜですか?
GitHub は、送信した code が誤っている、失効している、または認可リクエストで発行されたものと一致しない場合に bad_verification_code を返します。よくある原因は、発行から 10 分以上経過した code や、ブラウザの「戻る」操作やコールバックへの 2 回目のリクエストなどによってすでに交換済みの code です。その code を復旧することはできません。サインインフローを最初からやり直して新しい code を取得してください。
GitHub OAuth で redirect_uri_mismatch が発生する原因は何ですか?
GitHub は、送信した redirect_uri が OAuth App に登録されたコールバック URL と一致しない場合に 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 は、1 年間使用されていない OAuth トークンを自動的に失効させます。また、ユーザーがアプリの認可を取り消した場合や、トークンがパブリックリポジトリや gist にプッシュされた場合にも、トークンは使えなくなります。1 つのユーザー・アプリ・スコープの組み合わせで 10 個を超えるトークンが発行された場合、GitHub は古いトークンのうちの 1 つを失効させます。保存しているトークンが使えなくなった場合は、ユーザーに再度サインインしてもらってください。