Contact Us

Here’s a scary statistic: Over 50% of WordPress vulnerabilities come from plugins.

Not core WordPress. Not themes. Plugins. If you are new, check our full plugin development guide for better understanding.

Why? Because plugin developers—especially beginners—don’t think about security. They think about features. They think about design. They think about making things work.

But here’s the hard truth: If your plugin has a security hole, you’re not just risking your code. You’re risking your users’ entire websites. Their content. Their customer data. Their business.

In this tutorial, you’ll learn how to make your portfolio plugin production-ready. You’ll understand why we’ve been using functions like esc_html() and sanitize_text_field() throughout this series. And you’ll add the final security measures that separate amateur plugins from professional ones.

The Four Pillars of WordPress Security

Every secure WordPress plugin follows four fundamental principles:

Pillar What It Protects Against Our Implementation
Sanitization Malicious input sanitize_text_field()intval()sanitize_title()
Validation Invalid data Column range checking, email format verification
Escaping XSS attacks esc_html()esc_attr()esc_url()esc_textarea()
Authentication Unauthorized access current_user_can(), nonces, wp_die()

Let’s examine each one in detail using your actual plugin code.

1. Input Sanitization: Cleaning User Data

Sanitization means cleaning data before storing it in the database. Never trust anything that comes from a user—whether from a form, URL parameter, or shortcode attribute.

In Your Settings Class

Look at your sanitize() method in class-wp-creative-portfolio-settings.php:

php
public function sanitize($input) {

    $sanitized = array();

    // Text fields - remove HTML, trim whitespace
    $sanitized['plural_name'] = !empty($input['plural_name']) ? 
        sanitize_text_field($input['plural_name']) : 
        'Portfolios';
    
    $sanitized['singular_name'] = !empty($input['singular_name']) ? 
        sanitize_text_field($input['singular_name']) : 
        'Portfolio';
    
    // Slugs - convert to URL-safe format
    $sanitized['slug'] = !empty($input['slug']) ? 
        sanitize_title($input['slug'], 'portfolio') : 
        'portfolio';
    
    $sanitized['taxonomy_slug'] = !empty($input['taxonomy_slug']) ? 
        sanitize_title($input['taxonomy_slug'], 'portfolio-category') : 
        'portfolio-category';
    
    // Numbers - ensure integer value
    $sanitized['posts_per_page'] = !empty($input['posts_per_page']) ? 
        intval($input['posts_per_page']) : 
        9;

    return $sanitized;
}

Sanitization Functions Explained

Function Purpose Example Input Sanitized Output
sanitize_text_field() Plain text <script>alert('xss')</script>Hello Hello
sanitize_title() URL slugs “My Portfolio!” my-portfolio
sanitize_email() Email addresses ” user@example.com “ user@example.com
sanitize_textarea_field() Multi-line text <p>Text</p> Text
intval() Whole numbers “123abc” 123
absint() Positive numbers “-5” 0

In Your Shortcode

Your shortcode also sanitizes user input:

php
// From render_portfolio() in class-wp-creative-portfolio.php
$title       = sanitize_text_field($atts['title']);
$description = sanitize_textarea_field($atts['description']);
$category    = sanitize_title($atts['category']);
$columns     = intval($atts['columns']);
$count       = intval($atts['count']);

// Additional validation
if ($columns < 1 || $columns > 4) {
    $columns = 3; // Reset to default if invalid
}

Why this matters: A user could try [wp_creative_portfolio title="<script>alert('hacked')</script>"]. Without sanitization, that script would execute in your admin area. With sanitize_text_field(), the script tags are stripped.

2. Output Escaping: Preventing XSS Attacks

Escaping means ensuring data is safe when output to the browser. Even if data is safely stored, you must escape it when displaying.

Look at your shortcode HTML:

php
<!-- Escaping in HTML attributes -->
<div class="wp-creative-portfolio" data-columns="<?php echo esc_attr($columns); ?>">

<!-- Escaping in HTML content -->
<h2 class="portfolio-title"><?php echo esc_html($title); ?></h2>

<!-- Escaping in URLs -->
<img src="<?php echo esc_url($thumbnail_url); ?>" 
     alt="<?php the_title_attribute(); ?>">

<!-- Escaping in JavaScript attributes -->
<a href="#" data-filter=".<?php echo esc_attr($term->slug); ?>">

Escaping Functions Explained

