テックブログ

OAuth運用の詰まりどころ:PKCEと寿命

OAuth運用の詰まりどころ:PKCEと寿命

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でもモバイルでも基本これを使います)。

  1. Client が Authorization Server に「/authorize」リクエスト(code_challenge, state, nonce を付ける)
  2. ユーザーがログイン&同意すると、ブラウザが redirect_uri?code=...&state=... に戻ってくる
  3. Client が Authorization Server の /tokencodecode_verifier を送る
  4. 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_challengecode_verifier をハッシュ化(SHA-256など)したもの

フローとしては、

  1. クライアントが code_verifier をランダム生成
  2. code_challenge = BASE64URL(SHA256(code_verifier)) を計算
  3. /authorizecode_challenge を載せて飛ぶ
  4. /tokencode_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で「複数タブで同時ログイン」のユースケースがあると、statecode_verifier の紐づけがややこしくなりがちです。

2-3. デバッグ手順

PKCEがおかしいときは、次の3段階でログを見て切り分けると調べやすくなります。

  1. 認可リクエスト(/authorize)
  • code_challenge がちゃんとS256で入っているか
  • redirect_uri が登録通りか
  • state がランダムで、予想可能でないか
  1. トークン交換(/token)
  • 同じcode_verifierを送れているか
  • 同じredirect_uriを送っているか
  1. コールバック処理(クライアント)
  • URLのstateと、自分が保存していたstateが一致しているか
  • エラー時にどのフェーズで落ちたかログから判断できるか

ログにはトークン本体は出さず、statecode_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_timeacr
  • ユーザー属性(アバター、表示名など)は、別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個)
  • 再利用されたトークンのリスト(攻撃の可能性)

クライアントからのリクエストと組み合わせると、次のようなパターンが発生します。

  1. 正常なローテーション:
    古いRTでリフレッシュ → 新しいRTを返却 → 古いRTは失効
  2. 再利用が発生:
    すでに使われたRTで再度リフレッシュ → 不正の可能性として全トークン失効
  3. 並行リクエスト:
    タブ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

  1. PKCEの保管場所
  • code_verifier をどこに置き、どうやって state と紐付けるか
  1. トークン寿命とUX
  • 短くしすぎると再ログイン連発、長くしすぎると漏えいリスク増
  1. Refresh Token の競合
  • ローテーションで並行リクエストが失敗したときの扱い

7-2. 結論の型:安全(短命+rotation)を入れつつ“再認証地獄”を避ける

実務で落としどころを作るときは、だいたい次のような型に落ち着きます。

  • Access Token は短命(数分〜10分程度)
  • Refresh Token は rotation ありで発行し、安全なストレージに保存
  • 401 のときだけリフレッシュを試し、ダメならログイン画面へ
  • トークンやPKCEの秘密値はログやlocalStorageに残さない

この型をベースに、サービスの性質(金融系ならもっと短命、社内ツールなら少し長めなど)に合わせて寿命やUXを調整していくと、無理のない運用設計に近づいていきます。

8. 参考リンク