2026年7月13日

2026年7月13日

PHPでOAuth 2.0認証を実装する方法:認可コードフローとPKCEをleague/oauth2-clientで組む

PHP で OAuth 2.0 を実装するなら、外部サービスの API を利用者の許可を得て呼ぶ「クライアント側」を、認可コードフローと PKCE の組み合わせで作るのが現在の推奨です。暗号処理やトークンのやり取りを自作せず、league/oauth2-client のような広く使われているライブラリに任せると、間違えやすい部分を減らせます。この記事では、OAuth 2.0 の役割の整理から、state と PKCE を含む実装、トークンの保存と更新、よくあるエラーの直し方、WordPress で使うときの注意までを説明します。

OAuth 2.0は「認可」の仕組み

OAuth 2.0 は、利用者のパスワードを預からずに、外部サービスの API を限られた範囲(スコープ)で使う許可を得る仕組みです。登場するのは次の 4 者です。

  • リソースオーナー:許可を出す利用者
  • クライアント:API を使いたいアプリ(この記事で PHP で作る側)
  • 認可サーバー:利用者を認証し、許可を確認してトークンを発行する
  • リソースサーバー:トークンを受け取って API を提供する

注意したいのは、OAuth 2.0 そのものは「誰がログインしたか」を確かめる仕組みではないことです。「Google でログイン」のようにログインに使う場合は、OAuth 2.0 の上に作られた OpenID Connect(OIDC)を使い、ID トークンを検証します。アクセストークンを受け取れたことだけを根拠にログインさせる実装は、別のアプリ向けに発行されたトークンを悪用される危険があります。

どのフローを使うか

OAuth 2.0 には複数のフロー(グラントタイプ)がありますが、2025 年 1 月に公開された OAuth 2.0 セキュリティのベストプラクティス(RFC 9700)では、次のように整理されています。

  • 認可コードフロー+PKCE:利用者が関わる場合の標準。サーバー側の PHP アプリでも PKCE を併用する
  • クライアントクレデンシャル:利用者が関わらない、サーバー同士の連携
  • インプリシットフロー:使わない(トークンが URL に載るため)
  • リソースオーナーパスワード:使わない(利用者のパスワードをクライアントが扱うため)

この記事では、もっとも使う場面が多い認可コードフロー+PKCE を実装します。

準備:クライアントの登録とライブラリの導入

最初に、連携先サービスの開発者向け管理画面でアプリ(クライアント)を登録し、クライアント ID とクライアントシークレットを取得します。このとき、認可後に戻ってくる URL(リダイレクト URI)を登録します。登録した URI と、プログラムから送る URI は文字単位で一致している必要があり、末尾のスラッシュや http と https の違いでもエラーになります。

管理画面の項目名や、審査が必要なスコープ、利用条件はサービスごとに異なり、頻繁に変わります(2026年10月現在)。とくに X(旧 Twitter)や Meta の API は、提供条件の変更が多いため、実装前に各社の開発者向けドキュメントで最新の条件を確認してください。

ライブラリは Composer で導入します。

composer require league/oauth2-client

Google や GitHub など主要なサービスには、このライブラリを拡張した専用パッケージ(例:league/oauth2-google)もあります。ここでは、どのサービスにも使える GenericProvider で説明します。

認可コードフロー+PKCEの実装

設定とプロバイダーの作成

クライアントシークレットはコードに書かず、環境変数などから読み込みます。

<?php
// bootstrap.php
require __DIR__ . '/vendor/autoload.php';

session_start();

$provider = new \League\OAuth2\Client\Provider\GenericProvider( [
	'clientId'                => getenv( 'OAUTH_CLIENT_ID' ),
	'clientSecret'            => getenv( 'OAUTH_CLIENT_SECRET' ),
	'redirectUri'             => 'https://app.example.com/oauth/callback.php',
	'urlAuthorize'            => 'https://auth.example.com/oauth/authorize',
	'urlAccessToken'          => 'https://auth.example.com/oauth/token',
	'urlResourceOwnerDetails' => 'https://api.example.com/me',
	'scopes'                  => 'read:profile',
] );

function base64url( string $bin ): string {
	return rtrim( strtr( base64_encode( $bin ), '+/', '-_' ), '=' );
}

URL やスコープの値は例です。実際の値は連携先のドキュメントで確認します。

認可画面へ送る

ログインや連携の開始ボタンから、次の処理へ移動させます。ここでは、CSRF 対策の state と、PKCE の code_verifier を生成してセッションに保存します。

<?php
// oauth/start.php
require __DIR__ . '/../bootstrap.php';

// PKCE: ランダムな code_verifier と、その SHA-256 である code_challenge を作る
$verifier  = base64url( random_bytes( 32 ) );
$challenge = base64url( hash( 'sha256', $verifier, true ) );

$authUrl = $provider->getAuthorizationUrl( [
	'code_challenge'        => $challenge,
	'code_challenge_method' => 'S256',
] );

// getAuthorizationUrl() を呼んだ後に state を取得する
$_SESSION['oauth2_state']    = $provider->getState();
$_SESSION['oauth2_verifier'] = $verifier;

header( 'Location: ' . $authUrl );
exit;

PKCE は、認可コードが途中で盗まれても、code_verifier を知らない第三者がトークンに交換できないようにする仕組みです。新しい版のライブラリには PKCE を自動で扱うオプションもありますが、版によって有無や書き方が異なるため、ここでは手動で値を渡しています。

