WP Tuts Hub

WordPress Tutorials, Themes and Plugins Hub
Advertisement

Browse

  • Home
  • Categories
    • WordPress Troubleshooting
    • WordPress Tips and Tricks
    • SSH (Secure Shell)
    • WordPress Insights
    • WordPress Common Facts
    • Securing WordPress
  • Series Tutorials
    • Advanced WordPress Topic
  • Privacy Policy
  • Terms of Use
  • Contact Me
  • About WP Tuts Hub
Home/Advanced WordPress Topic/Harden WordPress Application Passwords: Per-Role Availability, Revocation on Role Change and Auth Logging
Advanced WordPress Topic
November 10, 2026

Harden WordPress Application Passwords: Per-Role Availability, Revocation on Role Change and Auth Logging

Harden WordPress Application Passwords: Per-Role Availability, Revocation on Role Change and Auth Logging

Why application passwords deserve their own hardening pass

Application passwords are core WordPress functionality. Since 5.6 the feature ships enabled, letting any user with profile access mint a per-application credential that authenticates over HTTP Basic against the REST API and XML-RPC. That makes headless front ends, deploy pipelines, Zapier workflows and mobile apps trivial to wire up — and it also means a stolen string in a CI log is a full user session that never expires.

Out of the box the feature has four gaps worth closing:

  • Every role can create one. A subscriber can generate a credential and probe the REST API with it.
  • Nothing revokes them. Demote an editor to subscriber and their application password keeps working indefinitely.
  • They bypass two-factor plugins that only hook the login form, because authentication happens on the authenticate filter, not the login screen.
  • There is no audit trail. Core stores only last_used and last_ip on each item, which tells you nothing about failed attempts.

This tutorial builds a single mu-plugin that closes all four and adds a WP-CLI command for auditing and bulk revocation. Recent WordPress versions store application passwords hashed rather than in plain text, so a leaked database backup no longer hands over working credentials — but a leaked credential from anywhere else still does, which is exactly why the controls below matter.

Prerequisites

  • WordPress 6.4 or newer. The hooks used here have been stable since 5.6, but wp_get_environment_type() and the modern admin notice patterns are cleaner on recent releases.
  • PHP 8.1 or newer.
  • A site served over HTTPS with a valid certificate. Step 2 refuses to work without it.
  • WP-CLI 2.8+ for Step 5.
  • Write access to wp-content/mu-plugins/.

Create wp-content/mu-plugins/app-password-guard.php and paste each block into it as you go. Must-use plugins load before regular plugins, cannot be deactivated by an admin who is mid-incident, and are always loaded by WP-CLI — which matters for Step 5.

How the availability gate actually works

Two filters control the feature:

  • wp_is_application_passwords_available — a global boolean. Return false and the whole feature is off site-wide.
  • wp_is_application_passwords_available_for_user — receives $available and the WP_User object. This gates creation through the REST controller and the profile UI.

One thing to internalise before you write a line of code: the per-user filter is a gate, not a revocation mechanism. It governs who may create a password and who sees the UI. It does not retroactively invalidate credentials already sitting in wp_usermeta. That distinction is why Step 3 exists and why filtering alone gives a false sense of security.

Step 1 — Allow only named roles

Start by narrowing who can create credentials at all. Use a capability check rather than a role check if you have custom roles with granular permissions; the example below uses roles because that is what the brief calls for.

<?php
/**
 * Plugin Name: App Password Guard
 * Description: Restricts application passwords to named roles.
 */

add_filter(
	'wp_is_application_passwords_available_for_user',
	static function ( $available, $user ) {
		if ( ! $available ) {
			return false;
		}

		$allowed = array( 'administrator', 'editor', 'api_client' );

		return (bool) array_intersect( $allowed, (array) $user->roles );
	},
	10,
	2
);

Why this shape: returning false early preserves any other plugin’s decision to disable the feature entirely. array_intersect() handles multi-role users correctly, which a naive in_array( $user->roles[0], ... ) does not. Keep administrator in the list or you will lock yourself out of your own deploy scripts; add a dedicated low-privilege role such as api_client for machine accounts so integration credentials never belong to a human administrator.

Step 2 — Refuse to work over plain HTTP

HTTP Basic authentication base64-encodes credentials, it does not encrypt them. Over plain HTTP anyone on the path can replay the header. Disable the feature globally when TLS is absent.

<?php
add_filter(
	'wp_is_application_passwords_available',
	static function ( $available ) {
		if ( is_ssl() ) {
			return $available;
		}

		// Allow plain HTTP only on disposable local environments.
		if ( 'local' === wp_get_environment_type() ) {
			return $available;
		}

		return false;
	}
);

