Use Cases (更新: 2026/6/2)

Claude CodeでOAuth実装:PKCE・state・安全なトークン管理の実践ガイド

Claude CodeでOAuth 2.1/2.0認可コード+PKCEを安全に実装。state、nonce、保存、レビューまで解説。

Claude CodeでOAuth実装:PKCE・state・安全なトークン管理の実践ガイド

OAuthログインは「Googleでログイン」ボタンを置けば終わり、ではありません。実装を少し間違えるだけで、別ユーザーのアカウントに紐づく、認可コードが再利用される、リダイレクト先を差し替えられる、トークンがブラウザ拡張やXSSから読まれる、といった事故につながります。

この記事では、Claude CodeにOAuth実装を任せるときの依頼方法とレビュー観点を、初心者にも追える形で整理します。軸はOAuth 2.1で標準になりつつある「Authorization Code + PKCE」です。OAuth 2.0でも、現在は同じ考え方を採用するのが実務上の基本です。PKCEは「認可コードを盗まれても、最初にログインを始めたクライアントだけが交換できるようにする仕組み」と考えると理解しやすいです。

Claude Codeの基本操作はClaude Code入門ガイド、認証全体の設計はClaude Codeで認証実装を安全に進める実践ガイド、API側の境界設計はClaude Codeで本番API開発も合わせて確認してください。

まず結論:任せる範囲を分ける

Claude Codeは、OAuthの足場、つまりエージェントが安全に作業するための土台を作るのに向いています。ルート、セッション、環境変数、テスト、レビュー観点をまとめて整える作業は得意です。一方で、クライアントシークレット、実際のリフレッシュトークン、本番のプロバイダー管理画面にある値は貼り付けないでください。Claude Codeには変数名と期待する形だけを渡し、実値は.env.localやクラウドのSecret Managerで扱います。

項目推奨理由
フローAuthorization Code + PKCEImplicit Flowより安全で、現在の推奨に合う
CSRF対策stateをセッションに保存して照合他サイトからログイン完了だけを差し込まれる事故を防ぐ
OIDC再放送対策nonceを発行してIDトークンで照合古い認証結果の使い回しを検知する
redirect URI完全一致の許可リストhttps://evil.example/callbackのような差し替えを防ぐ
トークン保存サーバー側セッションか暗号化DBlocalStorageに長寿命トークンを置かない
Claude Code入力ダミー値、仕様、テスト条件だけ秘密情報をチャットやログに残さない

公式情報はOAuth 2.1PKCE RFC 7636OAuth 2.0 Security BCP RFC 9700OpenID Connect CoreOWASP OAuth2 Cheat SheetClaude Code Securityを基準にします。

実務で多いユースケース

1つ目は、B2B SaaSの管理画面です。Google WorkspaceやMicrosoft Entra IDでログインさせ、アプリ内では自社のユーザーID、組織ID、ロールに変換します。ここで大事なのは「OAuthでログインできた」ことと「このアプリで何ができるか」を分けることです。権限設計はClaude Code RBAC実装の領域です。

2つ目は、外部API連携です。カレンダー、メール、クラウドストレージ、CRMなどへのアクセスをユーザーから委任してもらいます。この場合、アクセストークンのスコープを最小にし、リフレッシュトークンは暗号化して保存し、連携解除時に破棄できるようにします。

3つ目は、社内ツールやMCPサーバーの認可です。開発者向けツールほど「とりあえずAPIキー」で始めがちですが、利用者が増えると失効、監査、権限分離が苦しくなります。OAuth/OIDCに寄せると、ログイン、同意、失効、監査の境界を整理しやすくなります。

4つ目として、モバイルアプリやSPAもあります。これらはクライアントシークレットを安全に隠せない「公開クライアント」です。だからこそPKCEが必須です。サーバーありのWebアプリでも、現在はPKCEを入れる前提で設計した方がレビューしやすくなります。

コピペで動くローカルOAuth + PKCEデモ

以下は、外部プロバイダーの登録なしで動く最小デモです。1つのExpressアプリの中に「OAuthクライアント」と「モック認可サーバー」を入れています。本番コードではありませんが、statenonce、PKCE S256、redirect URI完全一致、認可コードの一回利用、サーバー側セッション保存を目で確認できます。

