← Back to blog

WordPress application passwords: why the section is missing, and what they do not protect you from

The SandyWP team 7 min read

An application password is a 24-character credential that authenticates one integration against the REST API or XML-RPC, without handing over the account password. You create it under Users, Profile, and you can revoke it on its own.

Two things about it are almost never written down. The section vanishes based on what PHP sees, not on whether your site is served over HTTPS. And the password it hands out carries every capability the user has, with no way to narrow it.

All core code below was read from WordPress/wordpress-develop trunk on 2026-09-25.

Why the Application Passwords section is missing

Availability comes from one function, wp_is_application_passwords_supported() in wp-includes/user.php:

function wp_is_application_passwords_supported() {
	return is_ssl() || 'local' === wp_get_environment_type();
}

So it is is_ssl() that decides, and is_ssl() in wp-includes/load.php looks at exactly two things: $_SERVER['HTTPS'] being on or 1, or $_SERVER['SERVER_PORT'] being 443. It never reads home_url() and it never reads a forwarded header.

That is the whole bug. If a reverse proxy, a load balancer or a CDN terminates TLS and then talks to PHP over plain HTTP on port 80, is_ssl() returns false on a site whose visitors are unambiguously on HTTPS. The browser shows a padlock and the profile screen shows no Application Passwords box.

The fix goes in wp-config.php, above the require_once ABSPATH . 'wp-settings.php'; line:

if ( isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] )
	&& 'https' === strtolower( explode( ',', $_SERVER['HTTP_X_FORWARDED_PROTO'] )[0] ) ) {
	$_SERVER['HTTPS'] = 'on';
}

Only do that when you control the proxy and it strips the header from inbound requests. A client can otherwise set X-Forwarded-Proto itself.

The other route is the filter, which skips the TLS check entirely:

add_filter( 'wp_is_application_passwords_available', '__return_true' );

That is the wrong tool on a production site, because basic auth credentials travel in a header on every request. It is reasonable on a throwaway install where you are reproducing the problem.

Note the second branch of the supported check. wp_get_environment_type() returning local also enables the feature, which is why the box is there on your laptop and gone on the server.

Why a correct password still returns 401

Core reads the credentials from $_SERVER['PHP_AUTH_USER'] and $_SERVER['PHP_AUTH_PW'], in wp_validate_application_password():

if ( ! isset( $_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'] ) ) {
	return $input_user;
}

Nothing in core reconstructs those from the Authorization header. PHP itself populates them when it runs as an Apache module, and does not when it runs as FastCGI, which covers nginx plus PHP-FPM and most modern Apache setups.

The result is a silent failure. The request arrives with a perfectly good header, core never sees a username, and the REST API answers rest_not_logged_in with a 401 that looks like a wrong password.

On nginx, pass it through in the PHP location block:

fastcgi_param PHP_AUTH_USER $http_authorization;

That is a blunt version. The cleaner one is to split the decoded header, which most distributions already have a snippet for. On Apache with FPM, the equivalent is an .htaccess rule:

SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1

Test which side is broken before you change any config. This returns the user object when authentication worked and an error when it did not:

curl -s --user "admin:xxxx xxxx xxxx xxxx xxxx xxxx" \
  https://example.com/wp-json/wp/v2/users/me | head -c 200

Spaces in the password are fine. Core runs preg_replace( '/[^a-z\d]/i', '', $password ) before checking, and generated passwords are alphanumeric only, so the readability grouping is stripped either way.

Application passwords are not scoped, at all

This is the limit that matters most and the one the ranking guides skip. wp_authenticate_application_password() ends by returning the WP_User object:

WP_Application_Passwords::record_application_password_usage( $user->ID, $item['uuid'] );
...
return $user;

From that point the request is that user. There is no capability subset attached to the password, no read-only mode, and no per-endpoint restriction. A password you made so a reporting script could read post counts can also delete every page, install a plugin and edit functions.php if the user is an administrator.

The only real scoping mechanism is the user account. Make a dedicated user with the lowest role that does the job, and put the application password on that user rather than on your own administrator account.

If you need a genuine restriction, the hook is wp_authenticate_application_password_errors, which fires after the password matches and before the user is returned. Adding an error to the passed WP_Error rejects the request, and the callback receives the matched $item, so you can key a rule to one password's name or UUID.

The Last Used column is a daily bucket

The profile screen lists each password with a Last Used date, and it is not a live figure. From WP_Application_Passwords::record_application_password_usage():

// Only record activity once a day.
if ( $password['last_used'] + DAY_IN_SECONDS > time() ) {
	return true;
}

So a password used four thousand times today records one timestamp. The column answers "is this still in use", and it cannot answer "was it used at 3am".

The IP beside it is $_SERVER['REMOTE_ADDR'] written directly. Behind the same proxy that broke is_ssl(), that is the proxy's address on every row, not the caller's.

If you need a real audit trail, the two actions to hook are application_password_did_authenticate and application_password_failed_authentication. Both pass enough to log properly, and neither is rate limited.

Two more things that are true and surprising

A password reset does not revoke application passwords. wp_set_password() writes user_pass and user_activation_key and nothing else, so every integration keeps working after the reset, which is either convenient or alarming depending on why you reset it. The same gap applies to logged-in sessions, covered in resetting a WordPress password.

And the authorization flow hands the plaintext password to another domain. When an app sends a user to wp-admin/authorize-application.php with a success_url, core appends site_url, user_login and password as query parameters and redirects. The source is explicit about why that is not wp_safe_redirect():

// Explicitly not using wp_safe_redirect b/c sends to arbitrary domain.

Validation requires an HTTPS scheme, or a host of 127.0.0.1 or [::1]. It does not check the domain against anything, and localhost by name is not on the loopback list. Read the domain in the URL before you approve one of these.

Managing them from the shell

WP-CLI covers the lifecycle with wp user application-password, documented on developer.wordpress.org with seven subcommands: create, delete, exists, get, list, record-usage and update.

wp user application-password create admin "deploy bot" --porcelain
wp user application-password list admin --fields=name,created,last_used,last_ip
wp user application-password delete admin --all

create --porcelain prints just the password, which is what you want in a script. The plaintext is not recoverable afterwards: core stores wp_fast_hash() of it, which since 6.8 is BLAKE2b rather than phpass.

Reproduce it before you change the server

Most of the failures above are configuration, and configuration is the thing you should not be guessing at on a live site. The useful move is to stand up a throwaway WordPress install on the same PHP version, break it the same way, and confirm the fix works before touching production.

Spin up a sandbox, get a shell into it with SSH and SFTP, edit wp-config.php and watch whether the Application Passwords section appears. Then curl the REST endpoint from outside and see which of the two failure modes you actually have. WP-CLI is available in the same shell, so you can create and revoke passwords without the profile screen at all.

If the integration you are debugging is an AI agent rather than a script, the same credentials problem shows up, and SandyWP AI covers how agents get into a sandbox in the first place.

When it works in the sandbox, delete it. That is what a disposable install is for, and it is why this kind of test does not belong on staging, where you would rather not leave a wp_is_application_passwords_available filter behind.

When an application password is the wrong answer

If a human needs to log in, this is not it. Application passwords are blocked at wp-login.php by design: authentication only runs when the request is XML-RPC or REST, checked in wp_authenticate_application_password() and adjustable only through the application_password_is_api_request filter.

If one integration needs to act as several users, one password per user is the honest structure, not one administrator password shared around.

And if the thing on the other end is a plugin you control, a signed request with your own secret gives you the scoping that application passwords do not. Use them for the case they were built for: giving one outside tool one revocable way in.