Skip to main content
Skip to main content

Developers Doc

Security: A Developer's Guide to Nonces, Capabilities & Tokens

13 mins read 6 Views 3+

Security in Havenlytics is not an afterthought bolted onto a few handlers — it is a set of consistent conventions applied across every admin AJAX endpoint, REST route, form, and email header in the plugin. This guide documents those conventions exactly as they exist in Havenlytics 3.3.1 so that any code you write to extend Havenlytics — a custom AJAX action, a REST controller, an admin action link — matches the same bar. Every nonce action, capability string, sanitizer, and token below is real and verbatim from the plugin source.

Security: The three rules that hold everywhere in Havenlytics: verify a nonce before trusting a request, check a capability before performing the action, and sanitize on input, escape on output. Skipping any one of them is the usual root cause of a vulnerability. Treat this page as the checklist.

Nonce actions inventory

Havenlytics uses WordPress nonces on every state-changing request. Admin AJAX endpoints use custom action nonces; REST routes rely on the standard wp_rest nonce plus a permission_callback. The table below lists the nonce actions the plugin registers, so you know which token guards which surface.

Nonce action(s)SubsystemNotes
hvnly_import_nonce + hvnly_import_csrfProperty Import WizardDouble nonce (see below). Both must pass.
hvnly_ws_auth_loginhvnly_ws_auth_registerhvnly_ws_auth_logouthvnly_ws_auth_lostpasswordhvnly_ws_auth_resetpasswordhvnly_ws_auth_verify_emailhvnly_ws_auth_resend_verificationWorkspace authSessionAuthController NONCE_* constants, verified via verify_nonce().
hvnly_ws_auth_noncesWorkspace authBundle nonce exposed to the SPA.
hvnly_mgmt_exportAgent managementGuards the agents CSV bulk export redirect.
hvnly_agent_identity_scanAgent identity adminIdentity/verification admin scan action.
hvnly_cache_nonceCache adminClear-cache admin AJAX.
hvnly_ajax_request / hvnly_ajax_nonceFrontend AJAXShared frontend AJAX nonces. Contact Agent endpoints use hvnly_ajax_request.
hvnly_property_agents_nonceProperty/agent metaboxSave assigned agents.
hvnly_agent_profile_nonceAgent profileSave agent profile fields.
hvnly_agency_term_save / hvnly_agency_term_save_nonceAgency taxonomySave agency term fields.
hvnly_featured_property_save / hvnly_featured_property_save_nonceProperty metaRegistered in Havenlytics_Type.php.
hvnly_term_advanced_img_noncehvnly_advanced_icon_noncehvnly_advanced_icon_picker_nonceTaxonomy fieldsTerm image upload and Font Awesome icon picker traits.
hvnly_inquiry_action_{id} / _hvnly_inquiry_nonceContact Agent inquiriesPer-inquiry action nonce (the {id} binds the token to one object).
wp_restAll REST routesStandard WordPress REST nonce, paired with a permission_callback.

Note: Per-object nonces like hvnly_inquiry_action_{id} are stronger than a single shared action, because a token minted for one inquiry cannot be replayed against another. When you add an action that operates on a specific object, bind the object ID into the nonce action the same way.

Capability strings and their contexts

Havenlytics never gates on user roles — it gates on WordPress capabilities, which is the correct, future-proof approach. Different surfaces require different capabilities depending on the sensitivity of the action. Use the same capability the plugin uses for an equivalent surface; do not blanket everything with manage_options.

CapabilityWhere it gates
manage_optionsThe dominant admin gate: the import engine, React onboarding, all settings APIs, CacheAdminWorkspacePage, migration notices, email-verification admin, agent management and export, and ownership transfer.
edit_users / create_usersAgent provisioning and the agent identity bridge (creating or linking WordPress users to agents).
edit_post / edit_posts / edit_others_postsPer-object property and workflow permission checks. edit_posts also gates the Price-on-Call API.
publish_postsPublishing a property in the workflow transition.
upload_filesThe media controller and profile photo upload.
delete_postMedia and profile deletion.
activate_pluginsElementor integration checks, the ConflictChecker, and the ErrorHandler.
PortalCapabilities (Workspace)The Agent Workspace SPA resolves portal-scoped permissions through its own capability layer rather than raw WordPress caps, so an agent’s access to Workspace features is decided in one place.

