This reference covers security practices that must be applied to every line of code that handles user input, database operations, file operations, or API communication. These practices are drawn from the WordPress Plugin Handbook security chapter, OWASP guidelines, and PCI-DSS requirements relevant to WooCommerce extensions.
Official sources:
- WordPress Plugin Security: https://developer.wordpress.org/plugins/security/
- OWASP Top 10: https://owasp.org/www-project-top-ten/
- WooCommerce Best Practices: https://developer.woocommerce.com/docs/extensions/best-practices-extensions/
- Core Security Principles
- Input Sanitization
- Output Escaping
- Nonces & CSRF Protection
- Capability Checks
- Database Security
- File Security
- API Security
- Financial Data Security
- Logging & Audit Trails
Per the WordPress Plugin Handbook Security chapter, these principles govern every security decision:
- Never trust user input. All data from
$_GET,$_POST,$_REQUEST,$_COOKIE,$_SERVER, REST API request bodies, webhook payloads, and any external source is untrusted. - Defense in depth. Layer security measures. Even if one layer fails, the next catches it.
- Principle of least privilege. Only request the minimum capabilities needed. Only expose the minimum data necessary.
- Validate, Sanitize, Escape. The WordPress security model is:
- Validate data to check it meets expected criteria (data validation)
- Sanitize data on input before storage (securing input)
- Escape data on output before rendering (securing output) These are three distinct operations and all three must be used together.
Sanitize every piece of external input before using it in any operation. WordPress provides purpose-built sanitization functions — use them.
| Data Type | Function | When to Use |
|---|---|---|
| Plain text | sanitize_text_field() |
Any single-line text input |
| Textarea | sanitize_textarea_field() |
Multi-line text inputs |
sanitize_email() |
Email address fields | |
| URL | esc_url_raw() |
URLs being stored in the database |
| Integer | absint() or intval() |
Numeric IDs, quantities, amounts |
| Float | floatval() + range check |
Prices, rates, percentages |
| HTML | wp_kses() or wp_kses_post() |
Rich text that needs some HTML |
| File name | sanitize_file_name() |
Uploaded file names |
| Key/slug | sanitize_key() |
Option names, meta keys, slugs |
| Title | sanitize_title() |
URL slugs, post titles |
| Hex color | sanitize_hex_color() |
Color picker values |
$settings = array(
'api_key' => sanitize_text_field( wp_unslash( $_POST['api_key'] ?? '' ) ),
'webhook_url' => esc_url_raw( wp_unslash( $_POST['webhook_url'] ?? '' ) ),
'max_retries' => absint( $_POST['max_retries'] ?? 3 ),
'tax_rate' => max( 0, min( 100, floatval( $_POST['tax_rate'] ?? 0 ) ) ),
'description' => sanitize_textarea_field( wp_unslash( $_POST['description'] ?? '' ) ),
);Always call wp_unslash() before sanitizing $_POST / $_GET data because WordPress adds
slashes to superglobals.
Validation checks that data meets specific criteria BEFORE sanitizing or using it. WordPress provides these validation functions:
// Check data types.
is_numeric( $value );
is_email( $value );
is_array( $value );
// WordPress-specific validators.
wp_validate_boolean( $value );
term_exists( $term, $taxonomy );
username_exists( $username );
// Custom validation example.
if ( ! in_array( $status, array( 'pending', 'active', 'completed' ), true ) ) {
return new \WP_Error( 'invalid_status', __( 'Invalid status value.', 'plugin-slug' ) );
}
// Range validation for numeric values.
$quantity = absint( $_POST['quantity'] ?? 0 );
if ( $quantity < 1 || $quantity > 999 ) {
return new \WP_Error( 'invalid_quantity', __( 'Quantity must be between 1 and 999.', 'plugin-slug' ) );
}Escape every piece of dynamic data at the point of output. Even data from the database — it may have been compromised or injected before your plugin was installed.
| Context | Function | When to Use |
|---|---|---|
| HTML body | esc_html() |
Text content inside HTML tags |
| HTML attributes | esc_attr() |
Inside attribute values (value="", title="") |
| URLs | esc_url() |
Inside href, src, action attributes |
| JavaScript | esc_js() |
Inside inline JavaScript (avoid if possible) |
| Translated HTML | esc_html__() / esc_html_e() |
Translatable strings in HTML |
| Translated attr | esc_attr__() / esc_attr_e() |
Translatable strings in attributes |
| CSS | Use safecss_filter_attr() |
Dynamic inline styles |
<input
type="text"
name="plugin_slug_api_key"
value="<?php echo esc_attr( $api_key ); ?>"
class="regular-text"
/>
<p class="description">
<?php echo esc_html__( 'Enter your API key.', 'plugin-slug' ); ?>
</p>
<a href="<?php echo esc_url( $dashboard_url ); ?>">
<?php echo esc_html__( 'View Dashboard', 'plugin-slug' ); ?>
</a>The rule is simple: escape late, escape once, and use the right function for the context.
Every form submission and AJAX request must include a nonce, and the handler must verify it. This prevents Cross-Site Request Forgery attacks.
// In the form template:
wp_nonce_field( 'plugin_slug_save_settings', 'plugin_slug_nonce' );
// In the handler:
if ( ! isset( $_POST['plugin_slug_nonce'] )
|| ! wp_verify_nonce(
sanitize_text_field( wp_unslash( $_POST['plugin_slug_nonce'] ) ),
'plugin_slug_save_settings'
)
) {
wp_die( esc_html__( 'Security check failed.', 'plugin-slug' ) );
}// Localize the nonce to JS:
wp_localize_script( 'plugin-slug-admin', 'pluginSlugAdmin', array(
'ajax_url' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'plugin_slug_ajax' ),
) );
// In the AJAX handler:
check_ajax_referer( 'plugin_slug_ajax', 'nonce' );WooCommerce REST API endpoints use their own authentication. For custom WordPress REST API
endpoints, use permission_callback with capability checks instead of nonces:
register_rest_route( 'plugin-slug/v1', '/settings', array(
'methods' => 'POST',
'callback' => array( $this, 'update_settings' ),
'permission_callback' => function () {
return current_user_can( 'manage_woocommerce' );
},
) );Every admin action must verify the user has the required capability. Don't rely on menu visibility alone — direct URL access can bypass menus.
// Before processing any admin action:
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'You do not have permission to perform this action.', 'plugin-slug' ) );
}| Capability | Who Has It | Use For |
|---|---|---|
manage_woocommerce |
Shop Manager, Admin | Plugin settings, order management |
edit_shop_orders |
Shop Manager, Admin | Viewing/editing orders |
view_woocommerce_reports |
Shop Manager, Admin | Reports and analytics |
manage_options |
Admin only | Site-wide configuration |
Every custom database query must use $wpdb->prepare(). No exceptions.
global $wpdb;
// Correct — parameterized query
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}plugin_slug_logs WHERE order_id = %d AND status = %s",
$order_id,
$status
)
);
// WRONG — direct variable interpolation (SQL injection vulnerability)
// $results = $wpdb->get_results( "SELECT * FROM ... WHERE order_id = $order_id" );Use dbDelta() for creating and updating custom tables, and always use $wpdb->prefix.
global $wpdb;
$table_name = $wpdb->prefix . 'plugin_slug_logs';
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE $table_name (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
order_id bigint(20) unsigned NOT NULL,
action varchar(50) NOT NULL,
details longtext,
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY order_id (order_id),
KEY created_at (created_at)
) $charset_collate;";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );- Validate file types by checking MIME type, not just extension
- Use
wp_handle_upload()for any file uploads - Store uploaded files outside the web root when possible
- Add index.php files to all plugin directories to prevent directory browsing
- Check file permissions before writing
- Use
wp_remote_get()/wp_remote_post()(nevercURLdirectly) - Validate SSL certificates (don't disable SSL verification)
- Implement timeouts (default is 5 seconds, set explicitly)
- Don't log sensitive data (API keys, tokens) — mask in logs
- Store API credentials in the options table, never in code
- Verify webhook signatures / HMAC
- Validate the payload schema before processing
- Respond with 200 quickly, then process asynchronously via Action Scheduler
- Rate-limit incoming webhooks
For plugins that handle prices, payments, or financial transactions:
- Use
wc_format_decimal()for all price calculations - Store prices as strings in meta to avoid floating-point errors
- Never use
round()on financial amounts without specifying precision - Match the store's configured decimal precision (
wc_get_price_decimals())
- Every payment operation must be idempotent
- Use unique transaction IDs / idempotency keys
- Check for duplicate processing before executing
- Never store raw credit card numbers, CVVs, or full magnetic stripe data
- Use tokenization through payment gateways
- Transmit payment data only over HTTPS
- Log access to payment-related functionality
Use the WooCommerce logger for all plugin logging:
$logger = wc_get_logger();
$context = array( 'source' => 'plugin-slug' );
$logger->info( 'Payment processed successfully for order #' . $order_id, $context );
$logger->error( 'API request failed: ' . $response_message, $context );
$logger->debug( 'Webhook payload received', $context );- All API requests and responses (with sensitive data masked)
- All payment operations (initiate, complete, fail, refund)
- All admin configuration changes
- All errors and exceptions
- Authentication events
- Credit card numbers, CVVs, or full account numbers
- Raw API keys or tokens
- Personally identifiable information (mask or hash)
- Full request/response bodies in production (use debug level)