「Googleでログイン」を自作したら別人のアカウントに入れた話。認可コードフロー+PKCEの正しい組み方
「Googleでログイン」を自前実装する手順を、認可コードフロー・PKCE・stateでのCSRF対策・トークンの保存場所まで、コピペで動くコードと失敗談つきで整理しました。
「Googleでログイン」を初めて自作したとき、僕はテスト中に他人のアカウントへ入れてしまいました。
別タブでログインを途中まで進めて、放置して、別のアカウントで入り直す。その状態でコールバックが戻ってくると、最初のタブのセッションに、あとから入ったアカウントが紐づく。手元では「あれ、名前が違う」で気づきましたが、これが本番で起きたら立派な乗っ取りです。
原因は単純で、stateを発行はしていたのに、戻ってきた値と照合していなかったんです。OAuthは「ボタンを置けば終わり」に見えて、ボタンの裏で交わされる“取引”を守るのが本体でした。
この記事は、その取引の守り方に絞って書きます。軸は、いま標準になりつつある 認可コードフロー + PKCE。Googleログインの実例と、外部登録なしで丸ごと動くデモを置きました。なお、セッションかJWTか・パスワードのハッシュ化・Cookie属性といった「ログイン認証ぜんぱん」の土台は、姉妹記事のWeb認証をセッション・bcrypt・Cookieで固めるにまとめてあります。この記事はOAuthフローそのものに集中します。
この記事の要点
- OAuthの主役は「認可コードフロー」。トークンを画面に晒さず、サーバー裏で安全に受け取る流れだ。
- PKCEは「認可コードを盗まれても、最初に始めた本人しか交換できない」ようにする鍵。SPAやモバイルでは必須、サーバーありでも今は入れる前提でいい。
stateは発行するだけでなく必ず照合する。これが抜けると別タブ・別人のログインを差し込まれる(=CSRF)。- アクセストークンとリフレッシュトークンは
localStorageに置かない。サーバー側セッションか暗号化保存にする。 - Claude Codeに任せるなら、実シークレットは渡さず「変数名・期待する形・失敗系テスト」を指定する。
認可コードフローを、宅配便で考える
専門用語が多いので、まず流れを身近な例にします。OAuthの認可コードフロー(Authorization Code Flow)は、宅配便の「置き配の暗証番号」に似ています。
- あなた(アプリ)はGoogleに「この人の身元を確認して」と送り出す。
- ユーザーはGoogleの画面でログインし、同意する。
- Googleはユーザーのブラウザ経由で、アプリに**短い引換券(認可コード)**だけを渡す。
- アプリは裏側(サーバー間通信)で、その引換券を本物のトークンと交換する。
ポイントは、ブラウザのURLに流れるのは「引換券」だけで、本物のトークンは人目につかないサーバー裏で受け取ること。昔よく使われたImplicit Flow(トークンを直接URLで返す方式)は、この引換券のステップを飛ばしてトークンをむき出しでブラウザに渡していたので、いまは非推奨です。
| 用語 | やさしい言い換え | 役割 |
|---|---|---|
| 認可コード | 短命の引換券 | これ単体ではAPIを叩けない。トークンと交換する |
| アクセストークン | 入館パス | APIを呼ぶ権限。期限が短い |
| リフレッシュトークン | パスの再発行券 | 期限切れの入館パスを裏で作り直す。長寿命なので厳重に保管 |
| IDトークン(OIDC) | 身分証 | 「誰がログインしたか」を表す。APIの権限ではない |
state | 自分で書いた合言葉 | 戻ってきた取引が自分の始めたものか確認する |
| PKCE | 引換券に紐づく封印 | 引換券を盗まれても本人以外は交換できなくする |
PKCEは「割符」だと思うと腹落ちする
PKCE(ピクシーと読む)は、認可コードの盗難対策です。仕組みは時代劇の割符そのものです。
ログインを始めるとき、アプリは手元で長いランダム文字列 code_verifier を作ります。これは秘密。次にそれをSHA-256でハッシュ化した code_challenge を、認可リクエストに同梱して送り出します。割符の片割れだけを先に渡すイメージです。
最後にトークンと交換するとき、アプリは隠していた code_verifier(もう片方の割符)を出します。サーバーは「ハッシュ化したら最初の code_challenge と一致するか」を確かめる。一致しなければ拒否。
これで何が嬉しいか。仮に攻撃者が認可コードを横取りしても、code_verifier を知らないので交換できません。SPAやモバイルアプリはクライアントシークレットを安全に隠せない「公開クライアント」なので、PKCEが事実上の必須。サーバーありのWebアプリでも、最初からPKCEを入れておくとレビューが楽になります。code_challenge_method は必ず S256 にしてください。plain(ハッシュなし)は割符を平文で渡すのと同じで、入れる意味が薄いです。
Googleログインを自作するときの実値
「Googleでログイン」を自前で組むときに使う、公式の本物の値がこれです(Google公式: OpenID Connectで確認できます)。
// Googleの実エンドポイント(公式値)
const GOOGLE = {
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
tokenEndpoint: "https://oauth2.googleapis.com/token",
// 鍵の検証や各種URLは、この discovery から取るのが安全
discovery: "https://accounts.google.com/.well-known/openid-configuration",
};
// 認可画面へ飛ばすURLの組み立て(state と PKCE 同梱)
function buildGoogleAuthUrl(opts: {
clientId: string;
redirectUri: string; // Cloud Consoleに完全一致で登録した値
state: string;
nonce: string;
codeChallenge: string;
}) {
const params = new URLSearchParams({
client_id: opts.clientId,
redirect_uri: opts.redirectUri,
response_type: "code", // 認可コードフロー
scope: "openid email profile",
state: opts.state, // CSRF対策の合言葉
nonce: opts.nonce, // IDトークン再放送対策
code_challenge: opts.codeChallenge, // PKCEの割符(公開する片割れ)
code_challenge_method: "S256", // 必ずS256
access_type: "offline", // リフレッシュトークンが欲しい場合
prompt: "consent",
});
return `${GOOGLE.authorizationEndpoint}?${params}`;
}
client_id とリダイレクトURIはGoogle Cloud Consoleで発行・登録します。リダイレクトURIは完全一致で登録するのが鉄則で、ここを前方一致やドメイン一致にすると差し替え攻撃の入口になります。client_secret はサーバーの環境変数(.env.localやSecret Manager)に置き、コードやチャットに貼らないこと。Claude Codeに依頼するときも、渡すのは変数名だけです。
コピペで動くローカルOAuth + PKCEデモ
「Googleで試すのは登録が面倒」という人向けに、外部登録なしで丸ごと動く最小デモを置きます。1つのExpressアプリの中に「OAuthクライアント」と「モック認可サーバー」を同居させてあります。本番コードではありませんが、state照合・nonce照合・PKCE S256・リダイレクトURI完全一致・認可コードの一回使い切り・トークンのサーバー側保存を、自分の目で動かして確認できます。
空のフォルダで次の2ファイルを作り、npm install && npm start、ブラウザで http://localhost:3000 を開いてください。
{
"scripts": { "start": "node server.mjs" },
"dependencies": { "express": "^4.19.2", "express-session": "^1.18.0" },
"engines": { "node": ">=20" }
}
// server.mjs
import crypto from "node:crypto";
import express from "express";
import session from "express-session";
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(session({
name: "oauth_demo_sid",
secret: "dev-only-change-this-32-byte-secret",
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, sameSite: "lax", secure: false, maxAge: 10 * 60 * 1000 },
}));
const client = {
clientId: "claude-code-demo",
redirectUri: "http://localhost:3000/callback",
scope: "openid profile email",
};
const authorizationEndpoint = "http://localhost:3000/mock/authorize";
const tokenEndpoint = "http://localhost:3000/mock/token";
// リダイレクトURIは「完全一致の許可リスト」で持つ
const registeredRedirectUris = new Set([client.redirectUri]);
const pendingCodes = new Map();
function randomUrlSafe(bytes = 32) {
return crypto.randomBytes(bytes).toString("base64url");
}
function sha256Base64Url(value) {
return crypto.createHash("sha256").update(value).digest("base64url");
}
function fail(res, status, message) {
return res.status(status).type("text/plain").send(message);
}
app.get("/", (_req, res) => {
res.type("html").send(`<h1>OAuth PKCE ローカルデモ</h1><p><a href="/auth/login">ログイン開始</a></p>`);
});
// ログイン開始:state / nonce / code_verifier をセッションに隠す
app.get("/auth/login", (req, res) => {
const state = randomUrlSafe();
const nonce = randomUrlSafe();
const codeVerifier = randomUrlSafe(48); // 秘密の割符。外には出さない
const codeChallenge = sha256Base64Url(codeVerifier); // 公開する片割れ
req.session.oauth = { state, nonce, codeVerifier, createdAt: Date.now() };
const params = new URLSearchParams({
response_type: "code",
client_id: client.clientId,
redirect_uri: client.redirectUri,
scope: client.scope,
state,
nonce,
code_challenge: codeChallenge, // challengeだけ送る。verifierは送らない
code_challenge_method: "S256",
});
res.redirect(`${authorizationEndpoint}?${params}`);
});
// モック認可サーバー:本来はGoogle側の処理
app.get("/mock/authorize", (req, res) => {
const p = req.query;
const redirectUri = String(p.redirect_uri || "");
if (p.response_type !== "code") return fail(res, 400, "response_type は code 必須");
if (p.client_id !== client.clientId) return fail(res, 400, "未知の client_id");
if (!registeredRedirectUris.has(redirectUri)) return fail(res, 400, "redirect_uri が完全一致で登録されていない");
if (p.code_challenge_method !== "S256") return fail(res, 400, "PKCE は S256 のみ許可");
if (!p.code_challenge || !p.state || !p.nonce) return fail(res, 400, "state / nonce / PKCE challenge が足りない");
const code = randomUrlSafe(24); // 短命の引換券
pendingCodes.set(code, {
clientId: client.clientId,
redirectUri,
codeChallenge: String(p.code_challenge),
nonce: String(p.nonce),
expiresAt: Date.now() + 60_000, // 認可コードは短命に
used: false,
});
const redirect = new URL(redirectUri);
redirect.searchParams.set("code", code);
redirect.searchParams.set("state", String(p.state)); // stateはそのまま戻す
res.redirect(redirect.toString());
});
// コールバック:戻ってきた state を必ず照合してから交換する
app.get("/callback", async (req, res) => {
const oauth = req.session.oauth;
const code = String(req.query.code || "");
const returnedState = String(req.query.state || "");
if (!oauth) return fail(res, 400, "OAuthセッションがない");
if (returnedState !== oauth.state) return fail(res, 403, "state 不一致:CSRFか別タブ混入の疑い");
const response = await fetch(tokenEndpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: client.redirectUri,
client_id: client.clientId,
code_verifier: oauth.codeVerifier, // ここで初めて秘密の割符を出す
}),
});
const tokens = await response.json();
if (!response.ok) return fail(res, response.status, JSON.stringify(tokens, null, 2));
if (tokens.nonce !== oauth.nonce) return fail(res, 403, "nonce 不一致:再放送の疑い");
req.session.oauth = undefined; // 使い終わった一時データは消す
req.session.tokenSet = {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: Date.now() + tokens.expires_in * 1000,
};
res.redirect("/dashboard");
});
// モックのトークン交換:割符を照合してから発行する
app.post("/mock/token", (req, res) => {
const body = req.body;
const record = pendingCodes.get(body.code);
if (body.grant_type !== "authorization_code") return res.status(400).json({ error: "unsupported_grant_type" });
if (!record || record.used || record.expiresAt < Date.now()) return res.status(400).json({ error: "invalid_grant" });
if (body.client_id !== record.clientId) return res.status(400).json({ error: "invalid_client" });
if (body.redirect_uri !== record.redirectUri) return res.status(400).json({ error: "invalid_redirect_uri" });
// 送られた code_verifier をハッシュ化し、保存済み code_challenge と突き合わせる
if (sha256Base64Url(body.code_verifier || "") !== record.codeChallenge) {
return res.status(400).json({ error: "invalid_code_verifier" });
}
record.used = true; // 認可コードは一回使い切り
res.json({
token_type: "Bearer",
access_token: randomUrlSafe(32),
refresh_token: randomUrlSafe(32),
expires_in: 300,
nonce: record.nonce,
});
});
app.get("/dashboard", (req, res) => {
const tokenSet = req.session.tokenSet;
if (!tokenSet) return res.redirect("/auth/login");
const secondsLeft = Math.max(0, Math.floor((tokenSet.expiresAt - Date.now()) / 1000));
res.type("html").send(`<h1>ログイン成功</h1><p>アクセストークンは localStorage ではなくサーバー側に保存しています。</p><p>残り ${secondsLeft} 秒で失効します。</p>`);
});
app.listen(3000, () => console.log("http://localhost:3000 を開いてください"));
動かしたら、ぜひ壊しにいってください。ブラウザのアドレスバーで state を1文字だけ書き換えてコールバックすると state 不一致 で止まります。デモコードの code_verifier を別の値に差し替えると invalid_code_verifier で交換が拒否されます。同じ認可コードでもう一度トークン交換すると invalid_grant になります。この「ちゃんと止まる」体験が、頭で読むより一番効きます。
stateでのCSRF対策を、誤解なく
冒頭の事故がまさにこれでした。state は 自分で書いた合言葉です。ログイン開始時にランダム値を作ってセッションに保存し、認可リクエストに同梱する。戻ってきたら、保存した値と完全一致するか照合し、一致したら使い、すぐ削除する。
これを照合しないと、攻撃者が自分のログインを途中まで進め、その認可コード入りURLを被害者に踏ませる「ログインCSRF」が成立します。被害者は気づかないまま攻撃者のアカウントにログインさせられ、そこに入力したデータが攻撃者の手に渡る。state 照合は、この「他人の取引を自分の取引にすり替える」攻撃を弾く一行です。
複数タブで同時にログインする可能性があるなら、state をキーにして短時間だけ複数のトランザクションを保持する設計にします。1つの固定値を使い回すと、タブ間で取引が混ざります。
トークンはどこに置くか問題
「ログインできた後、トークンをどこに保存するか」で事故が多発します。先に答えを言うと、localStorage に長寿命トークンを置くのはやめてください。
localStorage はJavaScriptから誰でも読めます。XSS(スクリプト混入)を1つ食らった瞬間、保存していたリフレッシュトークンごと持っていかれます。リフレッシュトークンは「入館パスの再発行券」なので、これを盗まれると長期間なりすませます。
| 保存先 | 向き不向き | コメント |
|---|---|---|
| サーバー側セッション | ◎ 第一候補 | トークンはサーバーに置き、ブラウザにはセッションIDのCookieだけ渡す |
| 暗号化したDB | ◎ リフレッシュトークン向き | 失効処理・退会時破棄とセットで設計する |
| HttpOnly Cookie | ○ | JSから読めない。Secure / SameSite を必ず付ける |
localStorage | × | XSSで丸ごと抜かれる。長寿命トークンは厳禁 |
Cookieに寄せるなら属性設計が要になります。HttpOnly・Secure・SameSite の決め方はNext.js認証でのCookie管理に細かくまとめてあります。
僕がやらかしたOAuthの失敗3つ
正直に書きます。最初のOAuth実装は穴だらけでした。
ひとつ目は、冒頭の state照合忘れ。発行して満足して、戻り値をチェックしていなかった。テストで別タブを開いた瞬間に他人として入れて、血の気が引きました。いまは「発行・保存・照合・削除」を必ずワンセットでレビューします。
ふたつ目は、リダイレクトURIを前方一致で許可したこと。開発・プレビュー・本番でURIが増えてきて、面倒になり「とりあえず広めに」と前方一致にした。https://app.example.com.evil.test/callback のような文字列が紛れ込む余地を、自分で作っていました。いまは環境ごとにクライアントを分け、完全一致だけにしています。
みっつ目は、デバッグで code や code_verifier をログに出したこと。障害調査のつもりが、CIログと監視ツールに秘密に近い値を撒き散らしていました。いまはログに残すのはイベント名・request id・失敗理由の種類だけ。トークンや認可コードそのものは絶対に出しません。
Claude Codeに任せるなら、渡し方を絞る
OAuthをClaude Codeに頼むときは、プロバイダー名だけでなく、保存場所・失敗時の扱い・テストまで指定します。曖昧に「OAuthログインを実装して」と書くと、stateが抜けたり、トークンがブラウザ側に寄ったり、レビューしづらい巨大な差分になります。実シークレットは渡さず、変数名と期待する形だけを渡すのが原則です。
Express + TypeScript で OAuth 認可コードフロー + PKCE を実装してください。
条件:
- プロバイダー値は環境変数名だけ定義し、実シークレットは書かない
- state / nonce / code_verifier はサーバー側セッションに保存する
- redirect_uri は設定値と完全一致で検証する
- code_challenge_method は S256 のみ許可する
- アクセストークン・リフレッシュトークンは localStorage に保存しない
- リフレッシュトークンは暗号化保存またはサーバー側セッションに限定する
- コールバック / state不一致 / PKCE不一致 / 期限切れコード / コード再利用 のテストを追加する
- 最後にセキュリティレビュー観点を箇条書きで出す
レビュー専用には、公式仕様に照らさせる追加指示が効きます。
このOAuth実装を RFC 7636(PKCE) と OWASP の観点でレビューしてください。
秘密情報がログ・テストスナップショット・フロントエンドバンドル・Git差分に
漏れていないかも確認し、重大度を High / Medium / Low で分けて修正案を出してください。
Claude Codeの基本操作が不安ならClaude Code入門ガイドを、ログインできた後の権限分けはClaude Code RBAC実装を合わせて読むと、認証から認可までひと続きで設計できます。
よくある質問
Q. OAuthとOpenID Connect(OIDC)って何が違うんですか?
OAuthは「APIを呼ぶ権限の委任」、OIDCはその上に乗る「本人確認」の仕組みです。「Googleでログイン」のように“誰がログインしたか”を知りたいならOIDCで、scopeにopenidを入れてIDトークンを受け取ります。IDトークン(身分証)とアクセストークン(入館パス)は役割が別物なので、混ぜないでください。
Q. サーバーありのWebアプリでもPKCEは要りますか?
入れてください。昔は「公開クライアントだけ必須」でしたが、いまはサーバーありでも入れる前提が標準的です。S256を足すコストは小さく、認可コード横取りへの保険になります。
Q. nonceとstateは何が違うんですか?
stateは「この取引は自分が始めたか」をブラウザ往復で確認する合言葉(CSRF対策)。nonceは「受け取ったIDトークンが、今回のログイン用に新しく発行されたものか」を確認する値(古いトークンの再放送対策)です。OIDCでIDトークンを使うなら両方使います。
Q. アクセストークンの期限が切れたらどう更新しますか? リフレッシュトークンを使って、サーバー裏で新しいアクセストークンを取り直します。リフレッシュトークンは長寿命で危険なので、暗号化保存し、退会時やログアウト時に必ず失効させます。フロントには出しません。
Q. IDトークンの検証はどこまでやればいいですか?
署名の検証に加えて、iss(発行者)、aud(自分のクライアントID向きか)、exp(期限)、そしてnonceを照合します。Googleなら、鍵やエンドポイントは.well-known/openid-configurationから取得すると、鍵のローテーションにも追従できます。
実際に試した結果
このデモは、ログイン開始 → モック認可 → コールバック → トークン交換 → ダッシュボード表示まで、ローカルで通る前提で書いています。実際に試すと、stateを1文字いじっただけで state 不一致 で止まり、code_verifier を差し替えると invalid_code_verifier になります。この「壊そうとすると、ちゃんと止まる」感触が得られたら、もう半分は理解できています。
冒頭の“他人のアカウントに入れた事故”以来、僕がOAuthのレビューで最初に見るのは、整った正常系の画面ではありません。ログイン開始時に何をセッションに隠したかです。state・nonce・code_verifierが、同じブラウザの同じ短い時間枠に結びついていて、使い終わったら消えている。ここさえ崩れていなければ、コールバックだけ別の取引に差し替えられる余地は、ぐっと小さくなります。賢い実装を探すより、転んでもケガしない取引設計を先に作る。これがいちばん速い、というのが今の実感です。
テンプレートや実務向けの教材が必要なら教材・テンプレート一覧を、チームでOAuth・OIDC・トークン管理・失効処理まで一気に整えたいなら研修・導入相談をどうぞ。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。