The security and role model: app-request auth, the pid soft-nonce, capabilities

The concept, and why it matters

MAM Suite serves two very different kinds of callers from the same WordPress install, and the security model has to keep them straight:

  • The website side — a logged-in admin sitting in wp-admin, configuring the app through the Mobile App Manager screens. This is an ordinary WordPress session: a real wp_get_current_user(), real cookies, real capabilities, and (where it’s been hardened) real nonces.
  • The app side — a mobile client in the field, owned by an end customer who is not a WordPress admin and never sees wp-admin. The app calls AJAX endpoints over HTTP, identifies itself with a device id, and expects a JSON response.

These two callers hit overlapping endpoints (many actions register both wp_ajax_* and wp_ajax_nopriv_*), but they authenticate in completely different ways. Understanding which world a request is in is the single most important thing to get right when you touch an endpoint, add a capability check, or audit a path for security. Apply WordPress’s normal session/nonce assumptions to an app request and you’ll either break legitimate mobile clients or wave through requests you meant to block.

This article explains the three pillars of that model: how app requests authenticate from a pid, why app AJAX uses pid as a soft nonce instead of a real WP nonce, and where the hard capability checks actually live.


App-request authentication: the pid is the identity

An app user is not a WordPress session user. They don’t carry an auth cookie, and wp_get_current_user() does not return them. Instead, every app request carries a pid (phone id / device token) in the request, and MAM resolves the app user from that.

The resolution is a usermeta lookup. The pid is stored against the WP user as the mam_app_token meta key at login/registration time, so resolving the current app user means finding the user whose mam_app_token equals the incoming pid:

// includes/helper-classes/user-manager-classes.php
$user = get_users( array(
    'meta_key'   => 'mam_app_token',
    'meta_value' => $_REQUEST['pid'],
) );

The resolved user is then stored in per-request context rather than in WordPress’s current-user globals. That context lives in MAM_Current_Request (includes/request-context/class-mam-current-request.php), a small final class with static state that holds the resolved WP_User, the user id, the active role, the pid, and the cloning flag. Its docblock is explicit about the distinction:

App users are NOT the same as wp_get_current_user() — they don’t share WP’s session handling. The mam_update_current_user action resolves the app user from the pid request param and populates this class.

The public API you should use is the helper pair mam_user() / mam_user_id() (and mam_user_role(), mam_is_cloning()). These route through MAM_Current_Request rather than reading globals directly. The resolution is triggered by the mam_update_current_user action, so anywhere that fires that action gets a freshly-resolved app user before reading it back.

Why a pid and not a cookie? The mobile client is a long-lived installed app, not a browser with a cookie jar tied to WordPress’s session lifetime. The device id is a stable identifier the server already knows about from the registration flow, and it survives app relaunches and screen transitions in a way short-lived session state does not.

Cloning: admins previewing as an app user

There’s a third actor worth knowing about. An admin can “view as” an app user — previewing the app exactly as a given role/user would see it. This is the cloning path. When an admin is cloning, MAM swaps the effective pid to the cloned user’s token (driven by the admin_is_cloning usermeta) and sets the is_cloning flag on the request context.

The canonical flag is MAM_Current_Request::is_cloning() (exposed as mam_is_cloning()). Any code that gates behavior on the user’s role should check is_cloning() first, because during a clone the “current user” is deliberately not the admin who actually made the request. Cloning also forces a cache bypass in the phone-data pipeline so the admin sees live, un-cached output.


Roles: app users live in their own role space

App users are assigned MAM-specific roles at registration time ($user->add_role( $this->new_user_role ) in the user-roles orchestrator). The role determines what the app shows them — the phone-data pipeline shapes the mobile JSON per role, resolving the buttons and content blocks each role is allowed to see through the mam_app_settings_get_buttons filter.

The important mental model: app roles gate app content, not WordPress admin capabilities. An app role is about “which tabs, buttons, and screens does this customer get in the mobile app.” It is not a WordPress capability grant. App users have no business in wp-admin, and the role they carry is not what protects admin endpoints — capabilities do that (see below).


The pid as a soft nonce, and the real nonce that rides alongside it

This is the part that most often trips up a developer (or a security reviewer) coming from stock WordPress, so it’s worth being precise. There are actually two mechanisms in play on the app side, and they do different jobs.

1. pid as a soft nonce

On app-side endpoints, the presence of a valid pid is treated as a de-facto soft nonce. The reasoning: the pid is a device token the server handed out and stored during registration, it’s reasonably hard to guess, and — crucially — it is not coupled to a short-lived clock. Real WordPress nonces (wp_create_nonce / wp_verify_nonce) expire on a ~12–24h tick. A mobile app may sit on a screen, or between launches, far longer than a nonce’s lifetime, so a hard nonce check on every app call produces spurious failures for perfectly legitimate clients. That timing problem is exactly why the team leans on pid instead of a real nonce on the general app read paths.

So when you audit app-side nopriv AJAX and find no wp_verify_nonce call, that is not automatically a finding. Note it, prefer the pid check, but don’t escalate it as a blocker on that basis alone. Tightening nonces on app endpoints is on the future-improvements list, not a launch-readiness gate.

