2026年10月13日
2026年10月13日
WordPress REST APIをJWTとRBAC(ロール・権限)で認可制御する実装例
WordPress REST API を JWT と RBAC で守るときは、「JWT で誰がアクセスしているかを確かめる(認証)」と「WordPress のロールと権限(capability)で何をしてよいかを決める(認可)」を分けて実装するのが要点です。トークンに書かれた役割をそのまま信じず、リクエストのたびに current_user_can() で権限を確認すれば、ロール変更やアカウント停止がすぐに反映されます。
この記事では、独自ロールの作成、トークンの発行と検証、エンドポイントごとの認可、失効の仕組みまでを、小さな自作プラグインとして実装する例で説明します。管理者アカウントにはトークンを発行せず、API 専用の権限の小さいユーザーだけに発行する設計にしています。
先に検討したい選択肢
WordPress 本体には JWT の仕組みはありません。実装を始める前に、次の選択肢で足りないかを確認してください。
- アプリケーションパスワード(WordPress 5.6 以降の標準機能): ユーザーごとに API 用のパスワードを発行し、Basic 認証で REST API を呼べます。サーバー間連携ならこれで足りることが多く、ユーザーのプロフィール画面から個別に取り消せます。
- 既存の JWT プラグイン: 「JWT Authentication for WP REST API」などが知られています。設定方法や更新状況はプラグインごとに異なるため、導入前に最終更新日と対応バージョンを確認してください(2026年10月時点)。
- 外部の認証基盤: Auth0 や Keycloak などの IdP がトークンを発行し、WordPress は検証だけを行う形です。2段階認証やシングルサインオンが必要な組織ではこちらが向いています。
独自に実装する場合も、署名や検証の処理は自分で書かず、広く使われているライブラリ(ここでは firebase/php-jwt)に任せます。暗号処理を自作すると、気付きにくい欠陥が入り込みやすいためです。
全体の設計
この記事の例は、次の流れで動きます。
- API 専用ユーザーがユーザー名とパスワードを
/wp-json/myapp/v1/tokenに送ると、有効期限の短い JWT を受け取る - 以降の API 呼び出しでは
Authorization: Bearer トークンヘッダーを付ける - WordPress はトークンの署名・期限・発行者・受け手・版数を確認し、そのユーザーとしてリクエストを処理する
- 各エンドポイントの
permission_callbackで、ユーザーの権限とトークンのスコープの両方を確認する
RBAC の部分は WordPress のロールと権限の仕組みをそのまま使います。独自の権限 myapp_read_reports(レポート閲覧)と myapp_write_reports(レポート作成)を作り、それを持つロールを用意します。
| ロール | myapp_read_reports | myapp_write_reports | トークン発行 |
|---|---|---|---|
| myapp_viewer(閲覧者) | あり | なし | できる(reports:read) |
| myapp_editor(編集者) | あり | あり | できる(reports:read と reports:write) |
| administrator(管理者) | あり | あり | できない(ログイン中の管理画面からのみ利用) |
管理者にも独自権限を付けるのは、管理画面にログインした状態(Cookie 認証)でレポートを見たり作ったりできるようにするためです。トークンの発行は手順3のコードで管理者を拒否します。
準備: ライブラリと秘密鍵
作業はステージング環境で行い、事前にファイルとデータベースのバックアップを取ります。プラグインフォルダーで Composer を使ってライブラリを入れます。
cd wp-content/plugins/myapp-api-auth
composer require firebase/php-jwt
署名用の秘密鍵は十分に長いランダム値にし、コードや Git リポジトリには書きません。サーバーの環境変数に設定し、wp-config.php で読み込みます。
# 秘密鍵の生成例(出力を環境変数 MYAPP_JWT_SECRET に設定する)
openssl rand -base64 48
<?php
// wp-config.php("That's all, stop editing!" の行より前)
define( 'MYAPP_JWT_SECRET', getenv( 'MYAPP_JWT_SECRET' ) ?: '' );
手順1: ロールと権限を作る
ロールはデータベースに保存されるため、毎回のリクエストではなく有効化時に1回だけ作ります。管理者には独自権限が自動では付かないので、明示的に追加します。
<?php
/**
* Plugin Name: MyApp API Auth
*/
defined( 'ABSPATH' ) || exit;
require_once __DIR__ . '/vendor/autoload.php';
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
register_activation_hook( __FILE__, function () {
add_role( 'myapp_viewer', 'MyApp 閲覧者', array( 'read' => true, 'myapp_read_reports' => true ) );
add_role( 'myapp_editor', 'MyApp 編集者', array(
'read' => true,
'myapp_read_reports' => true,
'myapp_write_reports' => true,
) );
// 管理者は管理画面(Cookie 認証)から使うために権限だけ持たせる。トークンは発行しない(手順3)
$admin = get_role( 'administrator' );
if ( $admin ) {
$admin->add_cap( 'myapp_read_reports' );
$admin->add_cap( 'myapp_write_reports' );
}
} );
function myapp_jwt_secret() {
return defined( 'MYAPP_JWT_SECRET' ) ? (string) MYAPP_JWT_SECRET : '';
}
以降の手順2〜4のコードも、同じプラグインファイルの続きに書きます(use 文はファイルの先頭に1回だけ置きます)。
手順2: トークンを検証してユーザーを決める
determine_current_user フィルターで Bearer トークンを読み、検証に成功したらそのユーザー ID を返します。失敗した理由は rest_authentication_errors フィルターで返します。WordPress 標準のアプリケーションパスワードも、この2つのフィルターの組み合わせで動いています。
<?php
final class MyApp_Jwt_Auth {
private static $error = null;
private static $scopes = null; // JWT で認証したときだけ配列になる
public static function determine_user( $user_id ) {
if ( $user_id || ! self::is_rest_request() ) {
return $user_id;
}
$header = self::auth_header();
if ( 0 !== stripos( $header, 'Bearer ' ) ) {
return $user_id;
}
$secret = myapp_jwt_secret();
if ( strlen( $secret ) < 32 ) {
self::$error = new WP_Error( 'myapp_jwt_config', 'サーバーの設定に問題があります。', array( 'status' => 500 ) );
return $user_id;
}
try {
$claims = JWT::decode( trim( substr( $header, 7 ) ), new Key( $secret, 'HS256' ) );
} catch ( Exception $e ) {
self::$error = new WP_Error( 'myapp_jwt_invalid', 'トークンが無効か、期限が切れています。', array( 'status' => 401 ) );
return $user_id;
}
$user = get_user_by( 'id', (int) ( $claims->sub ?? 0 ) );
$ver = $user ? (int) get_user_meta( $user->ID, 'myapp_token_version', true ) : -1;
if ( ! $user
|| user_can( $user, 'manage_options' )
|| ( $claims->iss ?? '' ) !== home_url()
|| ( $claims->aud ?? '' ) !== 'myapp-api'
|| (int) ( $claims->ver ?? -1 ) !== $ver ) {
self::$error = new WP_Error( 'myapp_jwt_invalid', 'トークンが無効か、期限が切れています。', array( 'status' => 401 ) );
return $user_id;
}
self::$scopes = array_filter( explode( ' ', (string) ( $claims->scope ?? '' ) ) );
return $user->ID;
}
public static function auth_errors( $result ) {
if ( ! empty( $result ) ) {
return $result;
}
if ( is_wp_error( self::$error ) ) {
return self::$error;
}
return is_array( self::$scopes ) ? true : $result;
}
public static function is_jwt_request() {
return is_array( self::$scopes );
}
public static function has_scope( $scope ) {
return is_array( self::$scopes ) && in_array( $scope, self::$scopes, true );
}
private static function auth_header() {
if ( isset( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
return (string) wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] );
}
if ( isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
return (string) wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] );
}
return '';
}
private static function is_rest_request() {
$uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
return false !== strpos( $uri, '/' . rest_get_url_prefix() . '/' ) || isset( $_GET['rest_route'] );
}
}
add_filter( 'determine_current_user', array( 'MyApp_Jwt_Auth', 'determine_user' ), 30 );
add_filter( 'rest_authentication_errors', array( 'MyApp_Jwt_Auth', 'auth_errors' ) );
ポイントを補足します。
- アルゴリズムを固定する:
new Key( $secret, 'HS256' )で検証に使うアルゴリズムを指定しています。トークン側のヘッダーに書かれたアルゴリズムを信じる実装は、改ざんに弱くなります。 - 期限以外の項目も確かめる: firebase/php-jwt が検証するのは署名と
exp・nbf・iatです。発行者(iss)と受け手(aud)は自分で比べます。 - 管理者は検証側でも拒否する: 発行後に管理者へ昇格したユーザーのトークンも使えないよう、
manage_optionsを持つユーザーは検証の段階でも弾いています。 - REST API 以外では使わない: 管理画面や通常のページでトークンが効かないよう、URL で REST API へのリクエストかを判定しています。
- エラーメッセージは同じにする: 期限切れ・署名不一致・ユーザー不在を区別して返すと、攻撃者に手がかりを与えます。詳細は必要に応じてサーバーのログに記録し、トークンそのものは記録しません。
Apache では Authorization ヘッダーが PHP に渡らないことがあります。WordPress が書き出す標準の .htaccess には RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] の行が含まれているので、まずこの行が残っているかを確認します。独自の .htaccess を使っていて行が無い場合は、WordPress のルールより前に次を追加します。
SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
手順3: トークンを発行する
発行エンドポイントは誰でも呼べるため、HTTPS の強制と試行回数の制限を入れます。パスワードはログにもレスポンスにも残しません。管理者権限(manage_options)を持つユーザーには発行しません。
<?php
add_action( 'rest_api_init', function () {
register_rest_route( 'myapp/v1', '/token', array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => 'myapp_issue_token',
'permission_callback' => '__return_true', // 公開エンドポイント。中で認証する
'args' => array(
'username' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_user' ),
'password' => array( 'type' => 'string', 'required' => true ),
),
) );
} );
function myapp_issue_token( WP_REST_Request $request ) {
if ( ! is_ssl() && 'local' !== wp_get_environment_type() ) {
return new WP_Error( 'myapp_https_required', 'HTTPS で接続してください。', array( 'status' => 400 ) );
}
$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
$key = 'myapp_token_try_' . md5( $ip );
$tries = (int) get_transient( $key );
if ( $tries >= 5 ) {
return new WP_Error( 'myapp_too_many', 'しばらく時間をおいてから再度お試しください。', array( 'status' => 429 ) );
}
$user = wp_authenticate( $request['username'], $request['password'] );
if ( is_wp_error( $user ) ) {
set_transient( $key, $tries + 1, 15 * MINUTE_IN_SECONDS );
return new WP_Error( 'myapp_auth_failed', 'ユーザー名またはパスワードが正しくありません。', array( 'status' => 401 ) );
}
delete_transient( $key );
// 管理者にはトークンを発行しない(API 専用の権限の小さいユーザーを使う)
if ( user_can( $user, 'manage_options' ) ) {
return new WP_Error( 'myapp_no_access', 'このアカウントには API 用のトークンを発行できません。', array( 'status' => 403 ) );
}
// 付与するスコープは、ユーザーが現在持っている権限から決める
$scope_map = array(
'reports:read' => 'myapp_read_reports',
'reports:write' => 'myapp_write_reports',
);
$scopes = array();
foreach ( $scope_map as $scope => $cap ) {
if ( user_can( $user, $cap ) ) {
$scopes[] = $scope;
}
}
if ( ! $scopes ) {
return new WP_Error( 'myapp_no_access', 'API を利用する権限がありません。', array( 'status' => 403 ) );
}
$now = time();
$payload = array(
'iss' => home_url(),
'aud' => 'myapp-api',
'sub' => (string) $user->ID,
'iat' => $now,
'nbf' => $now,
'exp' => $now + 15 * MINUTE_IN_SECONDS,
'jti' => wp_generate_uuid4(),
'ver' => (int) get_user_meta( $user->ID, 'myapp_token_version', true ),
'scope' => implode( ' ', $scopes ),
);
return rest_ensure_response( array(
'token' => JWT::encode( $payload, myapp_jwt_secret(), 'HS256' ),
'expires_in' => 15 * MINUTE_IN_SECONDS,
) );
}
注意点が3つあります。
- 2段階認証は効かない:
wp_authenticate()はユーザー名とパスワードだけで認証するため、2段階認証のプラグインを入れていても、この経路では2段階目が求められません。管理者を発行対象から外しているのはこのためです。API 用にはmyapp_viewerなど必要最小限のロールのユーザーを別に作ってください。2段階認証が必要なら外部 IdP の利用を検討します。 - アプリケーションパスワードでも通る: REST API のリクエスト中は、
wp_authenticate()がアプリケーションパスワードも受け付けます。API 専用ユーザーにアプリケーションパスワードを発行している場合は、それでもトークンを取得できることを前提に管理します。 - リバースプロキシ配下の IP アドレス: リバースプロキシや CDN の配下では
REMOTE_ADDRがプロキシのアドレスになり、全員が同じ回数制限を共有してしまいます。信頼できるプロキシが付けるヘッダーから実際の IP アドレスを取るなど、環境に合わせて調整します。
手順4: エンドポイントごとに認可する
認可の判断は permission_callback にまとめます。WordPress 5.5 以降、これを省略すると開発者向けの警告(_doing_it_wrong)が出ます。
<?php
function myapp_authorize( $capability, $scope ) {
if ( ! current_user_can( $capability ) ) {
return new WP_Error( 'rest_forbidden', 'この操作を行う権限がありません。', array( 'status' => rest_authorization_required_code() ) );
}
if ( MyApp_Jwt_Auth::is_jwt_request() && ! MyApp_Jwt_Auth::has_scope( $scope ) ) {
return new WP_Error( 'rest_forbidden', 'トークンにこの操作の許可が含まれていません。', array( 'status' => 403 ) );
}
return true;
}
add_action( 'rest_api_init', function () {
register_rest_route( 'myapp/v1', '/reports', array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'myapp_get_reports',
'permission_callback' => function () {
return myapp_authorize( 'myapp_read_reports', 'reports:read' );
},
),
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => 'myapp_create_report',
'permission_callback' => function () {
return myapp_authorize( 'myapp_write_reports', 'reports:write' );
},
'args' => array(
'title' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field' ),
),
),
) );
} );
myapp_get_reports と myapp_create_report は、レポートを返す・保存する処理として各自で実装します。
rest_authorization_required_code() は、未ログインなら 401、ログイン済みで権限がなければ 403 を返します。権限とスコープの両方を確認しているため、閲覧用に発行したトークンが漏れても書き込みはできず、ロールを外されたユーザーのトークンは有効期限内でもすぐに使えなくなります。
管理者が管理画面にログインした状態で JavaScript からこのエンドポイントを呼ぶ場合は、通常の Cookie 認証になるため、X-WP-Nonce ヘッダーに wp_create_nonce( 'wp_rest' ) の値を付けて送ります。「自分が作成したレポートだけ編集できる」のような個別の条件は、コールバックの中で投稿者 ID などを比べて追加します。
失効と運用
JWT は発行後にサーバー側で取り消しにくいのが弱点です。この例では、ユーザーメタ myapp_token_version の値をトークンに含めて照合しています。パスワード変更や退職の際にこの値を1つ増やせば、そのユーザーの既存のトークンはすべて無効になります。
# WP-CLI で版数を上げる例(ユーザーID 12 の既存トークンを無効にする)
wp user meta update 12 myapp_token_version 1
有効期限を15分程度と短くしておくと、漏えいしたときの影響も小さくなります。
動作確認は curl で行えます(ユーザー名・パスワード・トークンは実際の値に置き換えます)。
curl -s -X POST https://example.com/wp-json/myapp/v1/token \
-H 'Content-Type: application/json' \
-d '{"username":"api-viewer","password":"your-password"}'
curl -s https://example.com/wp-json/myapp/v1/reports \
-H 'Authorization: Bearer your-token'
次の結果になることも確かめてください。
- 閲覧ロールのトークンで POST すると 403
- 期限切れのトークンや版数を上げた後のトークンでは 401
- 管理者のユーザー名とパスワードでトークンを要求すると 403
他のプラグインが別の版の firebase/php-jwt を同梱していると、クラス名が衝突することがあります。配布するプラグインでは、名前空間を付け替えるツールの利用も検討してください。
まとめ
- 認証(JWT で誰かを確かめる)と認可(WordPress のロールと権限で可否を決める)を分けて実装する
- まずアプリケーションパスワードや既存プラグイン、外部 IdP で足りないかを検討し、独自実装でも署名処理はライブラリに任せる
- 検証ではアルゴリズムを固定し、
iss・aud・期限・トークン版数を確認する permission_callbackでcurrent_user_can()とスコープの両方を確認し、ロール変更をすぐ反映させる- 管理者にはトークンを発行せず、API 専用の権限の小さいユーザーを使う。秘密鍵は環境変数で渡し、発行エンドポイントは HTTPS と試行回数制限で守る