add_action(
	'admin_notices',
	static function () {
		if ( is_ssl() || ! current_user_can( 'manage_options' ) ) {
			return;
		}

		printf(
			'<div class="notice notice-error"><p>%s</p></div>',
			esc_html__( 'Application passwords are disabled because this site is not served over HTTPS.', 'app-pass-guard' )
		);
	}
);

Why the environment check: local development over http://localhost is legitimate and you do not want to break every developer’s tooling. wp_get_environment_type() reads WP_ENVIRONMENT_TYPE, so a staging site that accidentally inherits local still gets protected as long as you set the constant correctly.

Behind a reverse proxy, TLS terminates upstream and is_ssl() returns false even though visitors are on HTTPS. Fix that in wp-config.php before deploying this filter, or you will silently disable every integration on the site:

<?php
// wp-config.php — only when a trusted proxy terminates TLS.
if ( isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) && 'https' === $_SERVER['HTTP_X_FORWARDED_PROTO'] ) {
	$_SERVER['HTTPS'] = 'on';
}

Step 3 — Revoke on role change

Now the important one. When a user loses the role that justified their credential, every stored password must go.

<?php
/**
 * Revoke application passwords a user no longer qualifies for.
 */
function app_pass_guard_revoke_for_user( $user_id ) {
	$user = get_userdata( $user_id );

	if ( ! $user ) {
		return;
	}

	$allowed = array( 'administrator', 'editor', 'api_client' );

	if ( array_intersect( $allowed, (array) $user->roles ) ) {
		return;
	}

	$passwords = WP_Application_Passwords::get_user_application_passwords( $user_id );

	foreach ( $passwords as $item ) {
		WP_Application_Passwords::delete_application_password( $user_id, $item['uuid'] );
	}

	if ( $passwords ) {
		do_action( 'app_pass_guard_revoked', $user_id, count( $passwords ) );
	}
}

add_action( 'set_user_role', 'app_pass_guard_revoke_for_user' );
add_action( 'add_user_role', 'app_pass_guard_revoke_for_user' );
add_action( 'remove_user_role', 'app_pass_guard_revoke_for_user' );

Why three hooks: set_user_role fires only when WP_User::set_role() runs, which replaces all roles at once. Sites using multiple simultaneous roles call add_role() and remove_role() instead, firing add_user_role and remove_user_role. Hooking all three and re-evaluating inside the callback covers every path the WordPress API offers.

Why the check is inside the callback: add_user_role fires when a role is added, which may leave the user still eligible. Re-checking rather than assuming keeps the logic correct in both directions.

Reuse the same $allowed array from Step 1 — better still, define it once as a constant so the two lists cannot drift apart:

<?php
define( 'APP_PASS_GUARD_ROLES', array( 'administrator', 'editor', 'api_client' ) );

Step 4 — Log every success and failure

Core exposes two actions and one filter for this. application_password_did_authenticate passes $user and the password $item. application_password_failed_authentication passes only a WP_Error — no username — so you have to capture the attempted login earlier on the authenticate filter.

First, the storage table. Create it on activation with dbDelta():

<?php
register_activation_hook( __FILE__, static function () {
	global $wpdb;

	require_once ABSPATH . 'wp-admin/includes/upgrade.php';

	$table   = $wpdb->prefix . 'app_pass_log';
	$collate = $wpdb->get_charset_collate();

	$sql = "CREATE TABLE {$table} (
		id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
		created_at DATETIME NOT NULL,
		outcome VARCHAR(10) NOT NULL,
		user_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
		login VARCHAR(60) NOT NULL DEFAULT '',
		uuid CHAR(36) NOT NULL DEFAULT '',
		ip VARCHAR(45) NOT NULL DEFAULT '',
		user_agent VARCHAR(255) NOT NULL DEFAULT '',
		reason VARCHAR(64) NOT NULL DEFAULT '',
		PRIMARY KEY (id),
		KEY user_id (user_id),
		KEY outcome (outcome),
		KEY created_at (created_at)
	) {$collate};";

	dbDelta( $sql );
} );

Because this is a mu-plugin, register_activation_hook() never fires. Either run the SQL once by hand, or guard the block behind a version option check on plugins_loaded.

Then the logger itself:

<?php
final class App_Pass_Logger {

	private static string $attempted_login = '';

	public static function init() {
		add_filter( 'authenticate', array( __CLASS__, 'capture_login' ), 9, 3 );
		add_action( 'application_password_did_authenticate', array( __CLASS__, 'log_success' ), 10, 2 );
		add_action( 'application_password_failed_authentication', array( __CLASS__, 'log_failure' ) );
	}

	public static function capture_login( $user, $username, $password ) {
		self::$attempted_login = is_string( $username ) ? $username : '';
		return $user;
	}

	public static function log_success( $user, $item ) {
		self::write( 'success', (int) $user->ID, $item['uuid'] ?? '' );
	}