コールバックでトークンに交換する

認可サーバーから戻ってきたら、state を照合してから、認可コードをアクセストークンに交換します。

<?php
// oauth/callback.php
require __DIR__ . '/../bootstrap.php';

use League\OAuth2\Client\Provider\Exception\IdentityProviderException;

$state    = $_GET['state'] ?? '';
$expected = $_SESSION['oauth2_state'] ?? '';
$verifier = $_SESSION['oauth2_verifier'] ?? '';
unset( $_SESSION['oauth2_state'], $_SESSION['oauth2_verifier'] ); // 1 回だけ使う

if ( isset( $_GET['error'] ) ) {
	// 利用者が拒否した場合など
	exit( '連携がキャンセルされました。' );
}

if ( '' === $expected || ! is_string( $state ) || ! hash_equals( $expected, $state ) ) {
	http_response_code( 400 );
	exit( '不正なリクエストです。' );
}

try {
	$token = $provider->getAccessToken( 'authorization_code', [
		'code'          => (string) ( $_GET['code'] ?? '' ),
		'code_verifier' => $verifier,
	] );

	$owner = $provider->getResourceOwner( $token );

	session_regenerate_id( true );
	// ここでトークンを保存する(次の章を参照)
} catch ( IdentityProviderException $e ) {
	error_log( 'OAuth error: ' . $e->getMessage() );
	exit( '連携に失敗しました。もう一度お試しください。' );
}

state の比較には hash_equals() を使い、照合が終わったらセッションから消します。同じ値を使い回せると、CSRF 対策の意味が薄れるためです。

トークンの保存と更新

アクセストークンは有効期限が短く、期限が切れたらリフレッシュトークンで再取得します。保存と更新では次の点に注意します。

  • トークンはサーバー側(データベースやセッション)に保存し、ブラウザの localStorage には置かない
  • リフレッシュトークンは長く使える値なので、データベースに保存する場合は暗号化し、ログに出さない
  • 必要なスコープだけを要求する。スコープが広いほど、漏えいしたときの被害が大きくなる
<?php
// $stored は保存済みの AccessToken を復元したもの
if ( $stored->hasExpired() && $stored->getRefreshToken() ) {
	$newToken = $provider->getAccessToken( 'refresh_token', [
		'refresh_token' => $stored->getRefreshToken(),
	] );
	// サービスによっては新しいリフレッシュトークンが返り、古いものは無効になる
	// 返ってきた値で保存内容を置き換える
}

リフレッシュトークンを使うたびに新しい値を発行する(ローテーション)サービスでは、古い値を使い続けるとエラーになります。更新のたびに保存し直すようにしておくと安全です。

よくあるエラーと確認方法

可能性の高いものから順に挙げます。

  1. redirect_uri_mismatch などのリダイレクト URI のエラー:管理画面に登録した URI とコードの redirectUri を、スキーム・ホスト・パス・末尾のスラッシュまで比べる
  2. invalid_grant:認可コードの期限切れか、同じコードを 2 回使った。ブラウザの再読み込みでコールバックが 2 回実行されていないか確認する。PKCE の code_verifier が開始時の値と違う場合もこのエラーになる
  3. invalid_client:クライアント ID かシークレットの誤り。環境変数が読み込めているか、空文字になっていないか確認する
  4. state が一致しない:セッションが維持されていない。開始とコールバックでドメインが違う、セッション Cookie の SameSite や Secure の設定でブラウザが Cookie を送っていない、などが原因になる
  5. スコープのエラー:要求したスコープがアプリに許可されていないか、サービス側の審査が必要。開発者向けの管理画面で状態を確認する

WordPressで使うときの注意

WordPress のサイトに「外部サービスでログイン」を付けたいだけなら、自分で OAuth を実装するより、継続して更新されているソーシャルログイン系のプラグインを使う方が安全です。自作する場合は次の点を守ってください。

  • クライアントシークレットは wp-config.php の定数で定義し、データベースのオプションに平文で置かない
  • 開始用の URL には wp_nonce_url() などで nonce を付け、コールバックでは state を照合する
  • 外部アカウントと WordPress ユーザーを結び付けるときは、メールアドレスの一致だけで既存の管理者アカウントにログインさせない
  • 連携に失敗しても通常のログインが使えるようにし、問題が出たら FTP でプラグインのフォルダー名を変えて無効化できるようにしておく
  • 取得するプロフィール情報は最小限にし、プライバシーポリシーに「どのサービスから、何を取得し、何に使うか」を書く

導入は子テーマやプラグインとして行い、本番の前にステージング環境で、許可・拒否・期限切れの各パターンを試します。

まとめ

  • OAuth 2.0 は API を使う許可を得る仕組みで、ログインに使うなら OpenID Connect で ID トークンを検証する
  • 利用者が関わる連携は、認可コードフロー+PKCE を使う。インプリシットとパスワードグラントは使わない
  • state は hash_equals() で照合して 1 回で破棄し、code_verifier は開始時の値をトークン交換で送る
  • トークンはサーバー側に保存し、リフレッシュトークンは暗号化して、更新のたびに保存し直す
  • 連携先の画面や条件は変わりやすいため、各社の開発者向けドキュメントを確認してから実装する

お気軽にご相談ください

お見積りへ お問い合わせへ
▲