Week 24: WordPress Security & Performance

This closes out the WordPress module. WordPress's security model runs on nonces and capability checks — the same authentication/authorization split from Week 8 and Week 13, in WordPress's own vocabulary — plus the caching and query discipline that keeps a real, plugin-heavy WordPress site fast at scale.

Module 21 of 28 Week 24 of 32 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Harden custom code with nonces & capability checks
  • Apply the correct sanitize/escape function for every context
  • Use object caching & query optimization to keep a WordPress site fast

1. Nonces

A WordPress nonce ("number used once," though in practice time-limited and reusable within a window) is CSRF protection — the same mechanism built by hand in Week 8, wrapped in a WordPress-native API:

a nonce-protected form
<form method="post" action="">
  <?php wp_nonce_field('pm_save_inquiry', 'pm_nonce'); ?>
  <input type="email" name="email" required>
  <button type="submit">Submit</button>
</form>
verifying it on submit
<?php

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (!isset($_POST['pm_nonce']) || !wp_verify_nonce($_POST['pm_nonce'], 'pm_save_inquiry')) {
        wp_die('Security check failed.');
    }

    // Safe to proceed -- the request genuinely originated from this form
}

'pm_save_inquiry' as the action string matters — it scopes the nonce to this specific action, so a nonce generated for one form can't be replayed against a different, more sensitive action even if an attacker somehow obtained it. AJAX requests use the same mechanism, passed as a header or request parameter instead of a hidden form field.

2. Capabilities

WordPress's authorization model is built on capabilities — granular permissions like edit_posts, manage_options, delete_users — grouped into roles (Subscriber, Author, Editor, Administrator). Check a capability, never a role directly:

right vs. wrong
<?php

// Wrong -- brittle, and ignores custom roles entirely
if ($user->roles[0] === 'administrator') { /* ... */ }

// Right -- checks the actual permission, regardless of which role grants it
if (current_user_can('manage_options')) { /* ... */ }

// Object-specific capability checks
if (current_user_can('edit_post', $postId)) { /* ... */ }

This is precisely the "check capability, not who someone is" principle from Week 13's Policies — a site might have a custom "Shop Manager" role with manage_woocommerce but not manage_options; checking the specific capability handles that correctly, where a hardcoded role-name check would get it wrong.

3. Sanitize vs. Escape, Precisely

Weeks 19 and 21 used esc_html() and esc_attr() without fully unpacking the rule. Now, precisely: sanitize on input, escape on output — every single time, with the function matched to the exact context:

  • sanitize_text_field($_POST['name']) — cleaning data as it comes in, before it's used or stored
  • esc_html($name) — printing into HTML text content
  • esc_attr($name) — printing inside an HTML attribute value
  • esc_url($url) — printing inside an href/src
  • esc_js($string) — printing inside an inline <script> block
  • $wpdb->prepare() — binding a value into a SQL query, per Week 21
the full lifecycle of one value
<?php

// 1. Sanitize on the way in
$name = sanitize_text_field($_POST['name'] ?? '');

// 2. Store it (via $wpdb->prepare(), or wp_insert_post(), etc.)
$wpdb->insert($table, ['name' => $name], ['%s']);

// 3. Escape on the way out -- for the context it's actually being printed into
echo '<p>Hello, ' . esc_html($name) . '</p>';
echo '<input value="' . esc_attr($name) . '">';
Sanitizing on input alone is not enough

Even a value fully sanitized at input time still needs the correct esc_*() call at every single point it's printed — different output contexts (HTML text, an attribute, a URL, JS) have different characters that are dangerous, and one sanitize pass at input time can't anticipate every context the value will later be printed into. This mirrors the exact same "escape at the point of output, not just once at input" rule from Week 1 and Week 8.

4. Object Caching with Redis

WordPress's WP_Object_Cache caches expensive computed values — database query results, API responses — but by default only for the duration of a single request. A persistent object cache backed by Redis keeps that cache alive across requests, dramatically cutting database load on a busy site:

terminal
wp plugin install redis-cache --activate
wp redis enable
using the cache directly in custom code
<?php