	public static function log_failure( $error ) {
		$reason = $error instanceof WP_Error ? $error->get_error_code() : '';
		self::write( 'failure', 0, '', $reason );
	}

	private static function write( $outcome, $user_id, $uuid, $reason = '' ) {
		global $wpdb;

		$wpdb->insert(
			$wpdb->prefix . 'app_pass_log',
			array(
				'created_at' => gmdate( 'Y-m-d H:i:s' ),
				'outcome'    => $outcome,
				'user_id'    => $user_id,
				'login'      => substr( self::$attempted_login, 0, 60 ),
				'uuid'       => $uuid,
				'ip'         => self::client_ip(),
				'user_agent' => substr( (string) ( $_SERVER['HTTP_USER_AGENT'] ?? '' ), 0, 255 ),
				'reason'     => $reason,
			),
			array( '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s' )
		);
	}

	private static function client_ip() {
		$ip = $_SERVER['REMOTE_ADDR'] ?? '';
		return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '';
	}
}

App_Pass_Logger::init();

Why REMOTE_ADDR and nothing else: X-Forwarded-For is attacker-controlled unless you sit behind a proxy you own. Logging a spoofable header into a security table is worse than logging nothing, because it invites bad conclusions during incident review. If you do run a trusted proxy, whitelist its IP range before reading the header.

Why the UUID column: it ties a successful authentication back to the exact credential, so you can correlate with the profile UI and revoke precisely one item rather than all of them. Note that core already maintains last_used and last_ip on the password item itself — this table adds the failure history and the retention policy core does not give you.

Prune the table on a schedule so it does not grow without bound:

DELETE FROM wp_app_pass_log WHERE created_at < DATE_SUB( UTC_TIMESTAMP(), INTERVAL 90 DAY );

Step 5 — WP-CLI list and revoke

Core already ships wp user application-password list|create|delete|get, which works per user. What it does not do is answer “which credentials exist across the whole site, and which ones belong to people who no longer have the role?” Add a command for that.

<?php
if ( defined( 'WP_CLI' ) && WP_CLI ) {
	WP_CLI::add_command( 'app-pass', 'App_Pass_CLI' );
}

class App_Pass_CLI {

	/**
	 * Lists application passwords across users.
	 *
	 * ## OPTIONS
	 *
	 * [--user=<login>]
	 * : Limit to one user.
	 *
	 * [--role=<role>]
	 * : Limit to users holding a role.
	 *
	 * [--format=<format>]
	 * : table, csv or json.
	 * ---
	 * default: table
	 * ---
	 */
	public function list( $args, $assoc_args ) {
		$query = array( 'fields' => array( 'ID', 'user_login' ) );

		if ( ! empty( $assoc_args['role'] ) ) {
			$query['role'] = $assoc_args['role'];
		}

		if ( ! empty( $assoc_args['user'] ) ) {
			$user = get_user_by( 'login', $assoc_args['user'] );

			if ( ! $user ) {
				WP_CLI::error( 'No such user.' );
			}

			$query['include'] = array( $user->ID );
		}

		$rows = array();

		foreach ( get_users( $query ) as $user ) {
			foreach ( WP_Application_Passwords::get_user_application_passwords( $user->ID ) as $item ) {
				$rows[] = array(
					'user_id'    => $user->ID,
					'user_login' => $user->user_login,
					'name'       => $item['name'],
					'uuid'       => $item['uuid'],
					'created'    => gmdate( 'c', $item['created'] ),
					'last_used'  => $item['last_used'] ? human_time_diff( $item['last_used'] ) . ' ago' : 'never',
					'last_ip'    => $item['last_ip'] ?: '',
				);
			}
		}

		WP_CLI\Utils\format_items(
			$assoc_args['format'],
			$rows,
			array( 'user_id', 'user_login', 'name', 'uuid', 'created', 'last_used', 'last_ip' )
		);
	}

	/**
	 * Revokes application passwords.
	 *
	 * ## OPTIONS
	 *
	 * --user=<login>
	 * : Target user.
	 *
	 * [--uuid=<uuid>]
	 * : Revoke one credential only.
	 *
	 * [--yes]
	 * : Skip confirmation.
	 */
	public function revoke( $args, $assoc_args ) {
		$user = get_user_by( 'login', $assoc_args['user'] );

		if ( ! $user ) {
			WP_CLI::error( 'No such user.' );
		}

		$passwords = WP_Application_Passwords::get_user_application_passwords( $user->ID );

		if ( ! empty( $assoc_args['uuid'] ) ) {
			$passwords = array_values(
				array_filter(
					$passwords,
					static fn( $item ) => $item['uuid'] === $assoc_args['uuid']
				)
			);
		}

		if ( ! $passwords ) {
			WP_CLI::warning( 'Nothing to revoke.' );
			return;
		}

		WP_CLI::confirm(
			sprintf( 'Revoke %d application password(s) for %s?', count( $passwords ), $user->user_login ),
			$assoc_args
		);

		$done = 0;

		foreach ( $passwords as $item ) {
			if ( WP_Application_Passwords::delete_application_password( $user->ID, $item['uuid'] ) ) {
				$done++;
			}
		}

		WP_CLI::success( sprintf( 'Revoked %d application password(s).', $done ) );
	}
}

