← All posts

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 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.