Search by

baxtian / wp_jwt

baxtian

JWT tokens with expiration/revocation for external clients of Merak-based plugins.

Package info

bitbucket.org/baxtian/wp_jwt

pkg:composer/baxtian/wp_jwt

Statistics

Installs: 22

Dependents: 0

Suggesters: 0

0.4.5 2026-09-19 21:37 UTC

This package is auto-updated.

Last update: 2026-09-19 21:37:53 UTC


README

Shared JWT session auth for Merak-based plugins/themes: short-lived, expirable/revocable tokens for external clients (SPA, mobile apps) — not a replacement for Application Passwords, which stays the simple option for internal use (admin-to-admin, own tools, no expiration to manage).

Registers its REST routes under jwt-auth/v1 (kept from the jwt-auth plugin this library replaces, for drop-in compatibility with existing clients).

Usage

Extend Auth — same pattern baxtian/wp_p2p/wp_json/wp_settings use — even when a project has nothing to customize yet. Put it in src/JWT.php (top level, not under a WpJson/-style folder reserved for classes that declare their own REST endpoints — this one doesn't) so composer reset's SingletonTrait scan picks it up on its own, same as any other singleton:

<?php
namespace MyPlugin;

use Baxtian\WP_Jwt\Auth;
use WP_User;

/** Extends baxtian/wp_jwt's Auth (extend-and-configure). */
class JWT extends Auth
{
    use \Baxtian\SingletonTrait;

    /** Initializes the baxtian/wp_jwt instance. */
    protected function __construct()
    {
        // Access token lifetime, seconds
        // $this->expire         = 600;

        // Refresh token/cookie lifetime, seconds
        // $this->refresh_expire = 2592000;

        parent::__construct();
    }

    // /** {@inheritDoc} */
    // protected function extend_credential_response(array $response, WP_User $user): array
    // {
    //     return $response;
    // }

    // /** {@inheritDoc} */
    // protected function extend_token_payload(array $payload, WP_User $user): array
    // {
    //     return $payload;
    // }

    // /** {@inheritDoc} */
    // protected function custom_auth(string $username, string $password, $custom_auth)
    // {
    //     return parent::custom_auth($username, $password, $custom_auth);
    // }
}

// Instances.php (generated):
MyPlugin\JWT::get_instance();

Uncomment and fill in only what the project actually needs — the rest of this README documents what each one does and its default.

Required constants

Defined in wp-config.php, same as AUTH_KEY/DB_HOST/etc. — not something this library or a subclass sets.

ConstantRequiredPurpose
JWT_AUTH_SECRET_KEYYesSigns and verifies tokens. Without it, token responds with wp_jwt_bad_config.
JWT_AUTH_CORS_ENABLENoSet true to add Access-Control-Allow-Headers on the normal REST cycle.
API_HOSTNoString or array of allowed origin hosts. When defined, a token request from any other Origin is rejected.

If JWT_AUTH_SECRET_KEY isn't defined, an admin_notices warning is shown automatically once the library boots — no need to build one per project.

Routes

RouteMethodPurpose
jwt-auth/v1/tokenPOSTIssue a token from username/password, or from the refresh-token cookie when present. Accepts app/device (see below).
jwt-auth/v1/token/validatePOSTValidate the Authorization: Bearer token.
jwt-auth/v1/token/refreshPOSTRotate the refresh-token cookie and issue a new access token. Accepts app/device (see below).
jwt-auth/v1/loginGETRedeem a one-time login_key (see below) and redirect with a fresh refresh cookie.
jwt-auth/v1/logoutPOSTClear the refresh-token cookie. Accepts app/device (see below).
jwt-auth/v1/forgot-passwordPOSTSend a password reset email, redirect_url pointing at the consuming app's own reset screen.
jwt-auth/v1/reset-passwordPOSTSet a new password from a reset key.

Token lifetimes

TokenProperty (subclass)DefaultFilter (fires after the property, for non-subclassing consumers)
Access token (exp claim)$expire600 (10 minutes)wp_jwt_expire($expire, $issued_at)
Refresh token (refresh_token cookie)$refresh_expire2592000 (30 days)wp_jwt_refresh_expire($expires, $created)

Extending the credential response

A successful token request builds a response array (base user fields, roles, a one-time login_key), then calls extend_credential_response(array $response, WP_User $user): array (no-op by default — override it in a subclass) before firing jwt_auth_valid_credential_response (same filter name/signature the jwt-auth plugin used, for non-subclassing consumers).

extend_credential_response() is only called on the success path — never with the API_HOST rejection response — so $response is always the plain array, no is_array() guard needed. The filter doesn't have that guarantee: when API_HOST is defined and the request's Origin isn't on the allow-list, jwt_auth_valid_credential_response fires with a WP_REST_Response instead (the rejection response itself). A filter callback must guard with is_array($response) before indexing into it, or the WP_REST_Response case will fatal:

// Only needed if you're using the filter directly, not extend_credential_response().
add_filter('jwt_auth_valid_credential_response', function ($response, $user) {
    if (is_array($response) && isset($response['data'])) {
        $response['data']['my_field'] = my_project_value($user);
    }

    return $response;
}, 10, 2);

Multiple apps / multiple devices on the same browser

Two params, both optional, sent by the client on token (login and refresh) and logout:

ParamIdentifiesDefaultEffect
appWhich client app is calling (ex. two separate SPAs on the same domain)'default'Own refresh-token cookie per app (refresh_token_{app} instead of the shared refresh_token), so two apps on the same browser/domain don't overwrite each other's session.
deviceWhich physical device/browser is calling''Own stored refresh token per device (keyed by app+device in user meta), so the same app open on several devices doesn't overwrite each other's session either.

Without app, all consuming apps on the same domain share one refresh_token cookie — the last one to log in wins, silently logging the others out. This is what breaks when two headless SPAs (ex. an inventory app and a POS, same domain) are used interchangeably in one browser.

Client-side convention (see indipos-pdv/indipos-inv for reference implementations):

  • app: a fixed string per app, hardcoded in that app's code ('pdv', 'inv', etc.) — not derived from anything dynamic.
  • device: one persistent id per browser installation, shared across all apps on the domain (same localStorage key, ex. idps_device_id) — generated once with crypto.randomUUID() and reused from localStorage after that. Because it's per-browser, not per-app, it does NOT distinguish apps on its own — that's get_storage_key()'s job combining app+device server-side; see its docblock.
const DEVICE_ID_KEY = 'idps_device_id';

export function getDeviceId() {
	let deviceId = localStorage.getItem(DEVICE_ID_KEY);

	if (!deviceId) {
		deviceId = crypto.randomUUID();
		localStorage.setItem(DEVICE_ID_KEY, deviceId);
	}

	return deviceId;
}

Send both on every token call (login and refresh) and on logout — a client that only sends them at login has its refresh/logout calls fall back to app: 'default', device: '', missing the cookie/storage entry the login actually created.

Custom authentication

Override custom_auth(string $username, string $password, $custom_auth) in a subclass to support an auth mechanism other than username/password (ex. OTP, a third-party SSO token) — triggered when the client's token request includes a custom_auth param. No-op by default (always fails). The wp_jwt_do_custom_auth filter still fires afterward, for non-subclassing consumers.

Outside the REST/hooks cycle (ex. a SHORTINIT script)

validate_token_string(), send_cors_headers(), get_iss(), get_alg() and decode_token() are static — they don't need get_instance(). This matters under SHORTINIT: get_instance() boots the full singleton, which schedules a weekly cron event in its constructor, and cron.php isn't loaded yet at that point (WordPress bails out of SHORTINIT before reaching it in wp-settings.php) — booting the singleton there fatals. get_iss() uses get_option('home') rather than get_bloginfo('url')/home_url() for the same reason (neither is loaded under SHORTINIT either).

validate_token_string() checks the signature and iss, but — unlike the token/validate route — does NOT check that the user still exists: get_user_by()/WP_User need user.php/class-wp-user.php, also not loaded under SHORTINIT. A caller that needs that guarantee checks it itself once its own bootstrap has loaded far enough.

try {
    $payload = \Baxtian\WP_Jwt\Auth::validate_token_string($bearer_token);
} catch (\Exception $e) {
    // invalid/expired token, or iss mismatch
}

\Baxtian\WP_Jwt\Auth::send_cors_headers(['Authorization', 'Content-Type', 'X-Blog-Path'], ['GET', 'OPTIONS']);

Maintainers

Juan Sebastián Echeverry baxtian.echeverry@gmail.com

Changelog

See CHANGELOG.md.