Usage from the server:

# Everything on the site, oldest first, as CSV for a spreadsheet.
wp app-pass list --format=csv

# Just the integrations team.
wp app-pass list --role=api_client

# One credential, machine readable.
wp app-pass list --user=deploy --format=json

# Kill every credential for a departing contractor.
wp app-pass revoke --user=deploy --yes

# Kill exactly one, keeping the rest alive.
wp app-pass revoke --user=deploy --uuid=8f1c2a4e-0b7d-4f3a-9c55-1d2e3f4a5b6c --yes

Troubleshooting

  • The Application Passwords section vanished from the profile screen. Either the site is not on HTTPS, or your per-user filter is returning false for that role. Check is_ssl() first — a proxy terminating TLS is the usual culprit.
  • A valid credential returns 401. The global wp_is_application_passwords_available filter is probably returning false, or the request is not reaching a context where application_password_is_api_request evaluates to true. Application passwords only authenticate on REST and XML-RPC requests.
  • Log rows are empty on success. application_password_did_authenticate fires only when the request was not already authenticated by cookie or a prior filter at an earlier priority. Test with a clean client, not a logged-in browser.
  • Revocation did not fire on a role change. Bulk role edits run through direct wp_usermeta writes, and some migration plugins call $wpdb->update() instead of set_role(). Neither fires any hook. Add a nightly sweep that re-runs app_pass_guard_revoke_for_user() against every user ID to catch drift.
  • wp app-pass reports an unknown command. The mu-plugin must be present and readable by the WP-CLI user. Confirm with wp eval 'var_dump( class_exists( "App_Pass_CLI" ) );'.
  • dbDelta() silently creates nothing. It requires two spaces after PRIMARY KEY, lowercase types and one field per line. Copy the SQL above verbatim rather than reformatting it.

Next steps

  • Rate-limit failures. Count rows in wp_app_pass_log per IP over five minutes and return a WP_Error from the authenticate filter once a threshold is crossed. That turns the log from a record into a control.
  • Expire credentials automatically. Schedule an event that revokes any password whose created timestamp is older than 90 days, and email the owner a week beforehand.
  • Alert on IP changes. Compare last_ip against the logged ip and notify the site admin when a credential authenticates from a new address.
  • Surface the log in wp-admin. A WP_List_Table subclass under Tools gives non-CLI admins the same visibility, with the same capability check as the profile screen.
  • Pair it with two-factor. Application passwords deliberately skip 2FA. Restricting them to a machine-only role and revoking on role change is the substitute for the second factor.

Read the reference pages for wp_is_application_passwords_available_for_user, the WP_Application_Passwords class, and the application_password_did_authenticate action before adapting any of this.

Share this:

  • Share on X (Opens in new window) X
  • Share on Facebook (Opens in new window) Facebook
  • Share on LinkedIn (Opens in new window) LinkedIn
  • Share on WhatsApp (Opens in new window) WhatsApp
  • Share on Reddit (Opens in new window) Reddit
  • Email a link to a friend (Opens in new window) Email

Related

Previous Article Build a Streaming WordPress AI Chat Assistant: SSE Endpoint, Conversation Table and Per-User Rate Limits

About the author

Md. Zubaer Ahammed
Md. Zubaer Ahammed

Md. Zubaer Ahammed is a senior full-stack developer building learning platforms and modern web systems with WordPress, Laravel, Python/Django, Vue, and React. More at https://zubaer.com/.

Related Posts

  • Build a Streaming WordPress AI Chat Assistant: SSE Endpoint, Conversation Table and Per-User Rate Limits Build a Streaming WordPress AI Chat Assistant: SSE Endpoint, Conversation Table and Per-User Rate Limits
  • AI Content Provenance for WordPress: Log Model, Prompt Hash and Token Usage per Post with a Review Diff AI Content Provenance for WordPress: Log Model, Prompt Hash and Token Usage per Post with a Review Diff
WP Tuts Hub

WordPress tutorials, themes, and plugins — written for builders.

  • Portfolio
  • Calorie Calculator Pro
  • Contact
  • About
  • Privacy
  • Terms

© 2026 WP Tuts Hub · Md. Zubaer Ahammed