空のフォルダで次を作り、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";
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 local demo</h1><p><a href="/auth/login">Start login</a></p>`);
});

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,
    code_challenge_method: "S256",
  });
  res.redirect(`${authorizationEndpoint}?${params}`);
});

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 must be code");
  if (p.client_id !== client.clientId) return fail(res, 400, "unknown client_id");
  if (!registeredRedirectUris.has(redirectUri)) return fail(res, 400, "redirect_uri is not registered exactly");
  if (p.code_challenge_method !== "S256") return fail(res, 400, "PKCE S256 is required");
  if (!p.code_challenge || !p.state || !p.nonce) return fail(res, 400, "missing state, nonce, or 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));
  res.redirect(redirect.toString());
});

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, "missing OAuth session");
  if (returnedState !== oauth.state) return fail(res, 403, "state mismatch: possible CSRF or mixed login attempt");

  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 mismatch: possible replay");

  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" });
  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>Logged in</h1><p>Access token is stored server-side, not in localStorage.</p><p>Expires in ${secondsLeft} seconds.</p>`);
});

app.listen(3000, () => console.log("Open http://localhost:3000"));

このデモで確認してほしい点は5つです。/auth/loginstatenoncecode_verifierをセッションに置くこと。認可リクエストにはcode_challengeだけを送ること。/callbackで戻ってきたstateを照合すること。/mock/tokencode_verifierから再計算した値を保存済みcode_challengeと比べること。そしてアクセストークンをブラウザのlocalStorageではなくサーバー側セッションに置くことです。

Claude Codeに渡すプロンプト

OAuthをClaude Codeに頼むときは、プロバイダー名だけでなく、保存場所、失敗時の扱い、テストまで指定します。曖昧に「OAuthログインを実装して」と書くと、stateが抜けたり、トークンがブラウザ側に寄ったり、レビューしづらい大きな差分になります。

Express + TypeScriptでOAuth 2.0 Authorization Code + PKCEを実装してください。
条件:
- プロバイダー値は環境変数名だけ定義し、実シークレットは書かない
- state、nonce、code_verifierはサーバー側セッションに保存する
- redirect_uriは設定値と完全一致で検証する
- code_challenge_methodはS256のみ許可する
- アクセストークンとリフレッシュトークンはlocalStorageに保存しない
- リフレッシュトークンは暗号化保存またはサーバー側セッションに限定する
- コールバック、state不一致、PKCE不一致、期限切れコードのテストを追加する
- 最後にセキュリティレビュー観点を箇条書きで出す

レビュー用には次の追加指示が効きます。

このOAuth実装をRFC 9700、RFC 7636、OWASP OAuth2 Cheat Sheetの観点でレビューしてください。
秘密情報がログ、テストスナップショット、フロントエンドバンドル、Git差分に出ないかも確認してください。
重大度をHigh/Medium/Lowで分類し、修正パッチを提案してください。

具体的な落とし穴

最も多い失敗は、redirect_uriを前方一致やドメイン一致で許可することです。https://app.example.com.evil.test/callbackのような文字列が紛れます。登録済みURIとの完全一致を基本にしてください。

次に多いのは、stateを発行しているのに検証していない実装です。ログイン開始時にセッションへ保存し、コールバックで一致確認し、使ったら削除します。複数タブでログインする可能性があるなら、stateをキーにして複数のトランザクションを短時間だけ持つ設計にします。

PKCEでも事故はあります。plain方式を許可する、code_verifierをログに出す、認可コードを一回利用にしない、期限切れにしない、といった実装です。S256だけを許可し、コードは短命かつ一回限りにします。

OIDCでIDトークンを使うなら、nonceだけでなく署名、issaudexp、必要ならazpも検証します。IDトークンは「ログインしたユーザーの身元」を表し、アクセストークンは「APIを呼ぶ権限」を表します。この2つを混ぜると、API側で意図しない信頼が生まれます。

トークン保存も危険です。長寿命のリフレッシュトークンをlocalStorageへ置くと、XSSで抜かれたときの被害が大きくなります。通常のWebアプリでは、サーバー側セッション、暗号化DB、短いCookie寿命、失効処理を組み合わせます。Cookie設計はClaude Code Cookie管理も参考になります。

現場でのレビュー手順

実案件では、Claude Codeに実装を出させた直後にマージしないでください。最初に見るのは、正常系の画面ではなく「ログイン開始時に何を保存したか」です。statenoncecode_verifierが同じセッション、同じブラウザ、同じ短い時間枠に結び付いていなければ、コールバックだけ別の取引として差し込まれる余地が残ります。レビューでは、これらの値がサーバー側に保存され、使い終わったあとに消えることを確認します。

次に、プロバイダー設定を見ます。開発環境、本番環境、プレビュー環境でredirect URIが混ざっていると、動作確認のためにワイルドカードや前方一致を入れたくなります。しかし、ここがOAuth事故の入口です。環境ごとに登録URIを分け、アプリ側の設定値も1つに固定し、コールバックで受け取った値やクエリから組み立てた値を信用しないようにします。

