2026年9月14日

2026年9月14日

PHPでJWT認証を実装する方法:firebase/php-jwtでの発行・検証と安全な運用

PHP で JWT 認証を実装するときは、署名と検証を自作せず firebase/php-jwt などの定番ライブラリに任せ、「使うアルゴリズムをサーバー側で固定する」「有効期限を短くする」「発行者と対象(iss・aud)も確かめる」の 3 点を守るのが基本です。この記事では、JWT の仕組みとセッションとの使い分け、トークンの発行と検証のコード、Authorization ヘッダーが届かないときの対処、失効やリフレッシュの考え方、WordPress の REST API と組み合わせるときの注意までを説明します。

JWTの仕組みと、暗号化ではないこと

JWT(JSON Web Token)は、ヘッダー.ペイロード.署名 の 3 つを . でつないだ文字列です。ヘッダーとペイロードは base64url でエンコードした JSON で、署名はそれらが改ざんされていないことを確かめるためのものです。

ここで誤解されやすいのが、JWT のペイロードは暗号化されていないという点です。base64url をデコードすれば誰でも中身を読めます。そのため、パスワード、個人情報、内部の権限設定などをペイロードに入れてはいけません。入れるのは、ユーザーを示す ID や有効期限など、読まれても困らない情報にとどめます。

セッションとJWTの使い分け

ブラウザで動く通常の Web サイトなら、PHP のセッション(Cookie にセッション ID を入れ、情報はサーバーに置く方式)の方が扱いやすく、ログアウトや強制失効も簡単です。JWT が向いているのは、次のような場面です。

  • スマートフォンアプリや別ドメインのフロントエンドから API を呼ぶ
  • 複数のサーバーやサービスで、共有ストレージなしにトークンを検証したい
  • 外部の認証サービス(OpenID Connect の ID トークンなど)が発行したトークンを検証する

JWT は発行後にサーバー側で取り消しにくいという性質があります。「とりあえず JWT」ではなく、この性質を受け入れられる構成かどうかで選んでください。

firebase/php-jwtの導入

Composer で導入します。

composer require firebase/php-jwt

このライブラリは 6 系で、検証時に鍵とアルゴリズムを Key オブジェクトで渡す書き方に変わりました。古い記事にある JWT::decode( $jwt, $key, ['HS256'] ) の形は現在の版では動かないため、注意してください(2026年10月現在)。

署名に使う秘密鍵は、十分な長さのランダムな値にします。HS256 なら 32 バイト以上が目安です。鍵はコードに書かず、環境変数や公開ディレクトリの外の設定ファイルに置き、Git にコミットしません。

# 鍵の生成例(出力された値を環境変数に設定する)
php -r 'echo base64_encode(random_bytes(32)), PHP_EOL;'

トークンを発行する

ログイン処理でユーザーを確認できたら、トークンを発行します。標準的なクレーム(項目)の意味は次のとおりです。

  • iss:発行者。自分のサービスの URL など
  • aud:対象。どの API 向けのトークンか
  • sub:主体。ユーザー ID
  • iat:発行時刻
  • exp:有効期限
  • jti:トークンごとの一意な ID(失効の管理に使う)
<?php
require __DIR__ . '/vendor/autoload.php';

use Firebase\JWT\JWT;

function issue_access_token( int $user_id ): string {
	$now     = time();
	$payload = [
		'iss' => 'https://api.example.com',
		'aud' => 'example-app',
		'sub' => (string) $user_id,
		'iat' => $now,
		'exp' => $now + 900, // 15 分
		'jti' => bin2hex( random_bytes( 16 ) ),
	];

	$secret = base64_decode( getenv( 'JWT_SECRET' ), true );

	return JWT::encode( $payload, $secret, 'HS256' );
}

ログインを受け付ける処理には、試行回数の制限を入れてください。JWT の仕組みが正しくても、パスワードの総当たりを許すと意味がありません。パスワードの照合には password_verify() を使い、入力されたパスワードをログに出さないようにします。

トークンを検証する

API 側では、リクエストの Authorization: Bearer <トークン> ヘッダーから JWT を取り出して検証します。

<?php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\ExpiredException;
use Firebase\JWT\SignatureInvalidException;

function get_bearer_token(): ?string {
	$header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
	if ( preg_match( '/\ABearer\s+(\S+)\z/', $header, $m ) ) {
		return $m[1];
	}
	return null;
}

function verify_access_token( string $jwt ): ?object {
	$secret      = base64_decode( getenv( 'JWT_SECRET' ), true );
	JWT::$leeway = 30; // サーバー間の時計のずれを 30 秒まで許容

	try {
		// アルゴリズムはサーバー側で固定する(トークンのヘッダーに従わない)
		$claims = JWT::decode( $jwt, new Key( $secret, 'HS256' ) );
	} catch ( ExpiredException $e ) {
		return null; // 期限切れ。クライアントにリフレッシュを促す
	} catch ( SignatureInvalidException $e ) {
		return null; // 改ざん、または鍵の不一致
	} catch ( \Throwable $e ) {
		return null; // 形式の誤りなど
	}

	// 発行者と対象はライブラリが確認しないため、自分で確認する
	if ( ( $claims->iss ?? '' ) !== 'https://api.example.com' || ( $claims->aud ?? '' ) !== 'example-app' ) {
		return null;
	}

	return $claims;
}