Function When to Use Example
esc_html() Plain text between HTML tags <p><?php echo esc_html($title); ?></p>
esc_attr() HTML attributes data-id="<?php echo esc_attr($id); ?>"
esc_url() URLs (including protocol) <a href="<?php echo esc_url($link); ?>">
esc_textarea() Textarea content <textarea><?php echo esc_textarea($text); ?></textarea>
esc_js() Inline JavaScript onclick="alert('<?php echo esc_js($message); ?>')"
the_title_attribute() Title attribute title="<?php the_title_attribute(); ?>"

The Consequences of Not Escaping

php
// BAD - Vulnerable to XSS
echo "<div data-category='{$_GET['cat']}'>";

// If someone visits:
// example.com/page/?cat=' onmouseover='alert(1)'

// Resulting HTML:
<div data-category='' onmouseover='alert(1)'> 
// Script executes!

// GOOD - Safe
echo "<div data-category='" . esc_attr($_GET['cat']) . "'>";
// Script tags become harmless text

3. Nonces: Preventing CSRF Attacks

Cross-Site Request Forgery (CSRF) is when an attacker tricks a logged-in user into performing an action they didn’t intend—like changing settings or deleting content.

Look at your updated settings class with nonce verification:

php
public function create_admin_page() {
    // Check if user has permission
    if (!current_user_can('manage_options')) {
        wp_die(__('You do not have sufficient permissions to access this page.', 'wp-creative-portfolio'));
    }
    ?>
    <div class="wrap">
        <h1>WP Creative Portfolio Settings</h1>
        
        <form method="post" action="options.php">
            <?php
            settings_fields('wp_creative_portfolio_group');
            do_settings_sections('wp-creative-portfolio');
            
            // Add nonce field - THIS IS CRITICAL
            wp_nonce_field('wp_creative_portfolio_options-options');
            
            submit_button('Save Settings');
            ?>
        </form>
    </div>
    <?php
}

public function sanitize($input) {

    // Verify nonce - THIS VERIFIES THE REQUEST IS LEGITIMATE
    if (!isset($_POST['_wpnonce']) || !wp_verify_nonce($_POST['_wpnonce'], 'wp_creative_portfolio_options-options')) {
        add_settings_error(
            'wp_creative_portfolio_options',
            'wp_creative_portfolio_nonce_error',
            __('Security check failed. Please try again.', 'wp-creative-portfolio'),
            'error'
        );
        return get_option('wp_creative_portfolio_options');
    }

    // Proceed with sanitization...
}

How Nonces Work

  1. Generate nonce: wp_nonce_field('action_name') creates a hidden field with a unique, time-limited token

  2. Verify nonce: wp_verify_nonce($_POST['_wpnonce'], 'action_name') checks if the token is valid

  3. Reject if invalid: If the nonce is missing or wrong, the request is rejected

Nonce lifetime: WordPress nonces last 12-24 hours by default. After that, they expire—preventing replay attacks.

4. Capability Checks: Controlling Access

Never assume someone accessing your admin pages should be there. Always check permissions:

php
// At the top of your admin page
public function create_admin_page() {
    
    // Check if user has permission
    if (!current_user_can('manage_options')) {
        wp_die(__('You do not have sufficient permissions to access this page.', 'wp-creative-portfolio'));
    }
    
    // Rest of your code...
}

Common WordPress Capabilities

Capability Who Has It When to Use
manage_options Administrators Plugin settings pages
edit_posts Editors, Authors, Contributors Content editing
publish_posts Editors, Authors Publishing content
delete_posts Editors, Authors Deleting content
upload_files Anyone who can upload Media handling
manage_categories Editors, Administrators Managing taxonomies

5. Database Security: Prepared Statements

When you query the database directly (not using WP_Query or get_posts), always use prepared statements:

php
global $wpdb;

// BAD - SQL injection vulnerability
$results = $wpdb->get_results("SELECT * FROM {$wpdb->prefix}posts WHERE ID = $_GET[id]");

// GOOD - Prepared statement
$results = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}posts WHERE ID = %d",
        intval($_GET['id'])
    )
);

Placeholders in prepared statements:

  • %d – Integer

  • %s – String

  • %f – Float

6. File Security: Preventing Direct Access

Every PHP file in your plugin should start with:

php
<?php
// Prevent direct access
if ( ! defined( 'ABSPATH' ) ) {
    exit; // Exit if accessed directly
}

This prevents someone from accessing your PHP files directly via URL:
https://yoursite.com/wp-content/plugins/wp-creative-portfolio-pro/includes/class-wp-creative-portfolio.php

Without this check, someone might see error messages or—worse—execute code in an insecure context.

7. Complete Security-Enhanced Settings Class

Here’s your complete class-wp-creative-portfolio-settings.php with all security measures:

php
<?php
/**
 * Portfolio Settings Class
 *
 * @package WP_Creative_Portfolio
 */

// Prevent direct access
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

class WP_Creative_Portfolio_Settings {

    private $options;

    public function __construct() {
        add_action('admin_menu', array($this, 'add_settings_page'));
        add_action('admin_init', array($this, 'register_settings'));
        add_action('admin_enqueue_scripts', array($this, 'admin_assets'));
    }

    public function admin_assets($hook) {
        if ($hook !== 'settings_page_wp-creative-portfolio') {
            return;
        }
        
        // Verify user has permission
        if (!current_user_can('manage_options')) {
            return;
        }
        
        wp_enqueue_style('wp-color-picker');
        wp_enqueue_script('wp-color-picker');
    }

    public function add_settings_page() {
        add_options_page(
            __('WP Creative Portfolio Settings', 'wp-creative-portfolio'),
            __('Creative Portfolio', 'wp-creative-portfolio'),
            'manage_options', // Capability required
            'wp-creative-portfolio',
            array($this, 'create_admin_page')
        );
    }

    public function register_settings() {

        register_setting(
            'wp_creative_portfolio_group',
            'wp_creative_portfolio_options',
            array($this, 'sanitize')
        );

        add_settings_section(
            'wp_creative_portfolio_section',
            __('Portfolio Settings', 'wp-creative-portfolio'),
            array($this, 'section_callback'),
            'wp-creative-portfolio'
        );

        $fields = array(
            'plural_name'    => __('Portfolio Plural Name', 'wp-creative-portfolio'),
            'singular_name'  => __('Portfolio Singular Name', 'wp-creative-portfolio'),
            'slug'           => __('Portfolio Slug', 'wp-creative-portfolio'),
            'taxonomy_slug'  => __('Category Slug', 'wp-creative-portfolio'),
            'posts_per_page' => __('Posts Per Page', 'wp-creative-portfolio')
        );

        foreach ($fields as $id => $label) {
            add_settings_field(
                $id,
                $label,
                array($this, 'render_field'),
                'wp-creative-portfolio',
                'wp_creative_portfolio_section',
                array(
                    'id' => $id,
                    'label' => $label
                )
            );
        }
    }

    public function section_callback() {
        echo '<p>' . esc_html__('Configure how your portfolio will display and behave.', 'wp-creative-portfolio') . '</p>';
    }

    public function sanitize($input) {

        // Verify nonce
        if (!isset($_POST['_wpnonce']) || !wp_verify_nonce($_POST['_wpnonce'], 'wp_creative_portfolio_options-options')) {
            add_settings_error(
                'wp_creative_portfolio_options',
                'wp_creative_portfolio_nonce_error',
                __('Security check failed. Please try again.', 'wp-creative-portfolio'),
                'error'
            );
            return get_option('wp_creative_portfolio_options');
        }

        // Verify user capability
        if (!current_user_can('manage_options')) {
            add_settings_error(
                'wp_creative_portfolio_options',
                'wp_creative_portfolio_capability_error',
                __('You do not have permission to change these settings.', 'wp-creative-portfolio'),
                'error'
            );
            return get_option('wp_creative_portfolio_options');
        }

        $sanitized = array();

        // Sanitize text fields
        $sanitized['plural_name'] = !empty($input['plural_name']) ? 
            sanitize_text_field($input['plural_name']) : 
            __('Portfolios', 'wp-creative-portfolio');
            
        $sanitized['singular_name'] = !empty($input['singular_name']) ? 
            sanitize_text_field($input['singular_name']) : 
            __('Portfolio', 'wp-creative-portfolio');
        
        // Sanitize slugs
        $sanitized['slug'] = !empty($input['slug']) ? 
            sanitize_title($input['slug'], 'portfolio') : 
            'portfolio';
            
        $sanitized['taxonomy_slug'] = !empty($input['taxonomy_slug']) ? 
            sanitize_title($input['taxonomy_slug'], 'portfolio-category') : 
            'portfolio-category';
        
        // Sanitize number
        $sanitized['posts_per_page'] = !empty($input['posts_per_page']) ? 
            intval($input['posts_per_page']) : 
            9;

        // Validate number range
        if ($sanitized['posts_per_page'] < -1 || $sanitized['posts_per_page'] > 50) {
            $sanitized['posts_per_page'] = 9;
        }

        // Add success message
        add_settings_error(
            'wp_creative_portfolio_options',
            'wp_creative_portfolio_updated',
            __('Settings saved successfully!', 'wp-creative-portfolio'),
            'updated'
        );

        return $sanitized;
    }