最後に、ログとテストを確認します。失敗時のデバッグでcodecode_verifierをそのまま出力すると、CIログや監視ツールに秘密に近い値が残ります。Claude Codeには「ログはイベント名、request id、失敗理由の種類だけにする」と明示してください。テストは、成功だけでなく、state不一致、nonce不一致、PKCE不一致、期限切れコード、同じコードの再利用を必ず入れます。この5つがあると、実装者が変わってもOAuthの安全境界を保ちやすくなります。

本番投入前に決める運用ルール

OAuthは実装よりも運用で崩れることがあります。たとえば、開発中はhttp://localhost:3000/callbackだけで十分でも、本番では独自ドメイン、プレビュー環境、管理画面用ドメインが増えます。ここで「一時的に広めに許可する」判断をすると、後から誰も消せない危険な設定になります。環境ごとにOAuthクライアントを分け、登録済みredirect URIを棚卸しし、不要なURIを削除する担当日を決めてください。

リフレッシュトークンの扱いも事前に決めます。保存先は暗号化DBかサーバー側セッションに限定し、誰が復号できるのか、退会時にどう失効するのか、プロバイダー側の連携解除とアプリ側の削除をどちらも行うのかを明文化します。Claude Codeには「保存するコード」だけでなく「削除するコード」も書かせてください。OAuth連携は追加より解除の方がレビューから漏れやすいからです。

監視では、成功率だけを見ると危険です。invalid_stateinvalid_code_verifierinvalid_redirect_uriinvalid_grantの件数を分けて記録すると、設定ミスと攻撃的な試行を切り分けやすくなります。ただし、ログに実際のトークン、認可コード、Cookie、メールアドレスを残す必要はありません。件数、種類、request id、環境名だけで十分な調査ができる設計を目指します。

また、リリース前には「誰がプロバイダー管理画面を変更できるか」も確認します。OAuthの弱点はコードだけでなく、管理画面の設定変更にもあります。担当者が増えたら、変更理由、変更日時、差分、戻し方を残します。Claude Codeには設定画面そのものを操作させるのではなく、必要な設定項目をチェックリスト化させ、人間が二人以上で確認する運用にすると安全です。

この運用メモまで記事に含めると、読者は実装だけでなく導入後の守り方まで持ち帰れます。

特に初回公開時は、設定、実装、監視、解除手順を同じレビュー表で確認してください。

収益化できる品質にするチェックリスト

公開記事や顧客向け実装にするなら、コードが動くだけでは足りません。設計メモ、脅威モデル、テスト、運用手順をそろえると、研修や相談につながる価値になります。

  • 認可コード、アクセストークン、リフレッシュトークンの寿命が明記されている
  • statenonce、PKCEの役割をチーム内の言葉で説明できる
  • redirect URI、スコープ、プロバイダーの許可設定がドキュメント化されている
  • ログにcodecode_verifier、トークン、Cookie値が出ない
  • 失敗時にユーザーへ詳細な内部エラーを見せない
  • テストで正常系、state不一致、PKCE不一致、期限切れ、再利用を確認している
  • Claude Codeの生成差分を人間がレビューし、公式仕様リンクに照らしている

ClaudeCodeLabでは、OAuth/OIDC、Claude Code導入、セキュリティレビューの研修・相談を想定した教材化も進めています。チームで「AIに認証を任せたいが、レビュー基準がない」という状態なら、ClaudeCodeLab研修のような形で、既存コードを題材にしたハンズオンに落とし込むのが効果的です。

実際に試した結果

この記事のデモは、ローカルでログイン開始、モック認可、コールバック、トークン交換、ダッシュボード表示まで確認する前提で書いています。試すと、stateを改ざんした場合はstate mismatchで止まり、code_verifierが違う場合はinvalid_code_verifierになります。Masaが過去にOAuth連携を試作したとき、一番レビューで見落としやすかったのは「動く正常系」ではなく、複数タブ、戻るボタン、期限切れコード、ログ出力でした。Claude Codeに実装させるなら、この失敗系を最初からテスト条件に入れるだけで品質が大きく変わります。

まとめ

OAuth実装で大事なのは、ボタンを作ることではなく、ログイン開始からトークン保存までの取引を壊されないようにすることです。Authorization Code + PKCE、state、nonce、redirect URI完全一致、サーバー側トークン管理を基本線にしてください。Claude Codeは実装速度を上げますが、秘密情報を渡さないこと、公式仕様でレビューすること、失敗系テストを必ず入れることが前提です。

#Claude Code #OAuth #認証 #セキュリティ #TypeScript
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。