Common mistake: Using current_user_can('administrator'). That checks for a role named “administrator” as if it were a capability — it is almost always wrong and silently fails on multisite and custom roles. Always check a capability such as manage_options.

Sanitize, escape, and validate conventions

Havenlytics follows the WordPress rule of sanitize on input, validate where a value has a known shape, and escape on output. These are the exact functions used throughout the plugin — reach for the same ones.

Input sanitizers

  • sanitize_text_field( wp_unslash( $_POST['field'] ) ) — the standard for scalar text input. Always wp_unslash() first, because WordPress slashes superglobals.
  • sanitize_key() — for keys, slugs used as array keys, and option-name fragments.
  • absint() or filter_input( INPUT_POST, 'id', FILTER_VALIDATE_INT ) — for IDs and integers.
  • sanitize_email() paired with is_email() — sanitize, then validate the result before using it.
  • esc_url_raw() — for URLs being stored or passed to HTTP calls (the import engine uses this before validating the host).
  • rest_sanitize_boolean() — for booleans arriving through REST.
  • floatval() — for coordinates; sanitize_file_name() for filenames; sanitize_title() for slugs.

The WorkspaceSecurity centralized helper

The Agent Workspace routes all sanitizing and escaping through one final class, includes/Workspace/Security/WorkspaceSecurity.php. It exposes typed entry points — text, textarea, html (via wp_kses_post), url, email, phone, number, currency, taxonomy, and array_values — plus escape and escape_attr for output. Any Workspace form or API payload is passed through it, so the sanitizing rules live in exactly one place.

Tip: If you extend the Workspace SPA or its REST controllers, route your input and output through WorkspaceSecurity rather than calling sanitize_* ad hoc. That keeps the Workspace’s sanitizing behavior centralized and consistent, which is the entire point of the class.

Output escaping

Escape at the point of output, never at the point of storage: esc_html() for text nodes, esc_attr() for attribute values, esc_url() for links. Email values are html_entity_decode()-ed once and then re-escaped so entities are not double-encoded in the rendered message.

Email header injection

Email subjects strip \r and \n, and display phrases (from-names, reply-to names) go through EmailHeaders::sanitize_phrase(), which strips CRLF, tabs, and the header-significant characters , " < > ; :. This prevents an attacker from smuggling extra headers through a name field. See the Email System guide for the full header pipeline.

The import wizard double-nonce

The demo-import engine in includes/Admin/PropertyImportWizard.php is the most privileged writer in the plugin, so it uses a double nonce. Every import AJAX request must carry both hvnly_import_nonce and a separate CSRF token verified against hvnly_import_csrf, and the user must hold manage_options. The React onboarding UI issues both tokens (importNonce and importCsrf) when it renders.

<?php
// Pattern used by PropertyImportWizard::ajax_import_properties()
// 1) Primary AJAX nonce
if ( ! check_ajax_referer( 'hvnly_import_nonce', 'nonce', false ) ) {
    wp_send_json_error( array( 'message' => 'Invalid request.' ), 403 );
}
// 2) Second, independent CSRF token
if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['csrf_token'] ) ), 'hvnly_import_csrf' ) ) {
    wp_send_json_error( array( 'message' => 'Invalid request.' ), 403 );
}
// 3) Capability
if ( ! current_user_can( 'manage_options' ) ) {
    wp_send_json_error( array( 'message' => 'Insufficient permissions.' ), 403 );
}
// ...proceed with the batched, resumable import.

The same triple gate (nonce + CSRF + manage_options) protects hvnly_cancel_import, and the wizard’s other AJAX actions (hvnly_save_google_api_keyhvnly_save_map_provider) run through the shared verify_import_wizard_ajax_request() checker. See Import & Export for the full engine flow.

