WordPress Application Passwords: Architecture, Limitations, and Building Scoped API Access
The Problem Application Passwords Were Built to Solve
WordPress 5.6, released in December 2020, introduced Application Passwords as a core authentication mechanism for the REST API and XML-RPC. Before this feature landed, developers who needed programmatic access to WordPress had a short list of bad options: pass a user’s actual credentials over HTTP Basic Auth, install third-party authentication plugins, or build custom token systems from scratch. Each approach carried its own risks and maintenance burden.
Application Passwords changed the calculus. They provide a way to generate secondary credentials tied to a specific user account, credentials that can be revoked individually without changing the user’s primary login password. The feature shipped as part of core, meaning no plugin dependency, no compatibility concerns, and a standardized API that external tools could target with confidence.
But the implementation made deliberate trade-offs. Application Passwords grant the full capabilities of the associated user. There is no built-in scope restriction, no expiration mechanism, no rate limiting, and no audit trail beyond what you build yourself. For simple integrations, these omissions are tolerable. For production systems handling sensitive data or serving as the backbone of agency workflows, they become gaps you need to fill with custom code.
This article walks through the full architecture of Application Passwords, from storage and hashing to authentication flow. It then addresses each limitation with concrete code: scoped middleware, rate limiting, audit logging, expiration, and IP restrictions. Finally, it compares Application Passwords against JWT and OAuth 2.0 to help you choose the right tool for your specific integration pattern.
How Application Passwords Are Stored
When a user generates an Application Password through the WordPress admin (Users > Profile > Application Passwords), WordPress creates a structured array and stores it in the _application_passwords user meta key. Each entry contains a UUID, a name (the label the user provides), the hashed password, a creation timestamp, and a last-used timestamp.
The raw password is shown to the user exactly once, at the moment of creation. WordPress then hashes it using wp_hash_password(), which relies on the phpass library (specifically the portable hash with bcrypt-style iteration). The raw password is never stored. This is the same hashing approach WordPress uses for user login passwords, meaning a database breach does not expose Application Passwords in plaintext.
Here is a simplified view of the stored structure:
array(
array(
'uuid' => 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
'app_id' => '',
'name' => 'CI/CD Pipeline',
'password' => '$P$BxSome...HashedValue...',
'created' => 1654070700,
'last_used' => 1654156800,
'last_ip' => '203.0.113.42',
),
// additional Application Passwords...
)
The uuid field uniquely identifies each Application Password. The app_id field was designed to let applications register themselves, though it sees limited use in practice. The name is purely for human identification in the admin UI. The last_used and last_ip fields are updated on every successful authentication, giving administrators a basic sense of activity.
You can retrieve all Application Passwords for a user programmatically:
$passwords = WP_Application_Passwords::get_user_application_passwords( $user_id );
foreach ( $passwords as $item ) {
error_log( sprintf(
'UUID: %s | Name: %s | Last used: %s',
$item['uuid'],
$item['name'],
$item['last_used'] ? date( 'Y-m-d H:i:s', $item['last_used'] ) : 'never'
) );
}
The Hashing Mechanism in Detail
When WordPress receives an API request with Basic Auth credentials, it needs to check the provided password against every Application Password stored for that user. The function wp_check_password() handles this comparison. Because phpass hashing is intentionally slow (it runs multiple iterations to resist brute-force attacks), checking a single password against many stored hashes introduces a linear cost. If a user has 15 Application Passwords, WordPress runs wp_check_password() up to 15 times per request.
This is a deliberate security trade-off: slower hashing means better protection against offline attacks, but it also means that users with many Application Passwords will experience slightly higher authentication overhead. In practice, most users maintain fewer than five Application Passwords, so the performance impact is negligible. But if you are building a system where users might accumulate dozens of passwords (for example, an agency managing many client integrations), be aware of this linear scan behavior.
Storage Location and Cleanup
Application Passwords live in the usermeta table. When you delete an Application Password through the admin UI or via WP_Application_Passwords::delete_application_password(), WordPress removes that specific entry from the serialized array. When a user account is deleted, all associated Application Passwords are cleaned up automatically because the usermeta rows are removed.
However, there is no garbage collection for stale passwords. An Application Password created three years ago and never used again will persist indefinitely unless someone manually revokes it. This is one of the architectural gaps we will address later with custom expiration logic.
The Authentication Flow
Understanding how WordPress validates an incoming API request with Application Passwords requires tracing through several layers of code. The process starts well before any REST API endpoint logic runs.
Step 1: Transport Layer
The client sends an HTTP request with Basic Auth credentials. The username is the WordPress username. The password is the Application Password (typically formatted with spaces every four characters, though WordPress strips these during validation). The request looks like this:
curl -u "david:ABcd EFgh 1234 IjKl MnOp QrSt" \
https://example.com/wp-json/wp/v2/posts
WordPress retrieves the credentials from the $_SERVER['PHP_AUTH_USER'] and $_SERVER['PHP_AUTH_PW'] superglobals. If the server does not populate these (common with CGI/FastCGI configurations), WordPress falls back to parsing the Authorization header directly.
Step 2: The determine_current_user Filter
WordPress hooks into the determine_current_user filter at priority 20 via WP_Application_Passwords::authenticate(). This runs after cookie-based authentication (priority 10), so if the user is already authenticated via a browser session, Application Password authentication is skipped.
The authenticate method performs these checks in sequence:
1. Verify that the request includes Basic Auth credentials.
2. Look up the user by the provided username using get_user_by( 'login', $username ).
3. If the user exists, iterate over their stored Application Passwords.
4. For each stored password, run wp_check_password( $provided_password, $stored_hash ).
5. If a match is found, record the last_used timestamp and last_ip, then return the user ID.
Step 3: The application_password_did_authenticate Action
After successful authentication, WordPress fires the application_password_did_authenticate action hook. This hook receives the user object and the matched Application Password item (including its UUID, name, and metadata). This is the primary hook you will use for audit logging, rate limiting, and scope enforcement.
do_action( 'application_password_did_authenticate', $user, $item );
If authentication fails (no matching password found), WordPress fires application_password_failed_authentication instead, which is useful for monitoring brute-force attempts.
Step 4: REST API Permission Callbacks
Once the user is authenticated, WordPress processes the REST API request normally. Each endpoint has a permission_callback that checks whether the authenticated user has the required capability. For example, the POST /wp/v2/posts endpoint checks for edit_posts. If the user associated with the Application Password has the Editor role, they can create posts. If they have the Subscriber role, they cannot.
This is where the scope gap becomes apparent: the permission system operates on user capabilities, not on Application Password metadata. There is no mechanism to say “this Application Password should only allow reading posts, not creating them.” The password inherits everything the user can do.
The Scope Gap: Full User Capabilities
The most significant architectural limitation of Application Passwords is the absence of scoped permissions. When you create an Application Password for an Administrator account, that password can do everything the Administrator can do: create users, install plugins, modify options, delete the entire site’s content.
This is a problem for several reasons:
Principle of least privilege violation. A CI/CD pipeline that only needs to publish posts should not have the ability to modify site options or manage users. If the Application Password is compromised (leaked in logs, exposed in a repository, intercepted on an insecure network), the attacker gains full administrative access.
Third-party integration risk. When you hand an Application Password to an external service (a mobile app, a headless frontend, a monitoring tool), you are trusting that service with your full user capabilities. If that service is breached, your WordPress site is fully exposed.
Multi-password ambiguity. If an administrator has three Application Passwords (one for a mobile app, one for Zapier, one for a staging sync tool), all three have identical permissions. There is no way to restrict what each one can access.
WordPress core made this trade-off intentionally. The Application Passwords feature was designed as a simple, baseline authentication mechanism. Adding scope management would have significantly increased the complexity of the UI and the codebase. The expectation was that developers needing fine-grained access control would layer it on top.
Building Custom Scope Middleware
To add scope restrictions to Application Passwords, you need to intercept authenticated API requests and check whether the specific Application Password used is authorized for the requested action. There are two complementary approaches: using the rest_authentication_errors filter and modifying individual endpoint permission callbacks.
Defining a Scope Schema
First, define what “scopes” mean for your application. A practical approach is to store scope metadata alongside each Application Password. Since WordPress stores Application Passwords as a serialized array in usermeta, you can add custom keys to each entry.
function wpkite_add_scopes_to_app_password( $user_id, $app_password_uuid, $scopes ) {
$passwords = WP_Application_Passwords::get_user_application_passwords( $user_id );
foreach ( $passwords as &$password ) {
if ( $password['uuid'] === $app_password_uuid ) {
$password['scopes'] = array_map( 'sanitize_text_field', $scopes );
break;
}
}
update_user_meta( $user_id, '_application_passwords', $passwords );
}
// Example: restrict a password to only reading and creating posts
wpkite_add_scopes_to_app_password( 4, 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', array(
'read:posts',
'create:posts',
) );
Enforcing Scopes via rest_authentication_errors
The rest_authentication_errors filter runs early in the REST API lifecycle, before any endpoint logic executes. By hooking here, you can block requests that fall outside the allowed scopes for the current Application Password.
add_filter( 'rest_authentication_errors', 'wpkite_enforce_app_password_scopes', 100 );
function wpkite_enforce_app_password_scopes( $errors ) {
// If another authentication handler already returned an error, respect it.
if ( is_wp_error( $errors ) ) {
return $errors;
}
$user_id = get_current_user_id();
if ( ! $user_id ) {
return $errors;
}
// Check if the current request was authenticated via Application Password.
$app_password_uuid = wp_get_authenticated_app_password_uuid();
if ( ! $app_password_uuid ) {
return $errors; // Not an Application Password request; skip.
}
// Retrieve the matched Application Password's scopes.
$passwords = WP_Application_Passwords::get_user_application_passwords( $user_id );
$current_password = null;
foreach ( $passwords as $pw ) {
if ( $pw['uuid'] === $app_password_uuid ) {
$current_password = $pw;
break;
}
}
if ( ! $current_password || empty( $current_password['scopes'] ) ) {
return $errors; // No scope restrictions defined; allow full access.
}
$required_scope = wpkite_determine_required_scope();
if ( ! $required_scope ) {
return $errors;
}
if ( ! in_array( $required_scope, $current_password['scopes'], true ) ) {
return new WP_Error(
'rest_app_password_scope_denied',
sprintf( 'This Application Password does not have the "%s" scope.', $required_scope ),
array( 'status' => 403 )
);
}
return $errors;
}
function wpkite_determine_required_scope() {
$method = $_SERVER['REQUEST_METHOD'];
$path = isset( $_SERVER['PATH_INFO'] ) ? $_SERVER['PATH_INFO'] : '';
// Fallback: parse from REQUEST_URI if PATH_INFO is empty.
if ( empty( $path ) && isset( $_SERVER['REQUEST_URI'] ) ) {
$parsed = wp_parse_url( $_SERVER['REQUEST_URI'] );
$path = isset( $parsed['path'] ) ? $parsed['path'] : '';
}
// Strip the REST API prefix.
$rest_prefix = rest_get_url_prefix();
$path = preg_replace( '#^/?' . preg_quote( $rest_prefix, '#' ) . '/#', '', $path );
// Map method + path to a scope string.
$resource = wpkite_extract_resource_from_path( $path );
$method_map = array(
'GET' => 'read',
'HEAD' => 'read',
'POST' => 'create',
'PUT' => 'update',
'PATCH' => 'update',
'DELETE' => 'delete',
);
$action = isset( $method_map[ $method ] ) ? $method_map[ $method ] : 'read';
if ( $resource ) {
return $action . ':' . $resource;
}
return null;
}
function wpkite_extract_resource_from_path( $path ) {
// Match common WP REST API patterns.
$patterns = array(
'#^wp/v2/posts#' => 'posts',
'#^wp/v2/pages#' => 'pages',
'#^wp/v2/media#' => 'media',
'#^wp/v2/users#' => 'users',
'#^wp/v2/comments#' => 'comments',
'#^wp/v2/categories#' => 'categories',
'#^wp/v2/tags#' => 'tags',
'#^wp/v2/settings#' => 'settings',
'#^wp/v2/plugins#' => 'plugins',
'#^wp/v2/themes#' => 'themes',
);
foreach ( $patterns as $pattern => $resource ) {
if ( preg_match( $pattern, $path ) ) {
return $resource;
}
}
// For custom post types or namespaces, extract the first meaningful segment.
$segments = explode( '/', trim( $path, '/' ) );
if ( count( $segments ) >= 3 ) {
return sanitize_key( $segments[2] );
}
return null;
}
A Note on wp_get_authenticated_app_password_uuid()
This helper function, available since WordPress 5.7, returns the UUID of the Application Password used for the current request. It returns null if the request was not authenticated via an Application Password. This is the key function that makes scope enforcement possible: it lets you identify exactly which Application Password was used and look up its metadata.
Rate Limiting per Application Password
WordPress does not impose any rate limits on API requests authenticated with Application Passwords. A compromised password can hammer your site with thousands of requests per second, and WordPress will happily process each one. Building rate limiting requires tracking request counts and enforcing thresholds.
Transient-Based Rate Limiting
A straightforward approach uses WordPress transients to track request counts per Application Password UUID. Transients are stored in the database (or in an object cache like Redis if available), and they support automatic expiration.
add_action( 'application_password_did_authenticate', 'wpkite_rate_limit_app_password', 10, 2 );
function wpkite_rate_limit_app_password( $user, $item ) {
$uuid = $item['uuid'];
$window = 60; // 60-second window
$limit = 120; // 120 requests per window
$key = 'wpkite_app_pw_rate_' . md5( $uuid );
$current = get_transient( $key );
if ( false === $current ) {
set_transient( $key, 1, $window );
return;
}
if ( (int) $current >= $limit ) {
// Send rate limit headers before terminating.
header( 'Retry-After: ' . $window );
header( 'X-RateLimit-Limit: ' . $limit );
header( 'X-RateLimit-Remaining: 0' );
wp_send_json_error(
array( 'message' => 'Rate limit exceeded for this Application Password.' ),
429
);
exit;
}
// Increment the counter. Using a direct option update avoids race conditions
// better than set_transient, but for most sites transients are sufficient.
set_transient( $key, (int) $current + 1, $window );
}
This implementation has a known weakness: transient updates are not atomic. Under high concurrency, two requests could read the same count and both increment to the same value, allowing slightly more requests than the configured limit. For sites with significant API traffic, a Redis-based counter with INCR and EXPIRE commands provides true atomic increments. You can achieve this through the WordPress object cache if a persistent cache backend is configured.
Per-Password Configurable Limits
You can extend the scope metadata pattern to include rate limit configuration per Application Password:
function wpkite_set_app_password_rate_limit( $user_id, $uuid, $requests_per_minute ) {
$passwords = WP_Application_Passwords::get_user_application_passwords( $user_id );
foreach ( $passwords as &$password ) {
if ( $password['uuid'] === $uuid ) {
$password['rate_limit'] = absint( $requests_per_minute );
break;
}
}
update_user_meta( $user_id, '_application_passwords', $passwords );
}
Then modify the rate limiting callback to read the limit from the password metadata instead of using a hardcoded value. This lets you set different thresholds for different integrations: a mobile app might get 30 requests per minute, while a CI/CD pipeline gets 200.
Audit Logging: Tracking Which Password Made Which Request
The last_used and last_ip fields that WordPress updates on each authentication are better than nothing, but they only tell you the most recent request. For any serious security or debugging need, you want a full audit log that records every API call, which Application Password was used, what endpoint was hit, and what data was sent.
A Custom Audit Log Table
Start by creating a dedicated table for audit entries:
function wpkite_create_app_password_audit_table() {
global $wpdb;
$table_name = $wpdb->prefix . 'wpkite_app_password_audit';
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE IF NOT EXISTS $table_name (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT(20) UNSIGNED NOT NULL,
app_password_uuid VARCHAR(36) NOT NULL,
app_password_name VARCHAR(255) NOT NULL,
request_method VARCHAR(10) NOT NULL,
request_path TEXT NOT NULL,
request_ip VARCHAR(45) NOT NULL,
request_body LONGTEXT,
response_code SMALLINT UNSIGNED,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_uuid (app_password_uuid),
KEY idx_user_id (user_id),
KEY idx_created_at (created_at)
) $charset;";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
}
register_activation_hook( __FILE__, 'wpkite_create_app_password_audit_table' );
Logging Every Authenticated Request
add_action( 'application_password_did_authenticate', 'wpkite_log_app_password_request', 10, 2 );
function wpkite_log_app_password_request( $user, $item ) {
global $wpdb;
$table = $wpdb->prefix . 'wpkite_app_password_audit';
// Capture request body for write operations, but limit size.
$body = null;
if ( in_array( $_SERVER['REQUEST_METHOD'], array( 'POST', 'PUT', 'PATCH', 'DELETE' ), true ) ) {
$raw_body = file_get_contents( 'php://input' );
if ( strlen( $raw_body ) <= 65535 ) {
$body = $raw_body;
} else {
$body = '[truncated: ' . strlen( $raw_body ) . ' bytes]';
}
}
$wpdb->insert( $table, array(
'user_id' => $user->ID,
'app_password_uuid' => $item['uuid'],
'app_password_name' => $item['name'],
'request_method' => sanitize_text_field( $_SERVER['REQUEST_METHOD'] ),
'request_path' => esc_url_raw( $_SERVER['REQUEST_URI'] ),
'request_ip' => sanitize_text_field( $_SERVER['REMOTE_ADDR'] ),
'request_body' => $body,
'response_code' => null, // Updated later if needed.
'created_at' => current_time( 'mysql' ),
), array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s' ) );
}
Querying the Audit Log
Once the log accumulates data, you can query it for security analysis:
// Find all requests made by a specific Application Password in the last 24 hours.
function wpkite_get_recent_app_password_activity( $uuid, $hours = 24 ) {
global $wpdb;
$table = $wpdb->prefix . 'wpkite_app_password_audit';
return $wpdb->get_results( $wpdb->prepare(
"SELECT request_method, request_path, request_ip, created_at
FROM $table
WHERE app_password_uuid = %s
AND created_at >= DATE_SUB( NOW(), INTERVAL %d HOUR )
ORDER BY created_at DESC",
$uuid,
$hours
) );
}
// Count requests per Application Password for anomaly detection.
function wpkite_get_app_password_request_counts( $user_id, $hours = 1 ) {
global $wpdb;
$table = $wpdb->prefix . 'wpkite_app_password_audit';
return $wpdb->get_results( $wpdb->prepare(
"SELECT app_password_uuid, app_password_name, COUNT(*) as request_count
FROM $table
WHERE user_id = %d
AND created_at >= DATE_SUB( NOW(), INTERVAL %d HOUR )
GROUP BY app_password_uuid, app_password_name
ORDER BY request_count DESC",
$user_id,
$hours
) );
}
Consider implementing a cleanup routine to purge old audit entries. A WP-Cron job that runs weekly and removes entries older than 90 days keeps the table from growing unbounded:
add_action( 'wpkite_cleanup_audit_log', 'wpkite_purge_old_audit_entries' );
function wpkite_purge_old_audit_entries() {
global $wpdb;
$table = $wpdb->prefix . 'wpkite_app_password_audit';
$wpdb->query( "DELETE FROM $table WHERE created_at < DATE_SUB( NOW(), INTERVAL 90 DAY )" );
}
if ( ! wp_next_scheduled( 'wpkite_cleanup_audit_log' ) ) {
wp_schedule_event( time(), 'weekly', 'wpkite_cleanup_audit_log' );
}
Expiration and Rotation Strategies
Application Passwords in WordPress core do not expire. Once created, they remain valid until manually revoked. This is a meaningful security gap for any production system. Credentials that live forever are credentials that accumulate risk over time: they get copied into shared documents, embedded in forgotten scripts, and stored in third-party services that may themselves be compromised months or years later.
Adding Expiration Metadata
The simplest approach extends the Application Password metadata with an expiration timestamp:
function wpkite_set_app_password_expiry( $user_id, $uuid, $days_until_expiry ) {
$passwords = WP_Application_Passwords::get_user_application_passwords( $user_id );
foreach ( $passwords as &$password ) {
if ( $password['uuid'] === $uuid ) {
$password['expires_at'] = time() + ( absint( $days_until_expiry ) * DAY_IN_SECONDS );
break;
}
}
update_user_meta( $user_id, '_application_passwords', $passwords );
}
Enforcing Expiration at Authentication Time
Hook into the authentication action to check whether the matched Application Password has expired:
add_action( 'application_password_did_authenticate', 'wpkite_check_app_password_expiry', 5, 2 );
function wpkite_check_app_password_expiry( $user, $item ) {
if ( empty( $item['expires_at'] ) ) {
return; // No expiration set.
}
if ( time() > (int) $item['expires_at'] ) {
// Optionally auto-revoke the expired password.
WP_Application_Passwords::delete_application_password( $user->ID, $item['uuid'] );
wp_send_json_error(
array(
'code' => 'app_password_expired',
'message' => 'This Application Password has expired. Please generate a new one.',
),
401
);
exit;
}
}
Automated Rotation Notifications
Rather than letting passwords silently expire and break integrations, send a warning email when an Application Password is approaching its expiration date:
add_action( 'wpkite_check_expiring_app_passwords', 'wpkite_notify_expiring_passwords' );
function wpkite_notify_expiring_passwords() {
$users = get_users( array( 'fields' => 'ID' ) );
$warning_threshold = 7 * DAY_IN_SECONDS; // Warn 7 days before expiry.
foreach ( $users as $user_id ) {
$passwords = WP_Application_Passwords::get_user_application_passwords( $user_id );
$user = get_userdata( $user_id );
foreach ( $passwords as $pw ) {
if ( empty( $pw['expires_at'] ) ) {
continue;
}
$time_remaining = (int) $pw['expires_at'] - time();
if ( $time_remaining > 0 && $time_remaining <= $warning_threshold ) {
$days_left = ceil( $time_remaining / DAY_IN_SECONDS );
wp_mail(
$user->user_email,
sprintf( 'Application Password "%s" expires in %d days', $pw['name'], $days_left ),
sprintf(
"Your Application Password \"%s\" will expire in %d days.\n\n" .
"Please generate a new one and update your integrations.\n\n" .
"You can manage your Application Passwords at: %s",
$pw['name'],
$days_left,
admin_url( 'profile.php#application-passwords-section' )
)
);
}
}
}
}
if ( ! wp_next_scheduled( 'wpkite_check_expiring_app_passwords' ) ) {
wp_schedule_event( time(), 'daily', 'wpkite_check_expiring_app_passwords' );
}
Programmatic Rotation
For automated systems, you may want to rotate Application Passwords programmatically. The process involves creating a new password, updating the external service's configuration, and then revoking the old one:
function wpkite_rotate_app_password( $user_id, $old_uuid, $new_name = '' ) {
// Get the old password's metadata for reference.
$passwords = WP_Application_Passwords::get_user_application_passwords( $user_id );
$old_password = null;
foreach ( $passwords as $pw ) {
if ( $pw['uuid'] === $old_uuid ) {
$old_password = $pw;
break;
}
}
if ( ! $old_password ) {
return new WP_Error( 'not_found', 'Application Password not found.' );
}
$label = $new_name ? $new_name : $old_password['name'] . ' (rotated)';
// Create the new Application Password.
$result = WP_Application_Passwords::create_new_application_password(
$user_id,
array( 'name' => $label )
);
if ( is_wp_error( $result ) ) {
return $result;
}
list( $new_raw_password, $new_item ) = $result;
// Copy scope and rate limit metadata from the old password.
if ( ! empty( $old_password['scopes'] ) || ! empty( $old_password['rate_limit'] ) ) {
$all_passwords = WP_Application_Passwords::get_user_application_passwords( $user_id );
foreach ( $all_passwords as &$p ) {
if ( $p['uuid'] === $new_item['uuid'] ) {
if ( ! empty( $old_password['scopes'] ) ) {
$p['scopes'] = $old_password['scopes'];
}
if ( ! empty( $old_password['rate_limit'] ) ) {
$p['rate_limit'] = $old_password['rate_limit'];
}
if ( ! empty( $old_password['expires_at'] ) ) {
$p['expires_at'] = time() + ( 90 * DAY_IN_SECONDS );
}
break;
}
}
update_user_meta( $user_id, '_application_passwords', $all_passwords );
}
// Revoke the old password.
WP_Application_Passwords::delete_application_password( $user_id, $old_uuid );
return array(
'new_password' => $new_raw_password,
'new_uuid' => $new_item['uuid'],
'old_uuid' => $old_uuid,
);
}
The raw password returned by this function must be securely transmitted to whatever system needs it and then discarded from memory. Never log raw Application Passwords.
Comparison: Application Passwords vs JWT vs OAuth 2.0
Choosing the right authentication mechanism depends on your integration's security requirements, complexity budget, and operational context. Here is a detailed comparison of the three most common options for WordPress REST API authentication.
Application Passwords
How they work: Long-lived credentials sent via HTTP Basic Auth on every request. Server-side validation against hashed values in the database.
Strengths: Zero plugin dependencies (core feature). Simple to implement for clients. No token refresh logic needed. Works with any HTTP client that supports Basic Auth.
Weaknesses: Credentials sent on every request (must use HTTPS). No built-in scoping, expiration, or rate limiting. Linear hash checking cost per stored password. No support for third-party authorization flows (user cannot grant limited access to an external app without sharing credentials).
Best for: First-party integrations, server-to-server communication, CI/CD pipelines, mobile apps under your control.
JSON Web Tokens (JWT)
How they work: Client authenticates once (typically with username/password), receives a signed token containing claims (user ID, roles, expiration). Subsequent requests include the token in the Authorization header. The server validates the token's signature and claims without a database lookup.
Strengths: Stateless validation (no database hit per request). Built-in expiration via the exp claim. Can embed custom claims for scope restrictions. Token refresh flows allow short-lived access tokens with long-lived refresh tokens.
Weaknesses: Requires a plugin (no core support). Token revocation is tricky without a blacklist (since tokens are self-contained). Token size can be large if many claims are embedded. Secret key management is critical and often mishandled.
Best for: Single-page applications, mobile apps that need offline token validation, architectures that benefit from stateless authentication.
WordPress implementation note: The most common JWT plugin for WordPress is "JWT Authentication for WP REST API." It hooks into determine_current_user similarly to Application Passwords but validates a JWT instead of checking hashed credentials. Be cautious with plugin selection: some JWT plugins have had security vulnerabilities related to weak secret key handling or algorithm confusion attacks.
OAuth 2.0
How they work: A full authorization framework where users grant limited access to third-party applications through a consent flow. The third party receives an access token (and optionally a refresh token) without ever seeing the user's password. Scopes are defined as part of the authorization request.
Strengths: Industry-standard authorization framework. Built-in scope support. Users explicitly consent to specific permissions. Access tokens are short-lived. Supports multiple grant types (authorization code, client credentials, etc.). Third-party apps never see user credentials.
Weaknesses: Significant implementation complexity. Requires a plugin (no core support). The authorization flow involves redirects, consent screens, and token exchanges. Client registration and management add operational overhead. Overkill for simple server-to-server integrations.
Best for: Third-party integrations where users authorize external apps. Multi-tenant SaaS platforms. Public APIs where you cannot trust the client.
WordPress implementation note: WordPress previously had an OAuth 1.0a server plugin maintained by the REST API team, but it never made it into core. For OAuth 2.0, the WP OAuth Server plugin is the most established option, though it requires careful configuration.
Decision Matrix
Use Application Passwords when you control both the client and the server, the integration is first-party, and you can enforce HTTPS. Add custom scope and expiration middleware as described in this article to close the security gaps.
Use JWT when you need stateless authentication, your client is a SPA or mobile app, and you want built-in token expiration without custom code. Accept the plugin dependency and manage your signing keys carefully.
Use OAuth 2.0 when third-party applications need to access your WordPress site on behalf of users, and those users need to explicitly consent to specific permissions. Accept the implementation complexity as the cost of proper authorization.
For many WordPress projects, Application Passwords with custom middleware offer the best balance: zero plugin dependencies, standard HTTP authentication, and the ability to layer on exactly the security controls you need.
Security Hardening: IP Restrictions and HTTPS Enforcement
Beyond scoping and rate limiting, two additional hardening measures significantly reduce the attack surface of Application Passwords.
IP Address Restrictions
For server-to-server integrations where the calling IP address is known and static, restricting an Application Password to specific IP addresses prevents a stolen credential from being useful elsewhere.
function wpkite_set_app_password_allowed_ips( $user_id, $uuid, $allowed_ips ) {
$passwords = WP_Application_Passwords::get_user_application_passwords( $user_id );
foreach ( $passwords as &$password ) {
if ( $password['uuid'] === $uuid ) {
$password['allowed_ips'] = array_map( 'sanitize_text_field', $allowed_ips );
break;
}
}
update_user_meta( $user_id, '_application_passwords', $passwords );
}
// Restrict to specific IPs.
wpkite_set_app_password_allowed_ips( 4, 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', array(
'203.0.113.42',
'198.51.100.0/24', // CIDR notation for a range.
) );
The enforcement hook checks the requesting IP against the allowed list:
add_action( 'application_password_did_authenticate', 'wpkite_enforce_ip_restriction', 3, 2 );
function wpkite_enforce_ip_restriction( $user, $item ) {
if ( empty( $item['allowed_ips'] ) ) {
return; // No IP restriction configured.
}
$request_ip = sanitize_text_field( $_SERVER['REMOTE_ADDR'] );
foreach ( $item['allowed_ips'] as $allowed ) {
if ( wpkite_ip_matches( $request_ip, $allowed ) ) {
return; // IP is allowed.
}
}
// Log the blocked attempt.
error_log( sprintf(
'Application Password IP restriction: blocked %s for password "%s" (user %d)',
$request_ip,
$item['name'],
$user->ID
) );
wp_send_json_error(
array( 'message' => 'Request origin not allowed for this Application Password.' ),
403
);
exit;
}
function wpkite_ip_matches( $ip, $allowed ) {
// Exact match.
if ( $ip === $allowed ) {
return true;
}
// CIDR match.
if ( strpos( $allowed, '/' ) !== false ) {
list( $subnet, $mask ) = explode( '/', $allowed, 2 );
$subnet_long = ip2long( $subnet );
$ip_long = ip2long( $ip );
$mask_long = -1 << ( 32 - (int) $mask );
return ( $ip_long & $mask_long ) === ( $subnet_long & $mask_long );
}
return false;
}
HTTPS Enforcement
Application Passwords are transmitted in cleartext as part of HTTP Basic Auth headers. Without TLS, anyone on the network path can intercept them. WordPress core already includes a check: the wp_is_application_passwords_available() function returns false on non-HTTPS sites (with an exception for localhost development).
However, this check can be bypassed by filters, and some server configurations report HTTPS incorrectly. Adding an explicit enforcement layer is worthwhile:
add_action( 'application_password_did_authenticate', 'wpkite_enforce_https', 1, 2 );
function wpkite_enforce_https( $user, $item ) {
if ( is_ssl() ) {
return;
}
// Allow localhost for development.
$host = isset( $_SERVER['HTTP_HOST'] ) ? $_SERVER['HTTP_HOST'] : '';
if ( in_array( $host, array( 'localhost', '127.0.0.1' ), true ) ) {
return;
}
// Also allow .local and .ddev.site domains for development.
if ( preg_match( '/\.(local|ddev\.site|test)$/', $host ) ) {
return;
}
wp_send_json_error(
array( 'message' => 'Application Password authentication requires HTTPS.' ),
403
);
exit;
}
Blocking XML-RPC
Application Passwords work with both the REST API and XML-RPC. If you are only using the REST API, disable XML-RPC authentication to reduce the attack surface:
add_filter( 'wp_is_application_passwords_available_for_user', 'wpkite_disable_app_passwords_xmlrpc', 10, 2 );
function wpkite_disable_app_passwords_xmlrpc( $available, $user ) {
if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
return false;
}
return $available;
}
This filter is evaluated before any authentication attempt, so XML-RPC requests with Application Passwords will be rejected immediately.
Managing Application Passwords Programmatically
The WP_Application_Passwords class provides a complete API for managing Application Passwords without going through the admin UI. This is essential for building automated provisioning systems, admin tools, and integration setup wizards.
Creating a New Application Password
$result = WP_Application_Passwords::create_new_application_password(
$user_id,
array(
'name' => 'Automated Deployment',
'app_id' => 'com.example.deploy-tool', // Optional application identifier.
)
);
if ( is_wp_error( $result ) ) {
error_log( 'Failed to create Application Password: ' . $result->get_error_message() );
} else {
list( $raw_password, $item ) = $result;
// $raw_password is the plaintext password. Store/transmit it securely.
// $item contains the uuid, name, and other metadata.
// IMPORTANT: Format the password with spaces for readability (matches admin UI).
$formatted = WP_Application_Passwords::chunk_password( $raw_password );
}
Listing All Application Passwords for a User
$passwords = WP_Application_Passwords::get_user_application_passwords( $user_id );
foreach ( $passwords as $pw ) {
printf(
"Name: %s | UUID: %s | Created: %s | Last Used: %s\n",
$pw['name'],
$pw['uuid'],
date( 'Y-m-d', $pw['created'] ),
$pw['last_used'] ? date( 'Y-m-d H:i', $pw['last_used'] ) : 'Never'
);
}
Deleting a Specific Application Password
$deleted = WP_Application_Passwords::delete_application_password( $user_id, $uuid );
if ( is_wp_error( $deleted ) ) {
error_log( 'Deletion failed: ' . $deleted->get_error_message() );
} else {
error_log( 'Application Password revoked successfully.' );
}
Revoking All Application Passwords for a User
This is the nuclear option, useful when you suspect a user's account has been compromised:
$count = WP_Application_Passwords::delete_all_application_passwords( $user_id );
error_log( sprintf( 'Revoked %d Application Passwords for user %d.', $count, $user_id ) );
Checking Application Password Availability
Before attempting to create or use Application Passwords, check whether the feature is available:
// Global availability check.
if ( ! wp_is_application_passwords_available() ) {
// Feature is disabled (possibly non-HTTPS or filtered off).
}
// Per-user availability check.
$user = get_userdata( $user_id );
if ( ! wp_is_application_passwords_available_for_user( $user ) ) {
// This specific user cannot use Application Passwords.
}
The wp_is_application_passwords_available filter lets you enable or disable Application Passwords globally. The wp_is_application_passwords_available_for_user filter lets you control access per user, which is useful if you want to restrict Application Passwords to administrators only:
add_filter( 'wp_is_application_passwords_available_for_user', 'wpkite_restrict_app_passwords_to_admins', 10, 2 );
function wpkite_restrict_app_passwords_to_admins( $available, $user ) {
if ( ! user_can( $user, 'manage_options' ) ) {
return false;
}
return $available;
}
REST API Endpoints for Application Passwords
WordPress also exposes Application Password management through the REST API itself. The endpoints live under /wp/v2/users/{user_id}/application-passwords:
# List all Application Passwords for a user.
GET /wp/v2/users/4/application-passwords
# Create a new Application Password.
POST /wp/v2/users/4/application-passwords
Content-Type: application/json
{"name": "New Integration", "app_id": "com.example.myapp"}
# Delete a specific Application Password.
DELETE /wp/v2/users/4/application-passwords/{uuid}
# Delete all Application Passwords for a user.
DELETE /wp/v2/users/4/application-passwords
These endpoints require authentication with a user who has the edit_user capability for the target user. They follow standard REST API conventions and return JSON responses.
Real-World Integration Patterns
The theory and code above become more concrete when applied to specific integration scenarios. Here are patterns for the most common use cases.
Mobile Application Integration
A mobile app connecting to WordPress via the REST API is one of the most natural use cases for Application Passwords. The flow typically works like this:
1. The user enters their WordPress username and Application Password in the app's settings.
2. The app stores the credentials securely (iOS Keychain, Android Keystore).
3. Every API request includes the credentials via Basic Auth.
4. The app handles 401 responses by prompting the user to re-enter or regenerate their Application Password.
For mobile integrations, apply these hardening measures:
// Set a reasonable rate limit for mobile app patterns (user-initiated actions).
wpkite_set_app_password_rate_limit( $user_id, $mobile_uuid, 30 );
// Scope to only the resources the app needs.
wpkite_add_scopes_to_app_password( $user_id, $mobile_uuid, array(
'read:posts',
'create:posts',
'update:posts',
'read:media',
'create:media',
'read:categories',
'read:tags',
) );
// Set a 90-day expiration to force periodic rotation.
wpkite_set_app_password_expiry( $user_id, $mobile_uuid, 90 );
The app should handle the app_password_expired error code gracefully, guiding the user to generate a new Application Password.
CI/CD Pipeline Integration
Continuous deployment pipelines often need to push content or trigger actions on a WordPress site. A typical pattern involves a GitHub Actions workflow that publishes content after a successful build.
# .github/workflows/deploy-content.yml
name: Deploy Content
on:
push:
branches: [main]
paths: ['content/**']
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publish to WordPress
env:
WP_USER: ${{ secrets.WP_APP_USER }}
WP_APP_PASSWORD: ${{ secrets.WP_APP_PASSWORD }}
WP_URL: ${{ secrets.WP_SITE_URL }}
run: |
curl -s -X POST \
-u "${WP_USER}:${WP_APP_PASSWORD}" \
-H "Content-Type: application/json" \
-d '{"title":"Automated Post","content":"Published via CI/CD","status":"draft"}' \
"${WP_URL}/wp-json/wp/v2/posts"
For CI/CD passwords, the hardening is more specific:
// CI/CD only needs to create posts and upload media.
wpkite_add_scopes_to_app_password( $user_id, $cicd_uuid, array(
'create:posts',
'update:posts',
'create:media',
) );
// Restrict to GitHub Actions IP ranges (these change; check GitHub's meta API).
wpkite_set_app_password_allowed_ips( $user_id, $cicd_uuid, array(
'140.82.112.0/20',
'185.199.108.0/22',
'192.30.252.0/22',
) );
// Higher rate limit for batch operations.
wpkite_set_app_password_rate_limit( $user_id, $cicd_uuid, 300 );
// 180-day expiration with automated rotation reminders.
wpkite_set_app_password_expiry( $user_id, $cicd_uuid, 180 );
External Service Webhooks and Sync
Services like Zapier, Make (formerly Integromat), and custom microservices often need to read or write WordPress data. Each service should get its own Application Password with the narrowest possible scope.
// Zapier integration: only needs to read posts and create posts.
wpkite_add_scopes_to_app_password( $user_id, $zapier_uuid, array(
'read:posts',
'create:posts',
) );
// Analytics microservice: read-only access to posts and users.
wpkite_add_scopes_to_app_password( $user_id, $analytics_uuid, array(
'read:posts',
'read:users',
'read:categories',
'read:tags',
) );
// E-commerce sync: needs broader access for product management.
wpkite_add_scopes_to_app_password( $user_id, $ecommerce_uuid, array(
'read:posts',
'create:posts',
'update:posts',
'delete:posts',
'read:media',
'create:media',
) );
Headless WordPress Frontend
A headless WordPress setup, where a JavaScript framework like Next.js or Nuxt.js serves the frontend and WordPress serves as a content API, is another common pattern. In this architecture, the frontend typically needs read-only access to published content.
However, there is an important distinction: public content (published posts, pages, categories) is accessible via the REST API without any authentication. You only need Application Passwords for the headless frontend if it needs to access draft content, private posts, or perform write operations.
For an editorial preview system where the frontend needs to display draft posts:
// Preview system needs read access to all post statuses.
wpkite_add_scopes_to_app_password( $user_id, $preview_uuid, array(
'read:posts',
'read:pages',
'read:media',
'read:categories',
'read:tags',
) );
// Restrict to the frontend server's IP.
wpkite_set_app_password_allowed_ips( $user_id, $preview_uuid, array(
'10.0.0.50', // Internal network address of the Next.js server.
) );
// Generous rate limit since every page load triggers API requests.
wpkite_set_app_password_rate_limit( $user_id, $preview_uuid, 600 );
Multi-Site Management Tools
Agencies managing multiple WordPress sites often build centralized dashboards that connect to each site's REST API. Each site gets an Application Password, and the management tool stores them securely.
For this pattern, consider creating a dedicated WordPress user with a custom role that has only the capabilities the management tool needs:
function wpkite_create_api_manager_role() {
add_role( 'api_manager', 'API Manager', array(
'read' => true,
'edit_posts' => true,
'edit_others_posts' => true,
'publish_posts' => true,
'edit_pages' => true,
'edit_others_pages' => true,
'upload_files' => true,
'manage_categories' => true,
'edit_theme_options' => false,
'manage_options' => false,
'install_plugins' => false,
'edit_plugins' => false,
'create_users' => false,
'delete_users' => false,
) );
}
register_activation_hook( __FILE__, 'wpkite_create_api_manager_role' );
This approach provides an additional layer of defense: even without custom scope middleware, the Application Password can only do what the api_manager role allows. Combined with scope restrictions, you get defense in depth.
Putting It All Together: A Complete Middleware Plugin
The individual components described throughout this article can be combined into a single, cohesive plugin. Here is a skeleton that ties together scope enforcement, rate limiting, IP restrictions, HTTPS enforcement, expiration checking, and audit logging:
/*
* Plugin Name: WPKite Application Password Middleware
* Description: Adds scoping, rate limiting, expiration, IP restrictions, and audit logging to WordPress Application Passwords.
* Version: 1.0.0
* Author: WPKite
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Priority order matters: HTTPS first, then IP, then expiration, then rate limit, then audit.
add_action( 'application_password_did_authenticate', 'wpkite_enforce_https', 1, 2 );
add_action( 'application_password_did_authenticate', 'wpkite_enforce_ip_restriction', 3, 2 );
add_action( 'application_password_did_authenticate', 'wpkite_check_app_password_expiry', 5, 2 );
add_action( 'application_password_did_authenticate', 'wpkite_rate_limit_app_password', 7, 2 );
add_action( 'application_password_did_authenticate', 'wpkite_log_app_password_request', 10, 2 );
// Scope enforcement runs on rest_authentication_errors, not on the authenticate action.
add_filter( 'rest_authentication_errors', 'wpkite_enforce_app_password_scopes', 100 );
// Disable Application Passwords for XML-RPC.
add_filter( 'wp_is_application_passwords_available_for_user', 'wpkite_disable_app_passwords_xmlrpc', 10, 2 );
// Schedule cron events.
add_action( 'init', 'wpkite_schedule_app_password_cron' );
function wpkite_schedule_app_password_cron() {
if ( ! wp_next_scheduled( 'wpkite_check_expiring_app_passwords' ) ) {
wp_schedule_event( time(), 'daily', 'wpkite_check_expiring_app_passwords' );
}
if ( ! wp_next_scheduled( 'wpkite_cleanup_audit_log' ) ) {
wp_schedule_event( time(), 'weekly', 'wpkite_cleanup_audit_log' );
}
}
add_action( 'wpkite_check_expiring_app_passwords', 'wpkite_notify_expiring_passwords' );
add_action( 'wpkite_cleanup_audit_log', 'wpkite_purge_old_audit_entries' );
The priority values on the application_password_did_authenticate hooks determine the order of execution. HTTPS enforcement runs first (priority 1) because there is no point proceeding if the connection is insecure. IP restriction runs next (priority 3) to block unauthorized origins early. Expiration checking (priority 5) catches stale credentials before they consume rate limit budget. Rate limiting (priority 7) throttles valid but excessive usage. Finally, audit logging (priority 10) records the request after all security checks have passed.
Scope enforcement operates on a different hook (rest_authentication_errors) because it needs access to the REST API route information, which is not available during the authentication phase. The priority of 100 ensures it runs after any other authentication error handlers.
Operational Considerations and Failure Modes
Deploying Application Password middleware in production requires thinking about failure modes, debugging workflows, and operational burden.
What Happens When the Database Is Slow
Audit logging, rate limiting, and scope checking all hit the database. If your database is under load, these additional queries compound the problem. Consider these mitigations:
Use the WordPress object cache for rate limit counters. If you have Redis or Memcached configured, transient reads and writes bypass the database entirely. For audit logging, consider writing to a file or an external logging service (via a non-blocking HTTP call) instead of inserting a database row on every request. The WP-Cron cleanup job for audit logs can also be resource-intensive if the table is large; run it during low-traffic periods.
Debugging Authentication Failures
When an Application Password stops working, the debugging process follows this sequence:
1. Check if the Application Password still exists: WP_Application_Passwords::get_user_application_passwords( $user_id ).
2. Check if it has expired (if you have implemented expiration).
3. Check if the requesting IP is allowed (if you have implemented IP restrictions).
4. Check the rate limit counter (if you have implemented rate limiting).
5. Check the audit log for recent activity patterns.
6. Verify that the user account is still active and has not been demoted to a role with insufficient capabilities.
Add a debug mode to your middleware that logs detailed information about each check:
define( 'WPKITE_APP_PASSWORD_DEBUG', true );
function wpkite_debug_log( $message ) {
if ( defined( 'WPKITE_APP_PASSWORD_DEBUG' ) && WPKITE_APP_PASSWORD_DEBUG ) {
error_log( '[WPKite App Password] ' . $message );
}
}
Handling the application_password_failed_authentication Hook
This hook fires when someone provides Basic Auth credentials but no Application Password matches. It is a valuable signal for detecting brute-force attacks:
add_action( 'application_password_failed_authentication', 'wpkite_handle_failed_auth' );
function wpkite_handle_failed_auth( $error ) {
$ip = sanitize_text_field( $_SERVER['REMOTE_ADDR'] );
$key = 'wpkite_failed_auth_' . md5( $ip );
$failures = (int) get_transient( $key );
$failures++;
set_transient( $key, $failures, HOUR_IN_SECONDS );
if ( $failures >= 10 ) {
error_log( sprintf(
'Potential brute-force: %d failed Application Password attempts from %s in the last hour.',
$failures,
$ip
) );
// Optionally trigger an alert or block the IP via .htaccess or a firewall API.
}
}
Testing Application Password Authentication Locally
DDEV and other local development environments typically run on HTTP, not HTTPS. WordPress core allows Application Passwords on localhost by default, but if you have added custom HTTPS enforcement, make sure your development domains are excluded (as shown in the HTTPS enforcement section).
For testing with curl:
# Generate an Application Password in wp-admin, then:
curl -v -u "admin:ABCD efgh 1234 IJKL mnop QRST" \
https://wpkite.com/wp-json/wp/v2/posts?per_page=1
The spaces in the password are optional; WordPress strips them during authentication. Including them matches the format shown in the admin UI and makes the password easier to read.
Security Checklist for Application Password Deployments
Before going live with Application Passwords on a production site, walk through this checklist:
Transport security. Confirm HTTPS is enforced site-wide. Check that the SSL certificate is valid and covers all domains (including www and non-www). Verify that HTTP requests are redirected to HTTPS at the server level, not just via WordPress.
Password hygiene. Ensure each integration has its own Application Password. Never share a single password across multiple services. Label each password clearly so administrators know what it is for.
Scope restrictions. Implement the scope middleware described in this article, or at minimum, create dedicated user accounts with limited roles for each integration. An API user with the Editor role is better than one with the Administrator role.
Expiration policy. Set expiration dates on all Application Passwords. 90 days is a reasonable default for most integrations. Implement the notification system to warn users before passwords expire.
Rate limiting. Configure per-password rate limits based on expected usage patterns. Monitor for anomalies.
Audit logging. Enable the audit log and review it periodically. Set up alerts for unusual patterns (requests from new IPs, sudden spikes in volume, access to sensitive endpoints).
IP restrictions. For server-to-server integrations with static IPs, restrict Application Passwords to those IPs. For dynamic-IP clients (mobile apps, home offices), skip this control but tighten others.
XML-RPC. Disable Application Password authentication for XML-RPC unless you specifically need it. Most modern integrations use the REST API exclusively.
Monitoring. Track the application_password_failed_authentication hook for brute-force detection. Consider integrating with your existing security monitoring tools.
Documentation. Document which Application Passwords exist, who created them, what they are for, and when they expire. This information is invaluable when debugging integration failures or responding to security incidents.
Application Passwords are a solid foundation for WordPress API authentication. Their simplicity is both their strength and their limitation. By layering custom middleware for scoping, rate limiting, expiration, IP restrictions, and audit logging, you can build a security posture that matches the requirements of production systems while keeping the operational simplicity that made Application Passwords appealing in the first place. The code in this article provides a starting point; adapt it to your specific threat model, traffic patterns, and operational workflows.
David Okonkwo
Application security engineer focused on WordPress. OWASP contributor and former penetration tester. Writes about REST API security, authentication, and hardening.