JWT::decode() が確認するのは、署名と、exp・nbf・iat の時刻です。iss と aud は確認しないため、上のように自分で比べます。これを省くと、同じ鍵を使う別のサービス向けのトークンを受け入れてしまうことがあります。

アルゴリズムを Key で固定しているのは、トークンのヘッダーに書かれた alg を信用すると、署名なし(none)や別の方式に差し替えられる攻撃を受けるおそれがあるためです。

公開鍵方式(RS256・ES256)を使う場合

トークンを発行するサーバーと検証するサーバーが別なら、秘密鍵で署名して公開鍵で検証する RS256 や ES256 が向いています。検証側には公開鍵だけを置けばよく、検証側から鍵が漏れてもトークンは偽造できません。外部の認証サービスが公開している JWKS(公開鍵の一覧)を使う場合は、ライブラリの JWK::parseKeySet() で読み込めます。

Authorization ヘッダーが届かないとき

Apache と PHP の組み合わせ(とくに CGI や FastCGI で PHP を動かす構成)では、Authorization ヘッダーが PHP に渡されず、$_SERVER['HTTP_AUTHORIZATION'] が空になることがあります。まず var_dump( $_SERVER ) などで、どの名前でも値が届いていないことを確認してから、.htaccess で次のどちらかを試します。

# Apache 2.4.13 以降
CGIPassAuth On

# 上が使えない場合
SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1

Nginx と PHP-FPM の構成では、通常は追加の設定なしで届きます。届かない場合は、リバースプロキシや CDN の設定でヘッダーが削られていないか確認します。

失効とリフレッシュの設計

JWT は発行後に取り消しにくいため、次の組み合わせで被害を限定します。

  • アクセストークンの有効期限を短く(数分から 15 分程度)する
  • 長く使うリフレッシュトークンは、JWT ではなくランダムな値にし、サーバー側のデータベースに「ハッシュ化した値」で保存する
  • リフレッシュのたびに新しいリフレッシュトークンを発行し、古いものを無効にする(ローテーション)
  • ログアウトやパスワード変更のときは、そのユーザーのリフレッシュトークンを削除する
  • すぐに止めたいトークンがあれば、jti を失効リストに入れて検証時に照合する

トークンをブラウザのどこに置くかも重要です。localStorage に入れると、XSS の脆弱性があった場合に読み取られます。HttpOnly と Secure を付けた Cookie に入れる方法もありますが、その場合は Cookie が自動で送られるため CSRF 対策が必要になります。

WordPressのREST APIと組み合わせる場合

WordPress の REST API に外部のアプリからアクセスしたいだけなら、まずコア機能のアプリケーションパスワード(WordPress 5.6 以降)で足りないか検討してください。HTTPS で使うことが前提ですが、自分で認証を実装するより安全に始められます。

JWT 方式が必要な場合は、更新が続いている JWT 系のプラグインを選ぶか、determine_current_user フィルターでユーザーを決める処理を自作します。自作する場合の骨組みは次のとおりで、上の get_bearer_token() と verify_access_token() をプラグイン内で読み込んでいる前提です。

<?php
// プラグインまたは mu-plugin に書く
add_filter( 'determine_current_user', function ( $user_id ) {
	if ( $user_id ) {
		return $user_id; // Cookie など、ほかの方法ですでに認証済み
	}

	$jwt = get_bearer_token();
	if ( null === $jwt ) {
		return $user_id;
	}

	$claims = verify_access_token( $jwt );
	if ( null === $claims ) {
		return $user_id;
	}

	$user = get_user_by( 'id', (int) $claims->sub );
	return $user ? $user->ID : $user_id;
}, 30 );

この方法で認証されたユーザーにも、各エンドポイントの permission_callback で current_user_can() による権限チェックが行われます。トークンを持っていることと、その操作をしてよいことは別なので、権限チェックを省かないでください。鍵は wp-config.php の定数として定義し、導入前にステージング環境で、期限切れ・改ざん・別ユーザーのトークンで弾かれることを確かめます。問題が起きたときに備えて、FTP でプラグインを無効化できる状態にしておくと安心です。

まとめ

  • JWT のペイロードは暗号化されていないため、読まれて困る情報は入れない
  • 通常の Web サイトならセッションの方が扱いやすい。JWT は API や複数サービスの連携で使う
  • firebase/php-jwt では Key でアルゴリズムを固定し、iss と aud は自分で確認する
  • アクセストークンは短命にし、リフレッシュトークンはサーバー側でハッシュ化して保存・ローテーションする
  • Apache で Authorization ヘッダーが届かないときは CGIPassAuth On などで渡す
  • WordPress ではまずアプリケーションパスワードを検討し、自作するなら権限チェックを省かない

お気軽にご相談ください

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