WordPress REST API Authentication & Permissions: Capabilities Done Right
Introduction
For a same-origin consumer — a block, an admin screen, a piece of JavaScript enqueued on your own site — WordPress already has a complete authentication system: cookies for identity, nonces to prove the request came from your own page, and capabilities to decide what that identity is allowed to do. The work isn’t building auth from scratch; it’s wiring permission_callback to the right capability check for every single route. This page is scoped to that native, same-origin context. A separate-origin consumer — a headless frontend on its own domain — cannot use cookies or nonces at all and needs a token-based scheme instead, covered in Headless Auth.
The Challenge
The single biggest failure mode in REST API permission design isn’t a missing check — it’s the wrong check. “Is the user logged in?” is not the same question as “can this user do this specific thing,” and treating them as interchangeable produces one of two bad outcomes: an editor able to hit an endpoint meant for administrators, or every non-trivial endpoint locked to manage_options out of caution because nobody wanted to think through the real capability. Neither is a permission model — both are guesses standing in for one.
Standards and Best Practices
Every permission_callback should answer one specific question: does this user hold the capability this exact action requires? Not “are they logged in,” not “are they an admin” — the actual WordPress capability that maps to the action, the same way core itself checks edit_posts before letting someone edit a post rather than just checking for any authenticated session. Build an explicit allow/deny map before writing any callback: list every route, list the action it performs, and assign the specific capability that action requires in the native WordPress capability system — edit_posts, publish_posts, manage_options, or a custom capability registered for the project. For requests from outside the browser session entirely — a script, a CI job, a third-party service authenticating as a real WordPress user — Application Passwords are the native, cookie-free mechanism; they still resolve to a real user with real capabilities, so the same allow/deny map applies to them without a separate permission system. Never treat “authenticated” as a stand-in for “authorized” — they answer different questions and collapsing them into one check is how both over-restriction and under-restriction happen.
Practical Application
The capability allow/deny mapping this page is built around — three requests to the same namespace, three different required capabilities:
A permission callback built around the specific capability an action requires, not a blanket login check:
register_rest_route( '4wp/v1', '/projects/(?P<id>\d+)', array(
'methods' => 'PUT',
'callback' => 'forwp_update_project',
'permission_callback' => function( $request ) {
$post = get_post( (int) $request['id'] );
if ( ! $post ) {
return new WP_Error( 'rest_not_found', 'Project not found.', array( 'status' => 404 ) );
}
// The specific capability this action requires — editing THIS post — not just "is logged in."
return current_user_can( 'edit_post', $post->ID );
},
) );
Potential Challenges
A capability map drawn up correctly at launch tends to drift as new routes get added under time pressure, each one copying whatever permission callback was closest at hand rather than being mapped against its own required capability. Custom capabilities registered for a project also need to be assigned to the right roles deliberately — a capability that exists in code but was never granted to the intended role behaves exactly like a bug, denying access nobody meant to deny.
Common Mistakes
Checking is_user_logged_in() as the entire permission logic, which grants every authenticated user — subscriber included — the same access as an editor. Overcorrecting into current_user_can( 'manage_options' ) on every custom route regardless of what the route actually does, which technically “works” but locks out every role except administrator for actions they should legitimately be able to perform. Forgetting that Application Passwords still resolve to a real WordPress user and therefore still need the same capability checks — treating “it came in via an Application Password” as itself a form of authorization.
When It’s Better to Bring In a Specialist
A permission model that’s wrong in the restrictive direction gets reported immediately; one that’s wrong in the permissive direction can sit unnoticed until it’s a real incident. A WordPress developer who builds the capability allow/deny map deliberately, route by route, against the project’s actual role structure closes that gap before it becomes a security review finding. This kind of permission audit is a focused, high-value piece of WordPress development services for any site exposing custom REST routes to more than one user role.
FAQ
Authentication answers “who is making this request” (cookies plus a nonce, or an Application Password). Authorization answers “what is this specific person allowed to do” — that’s the capability check inside permission_callback, and it’s the part that needs the most deliberate design.
No. It only confirms someone is authenticated, not that they hold the specific capability the action requires — a logged-in subscriber and a logged-in administrator both pass that check, which is rarely the intended access model.
Nonces are for same-origin JavaScript running in an active browser session. Application Passwords are for external clients — scripts, CI jobs, other servers — authenticating as a real WordPress user without a browser session; both still go through the same capability checks afterward.
No — nonces and cookies require the same origin and an active browser session. A separate-origin frontend needs a token-based authentication scheme instead; see the Headless Auth page for that pattern.
No. Each route should map to the specific capability its specific action requires — a read endpoint might need none, an edit endpoint needs the capability for editing that resource, and an admin-only action needs the capability that gates that admin function specifically.
Whenever a route is exposed to more than one user role, or before launch on anything handling non-public data — reviewing the capability map against actual role assignments catches both over- and under-restriction before either becomes a real problem.

