Settings & options security
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.