    public function render_field($args) {

        $options = get_option('wp_creative_portfolio_options');
        $value = isset($options[$args['id']]) ? $options[$args['id']] : '';
        
        $type = ($args['id'] === 'posts_per_page') ? 'number' : 'text';
        $min = ($args['id'] === 'posts_per_page') ? 'min="-1" max="50"' : '';
        
        ?>
        <input type="<?php echo esc_attr($type); ?>" 
               name="wp_creative_portfolio_options[<?php echo esc_attr($args['id']); ?>]" 
               value="<?php echo esc_attr($value); ?>" 
               class="regular-text" 
               <?php echo $min; ?> />
        <?php
        
        // Help text
        if ($args['id'] === 'slug') {
            echo '<p class="description">' . esc_html__('Warning: Changing this will break existing URLs!', 'wp-creative-portfolio') . '</p>';
        }
        
        if ($args['id'] === 'posts_per_page') {
            echo '<p class="description">' . esc_html__('Number of items to display. Use -1 to show all.', 'wp-creative-portfolio') . '</p>';
        }
    }

    public function create_admin_page() {

        // Verify user capability
        if (!current_user_can('manage_options')) {
            wp_die(
                esc_html__('You do not have sufficient permissions to access this page.', 'wp-creative-portfolio'),
                esc_html__('Permission Denied', 'wp-creative-portfolio'),
                array('response' => 403)
            );
        }

        $this->options = get_option('wp_creative_portfolio_options');
        ?>
        <div class="wrap">
            <h1><?php echo esc_html__('WP Creative Portfolio Settings', 'wp-creative-portfolio'); ?></h1>
            
            <?php settings_errors('wp_creative_portfolio_options'); ?>
            
            <form method="post" action="options.php">
                <?php
                settings_fields('wp_creative_portfolio_group');
                do_settings_sections('wp-creative-portfolio');
                
                // Add nonce field
                wp_nonce_field('wp_creative_portfolio_options-options');
                
                submit_button(__('Save Settings', 'wp-creative-portfolio'));
                ?>
            </form>
            
            <div class="wp-creative-portfolio-info">
                <h2><?php esc_html_e('How to use:', 'wp-creative-portfolio'); ?></h2>
                <p><?php esc_html_e('Use the following shortcode to display your portfolio:', 'wp-creative-portfolio'); ?></p>
                <code>[wp_creative_portfolio]</code>
                
                <h3><?php esc_html_e('Shortcode Parameters:', 'wp-creative-portfolio'); ?></h3>
                <ul>
                    <li><code>title</code> - <?php esc_html_e('Portfolio section title', 'wp-creative-portfolio'); ?></li>
                    <li><code>description</code> - <?php esc_html_e('Portfolio section description', 'wp-creative-portfolio'); ?></li>
                    <li><code>category</code> - <?php esc_html_e('Filter by category slug', 'wp-creative-portfolio'); ?></li>
                    <li><code>columns</code> - <?php esc_html_e('Number of columns (1-4)', 'wp-creative-portfolio'); ?></li>
                </ul>
                
                <p><strong><?php esc_html_e('Example:', 'wp-creative-portfolio'); ?></strong></p>
                <code>[wp_creative_portfolio title="My Work" description="Latest projects" columns="3"]</code>
            </div>
        </div>
        <?php
    }
}

Security Checklist for Your Plugin

Data Handling

  • All user input sanitized before database storage

  • All database output escaped before display

  • Prepared statements for custom database queries

  • Nonce verification on all forms

  • Capability checks on all admin pages

File Security

  • ABSPATH check in every PHP file

  • No direct file access possible

  • File permissions set correctly (644 for files, 755 for folders)

XSS Prevention

  • esc_html() for plain text

  • esc_attr() for attributes

  • esc_url() for URLs

  • esc_textarea() for textarea content

  • esc_js() for JavaScript strings

CSRF Prevention

  • wp_nonce_field() in all forms

  • wp_verify_nonce() on all form submissions

  • check_admin_referer() for admin actions

Authentication

  • current_user_can() checks on all admin pages

  • wp_die() for unauthorized access

  • Proper capability mapping for custom post types

Common Security Vulnerabilities in Plugins

1. Unescaped $_GET or $_POST Data

php
// VULNERABLE
echo $_GET['message'];

// SECURE
echo esc_html($_GET['message']);

2. Missing Nonce on Delete Actions

php
// VULNERABLE
if (isset($_GET['delete_id'])) {
    delete_post($_GET['delete_id']);
}