Rate limiting: VerificationRateLimiter

Public, unauthenticated endpoints — email verification and resend — are rate limited by VerificationRateLimiter. It is transient-backed with no custom table, and it is careful about what it stores as a key.

  • Hashed keys. Identifiers (IP, account) are run through HMAC-SHA256 keyed with wp_salt('auth') and truncated to 20 characters before being used as a transient name. A raw IP address is never stored as an option name.
  • REMOTE_ADDR only. ip() reads $_SERVER['REMOTE_ADDR'] and nothing else — proxy headers such as X-Forwarded-For are not trusted (they are trivially spoofed). Override the resolved IP with the hvnly_verification_client_ip filter only if you sit behind a trusted proxy.
  • Two window types. exceeded() enforces a fixed-window count; in_cooldown() enforces a minimum gap between attempts.

The limits applied in SessionAuthController are: on verify — verify_ip 20 per 10 minutes and verify_token 10 per hour; on resend — resend_ip 10 per hour, a 60-second per-account cooldown, resend_acct 5 per hour, and resend_daily 10 per day. Because these endpoints are reachable by unauthenticated users, failures return a deliberately generic message via generic_verify_fail() to avoid account enumeration.

Security: Never trust X-Forwarded-For for rate limiting or IP logging unless you control the proxy in front of WordPress. Havenlytics defaults to REMOTE_ADDR for exactly this reason. If you must honor a proxy header, validate the proxy chain, then supply the result through hvnly_verification_client_ip.

The HMAC token scheme: EmailVerificationFactor

Email verification tokens use a selector + verifier design, which is the same pattern used by well-designed “remember me” and password-reset systems. It lets the server look a token up by an index without ever storing anything that could be replayed if the database leaked.

  • Selector — bin2hex(random_bytes(8)), stored in plaintext in the meta _hvnly_email_verification_selector. It is only a lookup index, not a secret.
  • Verifier — bin2hex(random_bytes(32)). Only hash_hmac('sha256', $verifier, wp_salt('auth')) is persisted, in the meta _hvnly_email_verification_token. The raw verifier is never stored or logged.
  • Token — handed to the user as selector.verifier (the two joined by a dot) in the verification link.

Verification (verify()) strips any non-hex characters from the input, looks the row up by selector, recomputes the HMAC over the supplied verifier, and compares it with hash_equals() — a constant-time comparison that does not leak information through timing. It then enforces expiry (meta _hvnly_email_verification_expires) and single use (the token is deleted on success or expiry). Any failure fires hvnly_email_verification_failed and returns user_id = 0 when the token cannot be resolved, so the endpoint never confirms whether a given selector exists.

The token TTL comes from ttl(), filterable via hvnly_email_verification_ttl (default DAY_IN_SECONDS) and clamped between one hour and 48 hours. The whole factor is pluggable through the IdentityVerificationService and the hvnly_identity_factors filter.

Security: The four properties that make this scheme safe are worth memorizing for any token you design: store a hash, never the raw secretcompare with hash_equals() (never == or === on secrets); enforce expiry and single use; and return generic failures on public endpoints. Havenlytics does all four.

How to do it right in Havenlytics

Use these patterns verbatim when you extend the plugin. They mirror exactly what the core code does.

1. A secure admin AJAX handler

<?php
add_action( 'wp_ajax_my_plugin_do_thing', function () {
    // Nonce first — false = return, do not die, so we can send JSON.
    if ( ! check_ajax_referer( 'my_plugin_do_thing_nonce', 'nonce', false ) ) {
        wp_send_json_error( array( 'message' => 'Invalid request.' ), 403 );
    }
    // Capability second.
    if ( ! current_user_can( 'manage_options' ) ) {
        wp_send_json_error( array( 'message' => 'Insufficient permissions.' ), 403 );
    }
    // Sanitize third.
    $value = isset( $_POST['value'] )
        ? sanitize_text_field( wp_unslash( $_POST['value'] ) )
        : '';

    // ...do the work...
    wp_send_json_success( array( 'saved' => $value ) );
} );

