OAuth/OIDCはライブラリを入れるだけでもそれなりに動きますが、「本番で長く運用する」という視点に立つと、PKCEの扱い・トークンの寿命・Refresh Tokenの設計あたりでハマりやすいです。
この記事では、エンジニア1年目の方でも追えるレベルで、
- PKCEは何を守っていて、どこで壊れがちか
- Access/ID/Refreshトークンの寿命をどう決めるか
- ログアウトや再ログイン、エラー時の運用をどう設計するか
を “運用目線” で整理していきます。
1. OAuth/OIDCの全体像を“運用目線”で整理する
1-1. OAuth2とOIDCの違い(認可と認証、IDトークンの役割)
まず、用語を軽く整理します。
- OAuth 2.0:
「このクライアントにユーザーの代わりにAPIを呼ぶ権限をあげる」=認可(Authorization)の仕組みです。Access Token がメインの主役です。 - OpenID Connect(OIDC):
OAuth 2.0 の上に「このユーザーは誰か」を乗せた仕様です。ID Token というJWTが追加され、「認証(Authentication)」の役割を持ちます。
ざっくりまとめると、
- APIアクセス権:Access Token(OAuth)
- ログインしたユーザーの情報:ID Token(OIDC)
という分業になっている、と思っておくと整理しやすいです。
1-2. 登場人物と責務
主な登場人物は3つです。
- Client:
SPAやモバイルアプリ、バックエンドなど。「ログインしたい」「APIを呼びたい」側です。 - Authorization Server(AS):
Auth0 / Cognito / Keycloak など。「認可コードを出す」「トークンを発行する」役割です。 - Resource Server(RS):
APIサーバ。Access Token を検証し、「このユーザーはこのAPIを呼べるか」を判断します。
運用目線で大事なのは、「どのエラーをどこでハンドリングするか」です。
- 認可エラー(ログイン画面で失敗など) → Authorization Server
- トークンの期限切れ・リフレッシュ失敗 → Client(再ログイン誘導)
- スコープ不足による403 → Resource ServerとClientの両方
1-3. まず押さえるフロー:Authorization Code + PKCE が基本線
現在のベストプラクティスは、Authorization Code + PKCE です(SPAでもモバイルでも基本これを使います)。
- Client が Authorization Server に「/authorize」リクエスト(code_challenge, state, nonce を付ける)
- ユーザーがログイン&同意すると、ブラウザが
redirect_uri?code=...&state=...に戻ってくる - Client が Authorization Server の
/tokenにcodeとcode_verifierを送る - AS が OK なら Access Token / ID Token / (必要なら Refresh Token)を返す
このときの「code_verifier と code_challenge をどう作ってどこに置くか」が、PKCEで最初に詰まりやすいポイントです。
2. PKCEで詰まる所:何を守ってて、どこで壊れるか
2-1. code_verifier / code_challenge の作り方と保管場所
PKCEは、「認可コードを盗まれても、正しいクライアント以外はトークン化できないようにする」ための仕組みです。
code_verifier:クライアントだけが知っている秘密のランダム文字列code_challenge:code_verifierをハッシュ化(SHA-256など)したもの
フローとしては、
- クライアントが
code_verifierをランダム生成 code_challenge = BASE64URL(SHA256(code_verifier))を計算/authorizeにcode_challengeを載せて飛ぶ/tokenでcode_verifierを送って検証してもらう
JavaScriptでの生成イメージは次のようになります(ブラウザ環境想定)。
// ランダムな code_verifier を生成
async function generateCodeVerifier(length = 64) {
const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
const random = new Uint32Array(length);
crypto.getRandomValues(random);
return Array.from(random)
.map((x) => chars[x % chars.length])
.join("");
}
// code_verifier から code_challenge(S256) を作成
async function generateCodeChallenge(codeVerifier) {
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await crypto.subtle.digest("SHA-256", data);
const bytes = new Uint8Array(digest);
// base64url エンコード
const base64 = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
return base64;
}
このコードでは、ブラウザの crypto.getRandomValues を使って高品質な乱数から code_verifier を作り、そのハッシュを base64url でエンコードして code_challenge にしています。PKCEの仕様で求められる形式を満たすために、文字セットやエンコード方法をきちんと合わせているところがポイントです。
code_verifier の保管場所はクライアント種別で少し変わります。
- SPA(ブラウザ):
メモリに持つ、もしくは認可リダイレクト中だけsessionStorageに保存 - モバイル:
アプリ内メモリ、またはOS提供の安全なストレージ(Secure Storageなど)
Basicですが、localStorageには置かない(XSSで抜かれる)というのが重要です。
2-2. 典型的な事故例
- 検証失敗(invalid_grant):
code_verifierをどこかで取り違えたり、リダイレクトの途中で失ってしまい、トークンエンドポイントで一致しない。 - リダイレクト二重実行:
ユーザーのリロードやブラウザの「戻る」で、同じcodeを2回トークンエンドポイントに投げてしまい、2回目がエラーになる。 - state/nonce混線:
stateやnonceを複数タブで共有してしまい、「このレスポンスはどのリクエストのものか」がわからなくなる。
特にSPAで「複数タブで同時ログイン」のユースケースがあると、state と code_verifier の紐づけがややこしくなりがちです。
2-3. デバッグ手順
PKCEがおかしいときは、次の3段階でログを見て切り分けると調べやすくなります。
- 認可リクエスト(/authorize)
code_challengeがちゃんとS256で入っているかredirect_uriが登録通りかstateがランダムで、予想可能でないか
- トークン交換(/token)
- 同じ
code_verifierを送れているか - 同じ
redirect_uriを送っているか
- コールバック処理(クライアント)
- URLの
stateと、自分が保存していたstateが一致しているか - エラー時にどのフェーズで落ちたかログから判断できるか
ログにはトークン本体は出さず、state や code_challenge_method、レスポンスのステータスコードなどを出しておくと、デバッグがしやすくなります。
3. トークン寿命設計:短くすると安全、でも運用が死ぬ
3-1. Access Token の寿命:短命化と再取得コスト
Access Token は「もし漏れたら何ができてしまうか」が大きいので、なるべく寿命を短くしたいトークンです。
- 5〜10分程度:セキュリティ寄り、漏えい時の影響を小さくできる
- 30〜60分程度:再取得の回数が減るので、実装やログのノイズが少ない
ただし、短くしすぎると「リフレッシュのトラフィックが増え、Authorization Serverがしんどくなる」「期限切れ→再ログインが頻発してUXが悪くなる」などの問題も出てきます。
一般的には、
- Access Token は短め(〜10分程度)
- Refresh Token を持たせて、バックグラウンドで静かに再取得
という設計にすることで、「漏れにくく」「再ログインもあまりさせない」バランスを取ります。
3-2. ID Token の扱い:認証とユーザー情報を分ける
ID Token は、「このユーザーがいつ・どうやって認証されたか」を表すトークンです。
中身にユーザーの名前やメールアドレスなどが入っていることも多いですが、ここをユーザー情報キャッシュの代わりに使ってしまうと、寿命設計が難しくなります。
- ID Token の役割:ログインの証拠(
auth_timeやacr) - ユーザー属性(アバター、表示名など)は、別API(UserInfoエンドポイントや自前API)から取る
こうしておくと、
- ID Token の寿命は短めにしてもよい(再取得で対応)
- ユーザー情報の更新(名前変更など)は別途APIにキャッシュ戦略を持たせる
という形にできるため、「ログインの証拠」と「プロフィール情報」をきれいに分けて考えやすくなります。
3-3. “期限切れ”のユーザー体験:サイレント更新と再ログイン誘導
トークンが期限切れになったときのUXは、だいたい次の2つを組み合わせます。
- サイレントリフレッシュ:バックグラウンドで Refresh Token を使って再取得
- 再ログイン誘導:リフレッシュも失敗したときだけログイン画面に戻す
SPA の例だと、次のような流れにしやすいです。
// 擬似コード:API呼び出しのラッパ
async function fetchWithAuth(input, init = {}) {
let accessToken = await loadAccessToken();
const res = await fetch(input, {
...init,
headers: {
...(init.headers || {}),
Authorization: `Bearer ${accessToken}`,
},
});
if (res.status === 401) {
// 1度だけリフレッシュを試す
const refreshed = await tryRefreshToken();
if (!refreshed) {
redirectToLogin();
return res;
}
accessToken = await loadAccessToken();
return fetch(input, {
...init,
headers: {
...(init.headers || {}),
Authorization: `Bearer ${accessToken}`,
},
});
}
return res;
}
このコードでは、API呼び出し時に401が返ってきたら一度だけ Refresh Token を使った再取得を試し、それでもダメならログイン画面に飛ばす流れにしています。こうしておくことで、ユーザーは「たまにだけログインし直す」程度の体験に抑えつつ、トークンは短めに管理できるようにしています。
4. Refresh Token 設計:一番ハマるのはここ
4-1. Refresh Token を出す条件
Refresh Tokenは長生きなトークンなので、扱いを慎重にする必要があります。OAuth では、クライアントの種類によって扱いが変わります。
- Confidential クライアント(秘密鍵を持てる:バックエンドなど)
→ サーバ側に安全に保管できるので、Refresh Token を発行しやすい - Public クライアント(秘密鍵を隠せない:SPA、モバイル)
→ Refresh Token を出すとしても、回転(rotation)と送信先制限をセットで考える
最近のベストプラクティスでは、Public クライアントに対しても Refresh Token を出しつつ、
- リフレッシュするたびに新しい Refresh Token を発行(rotation)
- 古い Refresh Token が再利用されたら、全体を失効させる
という運用にすることで、漏えい時に気づきやすくしています。
4-2. ローテーション運用:再利用検知と競合
Refresh Token をローテーションする場合、Authorization Server の内部では次のような状態を持ちます(イメージ)。
- 今生きている Refresh Token(最新の1個)
- 再利用されたトークンのリスト(攻撃の可能性)
クライアントからのリクエストと組み合わせると、次のようなパターンが発生します。
- 正常なローテーション:
古いRTでリフレッシュ → 新しいRTを返却 → 古いRTは失効 - 再利用が発生:
すでに使われたRTで再度リフレッシュ → 不正の可能性として全トークン失効 - 並行リクエスト:
タブA/Bから同時にリフレッシュ → 片方は成功、もう片方は失敗(この挙動をどう扱うかがUXに効く)
並行リクエストのケースでは、「片方は失敗するのが仕様」と割り切ることが多いですが、その場合は Client 側で「リフレッシュ失敗時は一度だけ新しいトークンを読み直す」などの工夫が必要になります。
4-3. 保存戦略:ブラウザ/モバイル/サーバ
Refresh Token の保存場所は、環境ごとにベストプラクティスが異なります。
- ブラウザ(SPA):
- httpOnly Cookie に保存し、JSから直接触れないようにする
- クッキーに
SameSite/Secure/Pathを設定 - モバイル:
- OSの提供するSecure Storage(Keychain / Keystore 等)に保存
- バックアップや端末移行時の扱いもポリシーに含める
- サーバ(バックエンド):
- DBに暗号化して保存(KMSやアプリケーションキーで)
- クライアントごとに紐づけて、ログアウト時には削除
重要なのは、「XSSに弱い場所(localStorageなど)には長生きトークンを置かない」ことです。
5. セキュリティの落とし穴:やりがちで危ない実装
5-1. トークンをlocalStorageに置く是非
よくある実装として、
- Access Token や Refresh Token を localStorage に保存
- すべてのAPI呼び出し時に localStorage から取り出してヘッダに付ける
というパターンがありますが、これはXSSが1回入るとすべてのトークンが抜かれるのでかなり危険です。
ブラウザSPAでは、できる限り次のような方針に寄せるのが安全です。
- Access Token はメモリ+短命
- Refresh Token は httpOnly Cookie(JSから直接触れない)
- どうしてもlocalStorageを使う場合は、「絶対にRefresh Tokenは入れない」「スコープを絞る」などの制約をかける
5-2. state/nonce未実装、redirect_uriガバ、スコープ肥大化
実務でありがちなミスを3つ挙げます。
- state未実装:CSRF防止やリクエストの対応付けができなくなり、「このレスポンスはどのタブからのもの?」がわからなくなります。
- nonce未検証:ID Token のリプレイ攻撃に弱くなり、「昔のログイン結果を再利用される」可能性が出てきます。
- redirect_uriガバ:ワイルドカードで何でも許可してしまい、攻撃者が好きなURLにcodeを奪い取れるようになってしまいます。
- スコープ肥大化:最初から
openid profile email+ 独自スコープ全部、のように盛り盛りで発行してしまい、もしAccess Tokenが漏れたときの影響範囲が広がります。
特に redirect_uri は、「正確なURLを事前登録し、そのURL以外は受け付けない」くらいの厳しさで設定するのが安全です。
5-3. ログに出してはいけないもの
デバッグでついログに仕込んでしまいがちなもののうち、絶対に出してはいけないのは次のような値です。
- Access Token / Refresh Token
- 認可コード(
code=...) - PKCE の
code_verifier
どうしても内容を見たいときは、一時的にローカル環境だけで表示する、もしくはマスク(前後数文字だけ)にしておくなど、運用事故を防ぐ工夫が必要です。
6. 実装と運用:本番で回すためのチェックリスト
6-1. 401/403の扱いを統一する
APIのエラーを Client がどう扱うかも、最初に決めておくとトラブルを減らせます。
- 401 Unauthorized:
- Access Token が無効(期限切れ、署名不正など)
- → Client は Refresh Token で再取得を試す → それでもダメならログイン画面へ
- 403 Forbidden:
- 権限不足(スコープ不足、ロール不足など)
- → Client はログインし直しても解決しないケースが多いので、エラーメッセージで説明
このルールをフロント全体で統一して実装しておくと、「この403はログインし直せばいいのか?」という混乱を減らせます。
6-2. セッションとトークンの整合:ログアウトや強制失効
ログアウトのときに「どこまで消すか」も運用ポイントです。
- ブラウザ側:Cookieやローカルストレージ、メモリ上のトークンを削除
- Authorization Server側:Refresh Token を失効させる(revoke endpointなど)
- バックエンドのセッション:必要ならサーバ側のセッション情報も削除
また、管理画面からの「強制ログアウト」や「パスワード変更時の全端末ログアウト」などでは、
- 対象ユーザーの Refresh Token を全削除
- 以後の Access Token 検証時に「このトークンはもう受け付けない」判定をする
という形で、トークンとサーバ側セッションの整合を取っていきます。
6-3. 監視・アラート項目
運用面では、次のようなメトリクスをダッシュボードに載せておくと便利です。
- トークン交換失敗率(/token でのエラー率)
- リフレッシュ失敗数(invalid_grant や invalid_client などの内訳)
- Refresh Token の再利用検知数(rotationでの不正検知)
- 401/403 発生率(Resource Server側)
これらを監視することで、
- PKCEの実装ミスで invalid_grant が増えている
- 特定クライアントで Refresh Token の再利用が多発している
といった問題に早く気づけるようになります。
7. まとめ
7-1. 詰まりポイントTOP3
- PKCEの保管場所:
code_verifierをどこに置き、どうやってstateと紐付けるか
- トークン寿命とUX:
- 短くしすぎると再ログイン連発、長くしすぎると漏えいリスク増
- Refresh Token の競合:
- ローテーションで並行リクエストが失敗したときの扱い
7-2. 結論の型:安全(短命+rotation)を入れつつ“再認証地獄”を避ける
実務で落としどころを作るときは、だいたい次のような型に落ち着きます。
- Access Token は短命(数分〜10分程度)
- Refresh Token は rotation ありで発行し、安全なストレージに保存
- 401 のときだけリフレッシュを試し、ダメならログイン画面へ
- トークンやPKCEの秘密値はログやlocalStorageに残さない
この型をベースに、サービスの性質(金融系ならもっと短命、社内ツールなら少し長めなど)に合わせて寿命やUXを調整していくと、無理のない運用設計に近づいていきます。
8. 参考リンク
- RFC 6749 (OAuth 2.0 Authorization Framework)
https://www.rfc-editor.org/rfc/rfc6749.html - OpenID Connect Core 1.0(Errata Set 2)
https://openid.net/specs/openid-connect-core-1_0.html - RFC 7636 (PKCE)
https://www.rfc-editor.org/rfc/rfc7636.html - RFC 9700 (Best Current Practice for OAuth 2.0 Security)
https://datatracker.ietf.org/doc/rfc9700/