// SECURE
if (isset($_GET['delete_id']) && 
    wp_verify_nonce($_GET['_wpnonce'], 'delete_post_' . $_GET['delete_id'])) {
    delete_post(intval($_GET['delete_id']));
}

3. Insufficient Capability Checks

php
// VULNERABLE - any logged-in user can access
add_action('admin_menu', function() {
    add_menu_page('Secret Page', 'Secret', 'read', 'secret', 'render_page');
});

// SECURE - only admins
add_action('admin_menu', function() {
    add_menu_page('Settings', 'Settings', 'manage_options', 'settings', 'render_page');
});

4. Direct Database Queries Without Preparation

php
// VULNERABLE
$wpdb->query("DELETE FROM {$wpdb->prefix}posts WHERE ID = $_GET[id]");

// SECURE
$wpdb->delete(
    $wpdb->prefix . 'posts',
    array('ID' => intval($_GET['id'])),
    array('%d')
);

Testing Your Plugin’s Security

Check our guide about how to enable debug log properly in WordPress!

1. Enable WP_DEBUG

In your wp-config.php:

php
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', false);

2. Use Security Plugins

Check our guide about best security plugins for keep your website secure.

  • WordFence – Scans for vulnerabilities

  • WPScan – Checks for known vulnerabilities

  • Theme Check – Ensures compliance

3. Manual Testing

  • Try to access admin pages without logging in

  • Try to submit forms without nonces

  • Inject script tags in all inputs

  • Test with different user roles

Frequently Asked Questions

Do I really need nonces? They’re annoying to implement.

Yes! Nonces prevent attackers from tricking users into performing actions. Without them, your plugin is vulnerable.

What’s the difference between sanitization and escaping?

Sanitization cleans data before storing it in the database. Escaping cleans data before displaying it in the browser. Both are necessary.

Can I trust data from the database?

No. Always escape output even if you think the data is safe. Another plugin or process could have modified or corrupted it.

What happens if I forget to check capabilities?

Any logged-in user could access your admin pages, including subscribers who should not have permission to see or modify settings.

How long do nonces last?

Typically around 12 to 24 hours. After that the nonce expires and the form submission will fail, which helps protect against unauthorized actions.

Should I use stripslashes() or mysql_real_escape_string()?

No. You should use WordPress sanitization and escaping functions instead, as they are designed to work properly within the WordPress environment.

Quick Reference: WordPress Security Functions

Function Type Purpose
sanitize_text_field() Sanitization Clean text input
sanitize_title() Sanitization Create URL slugs
sanitize_email() Sanitization Validate emails
intval() Sanitization Ensure integer
esc_html() Escaping Safe HTML content
esc_attr() Escaping Safe attributes
esc_url() Escaping Safe URLs
wp_nonce_field() Authentication Add nonce to form
wp_verify_nonce() Authentication Verify nonce
current_user_can() Authentication Check permissions
wp_die() Error handling Stop execution with message

What’s Next?

Your plugin is now secure and production-ready. You’ve implemented:
✅ Input sanitization everywhere
✅ Output escaping on all displays
✅ Nonce verification on forms
✅ Capability checks on admin pages
✅ File access protection

In Video 9, you’ll learn about activation hooks and how to properly flush rewrite rules—fixing those annoying 404 errors once and for all.

Watch Video/Post 9 – WordPress Plugin Activation Hook Explained (coming soon)

Security Best Practices Summary

  1. Never trust user input – Sanitize everything

  2. Never trust the database – Escape everything

  3. Always verify intentions – Use nonces

  4. Always check permissions – Verify capabilities

  5. Protect your files – ABSPATH checks everywhere

  6. Use WordPress functions – They’re battle-tested

  7. Keep WordPress updated – Security patches matter

  8. Test with WP_DEBUG – Catch issues early

Share Your Security Wins!

Security isn’t glamorous, but it’s essential. You’ve now built a plugin that protects its users—that’s something to be proud of.

Question: What security practice was new to you? Drop a comment below!

Having issues? Post your code and describe what’s happening. I help every commenter secure their plugins.

WordPress Core Contributor | Plugin Developer | Educator

Akram Ul Haq is a WordPress core contributor, WordPress.org plugin author, and official translator with 10+ years of development experience. He has created premium plugins on CodeCanyon and professional themes for ThemeForest, along with custom WordPress solutions for businesses worldwide. At WPThrill, he teaches WordPress development, SEO structure, and performance optimization through practical, implementation-focused tutorial series.

Leave a Reply

Your email address will not be published. Required fields are marked *

Subscribe To Our Newsletter & Get Latest Updates.

Copyright @ 2025 WPThrill.com. All Rights Reserved.