2. A secure REST permission_callback

<?php
register_rest_route( 'my/v1', '/thing', array(
    'methods'             => 'POST',
    'callback'            => 'my_plugin_rest_thing',
    // Check a capability appropriate to the action, not blanket manage_options.
    'permission_callback' => function () {
        return current_user_can( 'edit_posts' );
    },
    'args' => array(
        'id' => array(
            'sanitize_callback' => 'absint',
            'required'          => true,
        ),
    ),
) );
// The client sends the wp_rest nonce header: wp_create_nonce( 'wp_rest' ).

3. A secure admin GET action link

<?php
// Build the link with a per-object nonce.
$url = wp_nonce_url(
    admin_url( 'admin.php?page=my-page&action=archive&id=' . $item_id ),
    'my_plugin_archive_' . $item_id,
    '_wpnonce'
);

// Handle it on admin_init.
add_action( 'admin_init', function () {
    if ( empty( $_GET['action'] ) || 'archive' !== $_GET['action'] ) {
        return;
    }
    $id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0;
    check_admin_referer( 'my_plugin_archive_' . $id, '_wpnonce' );
    if ( ! current_user_can( 'manage_options' ) ) {
        wp_die( 'Insufficient permissions.', 403 );
    }
    // ...perform the action on $id...
} );

4. Sanitize and escape

<?php
// Input: sanitize + unslash; IDs via absint; Workspace data via WorkspaceSecurity.
$name  = sanitize_text_field( wp_unslash( $_POST['name'] ?? '' ) );
$id    = absint( $_POST['id'] ?? 0 );
$email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) );
if ( $email && ! is_email( $email ) ) {
    $email = ''; // validate after sanitizing
}

// Output: escape at the boundary.
printf(
    '<a href="%s">%s</a>',
    esc_url( $link ),
    esc_html( $name )
);

5. Token handling

<?php
// Never store a raw secret. HMAC it with an auth salt, compare in constant time.
$verifier = bin2hex( random_bytes( 32 ) );
$stored   = hash_hmac( 'sha256', $verifier, wp_salt( 'auth' ) );
update_post_meta( $user_id, '_my_token', $stored );
update_post_meta( $user_id, '_my_token_expires', time() + HOUR_IN_SECONDS );

// On verify:
$candidate = hash_hmac( 'sha256', $incoming_verifier, wp_salt( 'auth' ) );
$ok = hash_equals( $stored, $candidate )                 // constant-time
    && (int) get_post_meta( $user_id, '_my_token_expires', true ) > time(); // expiry
if ( $ok ) {
    delete_post_meta( $user_id, '_my_token' );           // single use
}
// On any public endpoint, return a generic failure message either way.

Best practices

  • Verify a nonce and check a capability on every state-changing request — AJAX, REST, and admin GET links alike.
  • Match the capability to the action’s sensitivity. Reserve manage_options for site-wide settings; use per-object caps (edit_postpublish_posts) for object operations.
  • Sanitize on input with wp_unslash() first; validate values with a known shape (is_email()FILTER_VALIDATE_INT); escape on output.
  • Route all Agent Workspace input and output through WorkspaceSecurity.
  • For tokens, store only an HMAC, compare with hash_equals(), and enforce expiry plus single use.
  • Rate limit public endpoints and return generic failures to prevent enumeration.

Common mistakes

  • Forgetting wp_unslash() before sanitizing, which stores backslash-escaped data.
  • Checking a role name (administrator) instead of a capability (manage_options).
  • Comparing secret tokens with == or === instead of hash_equals(), leaking timing information.
  • Trusting X-Forwarded-For for rate limiting or IP logging.
  • Escaping at storage time instead of at output, leading to double-escaping.
  • Storing a raw token or IP address as a transient/option name instead of a hash.

Need more help?

Can't find what you're looking for? Our team and community are here to help you ship faster.