function pm_get_project_count(): int {
    $cached = wp_cache_get('pm_project_count', 'portfolio_manager');

    if ($cached !== false) {
        return $cached;
    }

    $count = (int) wp_count_posts('project')->publish;
    wp_cache_set('pm_project_count', $count, 'portfolio_manager', 300); // cache for 5 minutes

    return $count;
}

This is the same idea as Redis caching in Python or Node backends — compute once, serve from memory for a while, invalidate deliberately — expressed through WordPress's own cache API so it automatically benefits from whatever caching backend (Redis, Memcached, or the request-scoped default) the site has configured.

5. Query & Asset Performance

Two of the most common WordPress performance mistakes, both avoidable with what's already been covered:

an expensive, unbounded WP_Query
<?php
// Slow -- fetches every single project, no matter how many exist
$all = new WP_Query(['post_type' => 'project', 'posts_per_page' => -1]);

// Better -- paginated, and skip counting total rows if you don't need pagination UI
$page = new WP_Query([
    'post_type' => 'project',
    'posts_per_page' => 20,
    'no_found_rows' => true, // skips a separate COUNT(*) query when you don't need it
]);

posts_per_page => -1 is the WordPress-specific version of forgetting a LIMIT clause from Week 6 — fine on a small site, a real problem once a post type has thousands of rows. Loading unused plugins and unoptimized images is the other classic culprit — every active plugin can enqueue its own CSS/JS on every page load regardless of whether that page uses it, so periodically auditing what's actually active (and using a plugin like Query Monitor to see what's slow) matters on any real WordPress site.

6. Hands-on Exercise

Hands-on

Harden and cache Portfolio Manager

A security and performance pass over everything built in Weeks 19–23.

Requirements:

  1. Audit every form/AJAX handler in the plugin (the inquiry form from Week 21) and confirm each has a properly scoped nonce, verified before any write happens.
  2. Replace any hardcoded role checks with current_user_can() capability checks throughout the plugin's admin pages.
  3. Confirm every dynamic value printed anywhere in the theme or plugin uses the correct esc_*() function for its context — audit project-card.php, single-project.php and the admin settings page specifically.
  4. Add object caching (via wp_cache_get/wp_cache_set) around the project count and the inquiries-per-project lookup from Week 21, with a sensible expiration.
  5. Convert the "list all projects" query anywhere in the plugin/theme to be properly paginated with no_found_rows where a total count isn't needed.
Hint

Install the Query Monitor plugin during this exercise — it shows every database query, every hook fired, and every asset enqueued on a page load, making it far easier to spot an unbounded query or a missing cache than reading through code alone.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does wp_nonce_field() take an action name ('pm_save_inquiry') rather than generating a single generic token per session?

Scoping a nonce to a specific action means a nonce generated for one form can't be replayed against a different, potentially more sensitive action — even by a legitimate logged-in user's own browser making an unrelated request. A single generic session-wide token would protect against cross-site forgery in general, but wouldn't distinguish between "this token is valid for submitting an inquiry" and "this token is valid for deleting an account."

Q2

Why check current_user_can('manage_options') instead of $user->roles[0] === 'administrator'?

Capabilities describe what a user is actually allowed to do; roles are just named bundles of capabilities, and a site can define custom roles (a "Shop Manager" with some but not all admin capabilities, for example) that would incorrectly fail a hardcoded role-name check despite genuinely having the needed permission. Checking the capability directly is correct regardless of which role — built-in or custom — happens to grant it.

Q3

A value was already sanitized with sanitize_text_field() when it was saved. Is it safe to print with plain echo $value; later?

No. Sanitizing on input and escaping on output are two separate steps that both need to happen — sanitizing cleans the value once, generically, before storage, but the correct escaping function depends entirely on where the value is being printed later (HTML text needs esc_html(), an attribute needs esc_attr(), a URL needs esc_url()), and that context isn't known at sanitize time.

Q4

Why does posts_per_page => -1 become a real performance problem as a site grows, even though it works fine during development?

It fetches every matching row with no upper bound — fine with 10 test posts, but a query that returns (and a page that renders) thousands of rows at once as real content accumulates. It's the WordPress-specific version of forgetting a LIMIT clause, flagged as a general SQL concern back in Week 6 — the fix is the same idea, applied through posts_per_page and pagination instead of a raw SQL LIMIT.