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
authenticatefilter, not the login screen. - There is no audit trail. Core stores only
last_usedandlast_ipon 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$availableand theWP_Userobject. 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_availablefilter is probably returning false, or the request is not reaching a context whereapplication_password_is_api_requestevaluates to true. Application passwords only authenticate on REST and XML-RPC requests. - Log rows are empty on success.
application_password_did_authenticatefires 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_usermetawrites, and some migration plugins call$wpdb->update()instead ofset_role(). Neither fires any hook. Add a nightly sweep that re-runsapp_pass_guard_revoke_for_user()against every user ID to catch drift. wp app-passreports an unknown command. The mu-plugin must be present and readable by the WP-CLI user. Confirm withwp eval 'var_dump( class_exists( "App_Pass_CLI" ) );'.dbDelta()silently creates nothing. It requires two spaces afterPRIMARY 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_logper IP over five minutes and return aWP_Errorfrom theauthenticatefilter 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
createdtimestamp is older than 90 days, and email the owner a week beforehand. - Alert on IP changes. Compare
last_ipagainst the loggedipand notify the site admin when a credential authenticates from a new address. - Surface the log in wp-admin. A
WP_List_Tablesubclass 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.