2. The real per-pid nonce on transactions

The picture is more nuanced than “app endpoints have no nonces,” though. For state-changing app transactions — login being the prominent one — MAM does use a real WordPress nonce, scoped to the device.

The phone-data response embeds a freshly-minted nonce keyed to the requester’s pid:

// includes/phone-data/class-mam-phone-data-pipeline.php
$user_code = isset( $_REQUEST['pid'] ) ? sanitize_text_field( $_REQUEST['pid'] ) : '';
$context->set( 'mam_nonce', wp_create_nonce( 'mam_main_nonce_' . $user_code ) );

The app stores that mam_nonce and echoes it back as login_nonce on the login call, where the login manager verifies it against the same per-pid action name:

// includes/user-roles/login-manager.php
$user_code = sanitize_text_field( $formdata['pid'] );
$valid     = wp_verify_nonce( $formdata['login_nonce'], 'mam_main_nonce_' . $user_code );

Because the nonce action is namespaced with the pid (mam_main_nonce_<pid>), a nonce minted for one device won’t validate for another. This is the “website transactions are still nonce-hardenable” idea applied at the app boundary: the read path stays soft (so the app never gets stuck on an expired token), but the transaction path carries a real, device-scoped nonce that the app refreshes every time it pulls phone data.

The takeaway: don’t assume app endpoints are nonce-free across the board. Read paths lean on pid; sensitive transactions carry the per-pid mam_main_nonce_* nonce. When you add a new app-side write, prefer the device-scoped nonce pattern over inventing a new bare check.


Where the capability checks live

The hard authorization for website/admin functionality is a WordPress capability check, and it sits right at the top of the handler. The dominant gate across the codebase is:

if ( ! current_user_can( 'manage_options' ) ) {
    // bail
}

You’ll find this guarding the admin-facing AJAX and settings handlers throughout — app-settings saves, image manager, nav AJAX handler, notification settings, setcron settings, the setup wizard, the publish-app flow, and so on. A few endpoints use the more specific capability that matches the action: software/plugin management checks install_plugins and activate_plugins rather than the blanket manage_options.

The rule of thumb that falls out of this:

Caller world What protects the endpoint
Website / admin (browser in wp-admin) current_user_can( 'manage_options' ) (or a more specific cap), plus a real nonce where the path has been hardened
App (mobile client) a valid pid (soft nonce) for reads; the per-pid mam_main_nonce_* nonce for sensitive transactions

This is also the lens for a security audit. A website-side endpoint that registers nopriv and skips both a capability check and a nonce is a legitimate finding — harden it and drop the unnecessary nopriv registration. An app-side endpoint that relies on pid instead of a hard nonce is expected behavior, not a bug.

Note that many actions register both wp_ajax_* and wp_ajax_nopriv_* precisely because they may be called by an admin (logged in) or by the app (not a WP session). The shared handler then has to know which world it’s in and apply the right check — which is why the capability gate guards the admin-only branches rather than the whole function.


Sanitization duty

There is no framework-level auto-sanitization here. MAM reads request input directly from $_REQUEST, $_POST, and $_GET, which means the handler that reads a value is responsible for sanitizing it. The conventions you’ll see in the code:

  • sanitize_text_field( $_REQUEST['pid'] ?? '' ) for the device token and other free-text values.
  • sanitize_key( $_REQUEST['pid'] ) where the value is being used to build an option/meta key (e.g. <pid>-app_lang), so it must be reduced to a safe key shape.
  • Capability and nonce checks performed before any side effect, not after.

When you add or touch a handler, treat every $_REQUEST read as untrusted and sanitize at the point of use with the function that matches how the value will be consumed (text field vs. key vs. int). Don’t lean on a caller upstream having cleaned it — the procedural, globally-included helper style in app-connect and user-roles means there often isn’t a single chokepoint upstream to rely on.


How this connects to the rest of the system

  • Frozen public contracts. The app-side action names (local_app_get_phone_data, mam_get_phone_data, mam_user_roles_cred_handler) and the option keys are frozen — deployed mobile clients call them by name. You can refactor the handler behind a contract, but the auth-bearing surface (the action names, the pid/login_nonce request params) is part of that contract. See Frozen public contracts reference.
  • The phone-data pipeline. The pipeline is where the per-pid nonce is minted and where per-role content shaping happens, so it’s the natural place to look when reasoning about both “what is this app user allowed to see” and “what nonce will the next transaction carry.”
  • Login and the user-roles subsystem. The legacy mam_user_roles_cred_handler and modern mam_login_handler both route to the same login manager, which is where the mam_main_nonce_* verification lives. Older clients still call the legacy name, which is why both are kept.

  • Frozen public contracts reference (mam-main)
  • Architecture overview (mam-main)
  • AJAX action: local_app_get_phone_data
  • Settings cascade overview
Was this article helpful?
Contents

    Need Support?

    Can't find the answer you're looking for? Don't worry we're here to help!