Settings & options security
SkillSecurityUse when building an options or settings page with the WordPress Settings API - register_setting, add_settings_field, settings_fields, an options.php form, or update_option / get_option on plugin data. Attaches a sanitize_callback to every setting, gates the page with manage_options, relies on Settings API nonce handling, and escapes options on output. Prevents stored XSS and unauthorized option writes.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Settings & options security skill
What this skill tells your AI
The instructions your AI receives, as published by wpultimatesecurity/wordpress-security-skills in skills/settings-options-security/SKILL.md and read by ahel’s review.
When to use this skill
Use this skill whenever a plugin or theme stores configuration in WordPress options:
- Building a settings page with the Settings API (
register_setting,add_settings_section,add_settings_field). - A form that posts to
options.php. - Calling
update_option()oradd_option()from a custom handler. - Reading options with
get_option()and rendering them anywhere (admin, front-end, emails).
The Settings API gives you nonce handling, capability gating, and a structured sanitization hook for free — but only if you actually wire it. Bypassing it for a "quick" custom form is a common source of stored XSS and unauthorized writes.
Related: see the input-sanitization-validation skill for per-request sanitization
patterns and the nonces-csrf-protection skill for custom-form nonce handling.
Core principles (and why they matter)
- Every registered setting needs a
sanitize_callback. This is the single most important line of defense against stored XSS and malformed data. Without it,update_option()saves raw$_POSTvalues verbatim. - The Settings API supplies the nonce only when you use
settings_fields(). If you build a custom form that callsoptions.php, includesettings_fields( $option_group )or you lose CSRF protection. - Options are untrusted on read. Even if you sanitized on save, escape again on output. A compromised database, a bad import, or a future bug can reintroduce dangerous values.
- Gate the page with a capability, not just a menu position.
add_options_page()already requiresmanage_optionsby default; custom menus should too. - Use typed defaults. Pass a
defaultvalue toregister_setting()soget_option()returns a known shape instead offalse. - If you expose settings via REST (
show_in_rest), provide a schema. Untyped REST options are another stored-XSS vector.
Step-by-step implementation
- On
admin_init, callregister_setting()with:option_groupmatching the page.option_namefor the stored option.sanitize_callbackpointing to a strict sanitizer.defaultwith a safe value.
- Add sections and fields with
add_settings_section()andadd_settings_field(). - Render the form posting to
options.phpand callsettings_fields( $option_group ). - In field callbacks, escape the current option value with
esc_attr()/esc_textarea(). - For custom (non-
options.php) handlers, add your ownwp_nonce_field()and verify it withcheck_admin_referer(), pluscurrent_user_can( 'manage_options' ). - When displaying saved options anywhere, escape for the output context.
Supporting references
| Reference | Load when |
|---|---|
| Settings & options security checklist | Before final verification of the settings & options security controls. |
| Secure Settings API page | Implementing a sanitized Settings API page with an options.php form and escaped output. |
Common AI mistakes / anti-patterns
Mistake 1 — register_setting() with no sanitize callback
// ❌ Insecure: raw POST data is saved into the option.
register_setting( 'my_plugin_group', 'my_plugin_options' );
// ✅ Secure: every setting has a sanitize_callback.
register_setting(
'my_plugin_group',
'my_plugin_options',
array(
'type' => 'array',
'sanitize_callback' => 'my_plugin_sanitize_options',
'default' => array( 'api_key' => '', 'enabled' => 0 ),
)
);
function my_plugin_sanitize_options( $input ) {
$clean = array();
if ( isset( $input['api_key'] ) ) {
$clean['api_key'] = sanitize_text_field( wp_unslash( $input['api_key'] ) );
}
$clean['enabled'] = ! empty( $input['enabled'] ) ? 1 : 0;
return $clean;
}
Mistake 2 — Custom settings form bypassing settings_fields
// ❌ Insecure: no nonce, no capability gate, raw option write.
<form method="post" action="">
<input type="text" name="my_plugin_api_key">
<?php submit_button(); ?>
</form>
<?php
if ( isset( $_POST['my_plugin_api_key'] ) ) {
update_option( 'my_plugin_api_key', $_POST['my_plugin_api_key'] );
}
// ✅ Secure: post to options.php and use settings_fields.
<form method="post" action="options.php">
<?php
settings_fields( 'my_plugin_group' );
do_settings_sections( 'my-plugin' );
submit_button();
?>
</form>
Mistake 3 — Echoing get_option() unescaped
// ❌ Insecure: stored XSS if the option contains JavaScript.
echo '<div class="notice">' . get_option( 'my_plugin_notice' ) . '</div>';
// ✅ Secure: escape on output for the context.
$notice = get_option( 'my_plugin_notice', '' );
echo '<div class="notice">' . esc_html( $notice ) . '</div>';
Mistake 4 — Not recursively sanitizing array options
// ❌ Insecure: only the top-level array is sanitized; nested strings remain raw.
function my_plugin_sanitize_array( $input ) {
return array_map( 'sanitize_text_field', $input );
}
// ✅ Secure: know the schema and sanitize each member.
function my_plugin_sanitize_array( $input ) {
$clean = array();
if ( isset( $input['api_key'] ) ) {
$clean['api_key'] = sanitize_text_field( wp_unslash( $input['api_key'] ) );
}
if ( isset( $input['webhook_url'] ) ) {
$clean['webhook_url'] = esc_url_raw( wp_unslash( $input['webhook_url'] ) );
}
$clean['debug'] = ! empty( $input['debug'] ) ? 1 : 0;
return $clean;
}
Mistake 5 — show_in_rest => true with no schema
// ❌ Insecure: REST consumers can write arbitrary shapes to the option.
register_setting(
'my_plugin_group',
'my_plugin_options',
array( 'show_in_rest' => true )
);
// ✅ Secure: declare a REST schema so WordPress validates the shape.
register_setting(
'my_plugin_group',
'my_plugin_options',
array(
'type' => 'object',
'show_in_rest' => array(
'schema' => array(
'type' => 'object',
'properties' => array(
'api_key' => array( 'type' => 'string' ),
'enabled' => array( 'type' => 'boolean' ),
),
),
),
'sanitize_callback' => 'my_plugin_sanitize_options',
'default' => array( 'api_key' => '', 'enabled' => false ),
)
);
Correct code examples
A complete Settings API page (registration, section, field, form, sanitization) is in
references/secure-settings-page.php.
Checklist
- Every
register_setting()call includes asanitize_callback. - Settings have safe
defaultvalues of the correct type. - The settings form posts to
options.phpand callssettings_fields(). - The admin page is gated by
manage_options(or a stricter custom capability). - Custom non-Settings-API handlers add their own nonce + capability checks.
- Array/object options are recursively sanitized against a known schema.
-
get_option()values are escaped at the point of output. - REST-exposed settings (
show_in_rest) include a schema. - Secrets are stored encrypted or in wp-config constants, not plaintext in options.
Official references
Signals
- GitHub stars
- 31
- Forks
- 2
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
settings-options-security- Source
- github.com/wpultimatesecurity/wordpress-security-skills