Application passwords, and the integration that stopped needing a plugin

The warehouse integration authenticated as a person. It had been set up in 2018 with the marketing manager’s username and password because that was the only way to call the REST API, and when that account was closed in November the stock sync stopped — and nobody connected the two events for two days.

The symptom

$ wp user list --role=administrator --fields=user_login,user_email
| user_login | user_email                 |
| admin      | [email protected]         |
| s.marketing| (deactivated)              |
| wh-sync    | [email protected]      |
| zapier     | (none)                     |

# four administrators. two are integrations, one is a
# person who left.
$ grep -rn 'wp-json' /opt/integrations/*.env
WP_USER=s.marketing
WP_PASS=<a real login password>

A person’s real password, in a configuration file, on a machine in a different company. Rotating it locks them out; disabling the account breaks the integration; and the audit log records every stock update as having been made by them.

Why it happens

The REST API shipped in 4.7 with cookie authentication for the admin interface and nothing else built in. Every external integration therefore needed a plugin — JWT, OAuth, basic auth — or a real user account, and the real user account is the option that requires installing nothing.

5.6 shipped application passwords in December, four years later, and it is the mechanism that should have been there from the start: a per-integration credential, revocable independently, attached to a user but not usable to log in.

The fix

A credential per integration

$ wp user application-password create wh-sync 'warehouse stock sync'
| password  | Xk4m 9Qp2 Lz7v Ba1s Nc3e |

# shown once, stored hashed. it cannot be retrieved later —
# only revoked and replaced.
$ curl -u 'wh-sync:Xk4m 9Qp2 Lz7v Ba1s Nc3e' 
    https://example.test/wp-json/wp/v2/product/8814
{"id":8814,...}

$ curl -u 'wh-sync:Xk4m 9Qp2 Lz7v Ba1s Nc3e' 
    https://example.test/wp-admin/
# → the login form. it is API-only.

Being unusable for the admin interface is the property that makes this worth doing: a leaked application password cannot be used to log in and look around, only to make the API calls the user’s capabilities allow. That is a smaller blast radius than a real password by a considerable margin.

The spaces in the generated password are cosmetic — the server strips them — and they exist so a human can transcribe it. Anything storing it can keep them or not.

// HTTPS is required — worth knowing before debugging a 401
// on a staging site that is not on TLS
add_filter( 'wp_is_application_passwords_available', 'is_ssl' );

// and it can be refused per user, which is how an
// administrator is prevented from having one at all
add_filter(
    'wp_is_application_passwords_available_for_user',
    function ( $available, $user ) {
        return $available && ! user_can( $user, 'manage_options' );
    },
    10,
    2
);

Refusing application passwords for administrators is a policy worth adopting on any site with real integrations, because it forces the question of what the integration actually needs. An integration that genuinely requires manage_options is doing something that should be examined.

The role the integration should have had

add_action( 'init', function () {
    if ( get_role( 'stock_sync' ) ) {
        return;
    }

    add_role( 'stock_sync', 'Stock sync', array(
        'read'                => true,
        'edit_products'       => true,
        'edit_others_products'=> true,
        'read_private_products' => true,
    ) );
} );

Four capabilities instead of the sixty an administrator has, and the integration works identically. The reason it was an administrator was that nobody enumerated what it needed, and enumerating it took twenty minutes of reading the integration’s own code.

Guarding add_role on existence matters because it writes to the options table on every call, and a role added on init without the check is a database write on every request. Changing a role later also requires removing and re-adding it, since add_role does nothing when the role exists — which is the second half of the same gotcha.

Auditing what a credential does

add_action( 'application_password_did_authenticate', function ( $user, $item ) {
    turkerdev_log( 'api.auth', array(
        'user'    => $user->user_login,
        'app'     => $item['name'],
        'uuid'    => $item['uuid'],
        'ip'      => $_SERVER['REMOTE_ADDR'] ?? null,
        'route'   => $GLOBALS['wp']->query_vars['rest_route'] ?? null,
    ) );
}, 10, 2 );

The core tracking records only the last-used date and IP, which answers whether a credential is still in use and nothing about what it does. The hook gives the per-request record, and the credential name in it is what turns a log line into something identifiable — which is why the names have to be meaningful when the password is created.

# and the review this makes possible, quarterly
$ wp user application-password list wh-sync 
    --fields=name,created,last_used,last_ip
+---------------------+------------+------------+-------------+
| warehouse stock sync| 2020-12-14 | 2021-03-02 | 51.x.x.x    |
| old zapier hook     | 2020-12-14 | 2021-01-08 | 34.x.x.x    |

# the second has not been used in two months.
$ wp user application-password delete wh-sync <uuid>

What it does not solve

no scopes         a credential has ALL of the user's
                  capabilities. "read products only" is not
                  expressible — it needs a dedicated role.
no expiry         valid until revoked. nothing prompts one.
no rate limiting  nothing in core. a leaked credential works
                  as fast as the server allows.
HTTP Basic        as safe as the transport, and no safer.
                  HTTPS is required and is the protection.

The absence of scopes is the significant limitation and it is why the role is not optional — the credential is a way to authenticate, and authorisation is still entirely the user’s capabilities. An integration needing read access to products and nothing else gets that by having a role with nothing else.

The lack of expiry means the quarterly review is the only mechanism, and a review that depends on somebody remembering is a review that happens twice. Putting the last_used query in the same weekly report as the plugin updates is what makes it actually recur.

Verifying it worked

$ wp user list --role=administrator --fields=user_login
+------------+
| admin      |

$ wp user list --role=stock_sync --fields=user_login
+------------+
| wh-sync    |

$ wp user delete s.marketing --reassign=1
Success: Removed user 14.

# and the assertion that the role is actually restrictive
$ wp eval 'var_dump( user_can( get_user_by("login","wh-sync"), "manage_options" ) );'
bool(false)

$ curl -u 'wh-sync:...' -X POST 
    https://example.test/wp-json/wp/v2/users -d '{"username":"x"}'
{"code":"rest_cannot_create_user","data":{"status":403}}

The 403 on user creation is the check worth writing down, because it is the specific thing the old arrangement allowed — a compromised integration credential could create an administrator. Asserting the negative capability directly is better than trusting that the role list is right.

Deleting the departed user’s account with --reassign is what finally closed the original problem, and it could not be done until the integration stopped depending on it. That dependency is the reason accounts of people who have left survive for years.

What this costs

A role per integration, which is one more thing in the codebase and is a real improvement over the alternative. The cost is that roles are stored in the options table rather than in code, so a role defined in a plugin and a role in the database can disagree — and the database wins. Anybody debugging a capability problem needs to know that before they start editing the array.

The larger limitation is that this is authentication without authorisation scoping, four years after the API shipped. It removes the person-shaped credential and the shared password, which is most of the value; it does not give a way to say “this credential may read products and nothing else” without inventing a role for it. That is a reasonable place for core to have stopped and it is worth being clear that the role is doing the security work.