Skip to main content
Locking Down a Custom REST Endpoint: The Auth Mistake I Made So You Don't Have To

Locking Down a Custom REST Endpoint: The Auth Mistake I Made So You Don’t Have To

I found out a custom REST endpoint I'd built for a client's mobile app was wide open to the public internet when a routine log review turned up a request from…

I found out a custom REST endpoint I’d built for a client’s mobile app was wide open to the public internet when a routine log review turned up a request from an IP I didn’t recognize, pulling customer order data through an endpoint that had no business answering an unauthenticated request. The cause was almost funny in hindsight: during local development I’d set permission_callback to __return_true so I could test the endpoint quickly in Postman without fighting auth, meant to swap it out before launch, and never did. WordPress’s REST API will happily register a route with no authentication at all if you tell it to, and it won’t warn you.

The fix that finally stuck wasn’t a smarter one-off permission check — it was refusing to let a route reach register_rest_route without one. Every custom endpoint in that codebase now goes through a small wrapper function that requires an explicit permission_callback argument with no default, so leaving it out is a PHP error at registration time instead of a silent security hole in production. For endpoints that need real authentication rather than a capability check, I use WordPress’s application passwords rather than rolling custom token logic — wp_authenticate_application_password handles the credential verification, and I just gate the route behind current_user_can for the specific capability the mobile app actually needs, nothing broader.

Scoping that capability precisely turned out to matter as much as requiring a callback at all. My first fix gated the orders endpoint behind edit_posts, which technically worked but meant any contributor-level account on the site could hit an endpoint meant only for the mobile app’s service account. I ended up registering a dedicated capability, read_customer_orders, assigned to exactly one application-password user, so the blast radius of a leaked credential is one integration instead of every editor on the team.

I test this now the same way I’d test any other security-sensitive code path: a Pest test that asserts an unauthenticated request to the endpoint returns a 401, and a second test asserting a request with the wrong capability returns a 403. Those two assertions would have caught the original bug in CI, before it ever reached a server with real customer data on it.

The uncomfortable lesson wasn’t about REST API mechanics — it was that “I’ll fix it before launch” is not a security control. The fix that actually works is the one the framework enforces whether I remember to or not.