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:
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:
// 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:
<!-- 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
// 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:
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
-
Generate nonce:
wp_nonce_field('action_name')creates a hidden field with a unique, time-limited token -
Verify nonce:
wp_verify_nonce($_POST['_wpnonce'], 'action_name')checks if the token is valid -
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:
// 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:
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 // 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 /** * 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
// VULNERABLE echo $_GET['message']; // SECURE echo esc_html($_GET['message']);
2. Missing Nonce on Delete Actions
// 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
// 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
// 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:
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
-
Never trust user input – Sanitize everything
-
Never trust the database – Escape everything
-
Always verify intentions – Use nonces
-
Always check permissions – Verify capabilities
-
Protect your files – ABSPATH checks everywhere
-
Use WordPress functions – They’re battle-tested
-
Keep WordPress updated – Security patches matter
-
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.