WordPress Settings API Example: Build a Safer Plugin Settings Page
Build a safer WordPress plugin settings page with register_setting, sanitization callbacks, capability checks, escaping, and predictable defaults.
Published
April 17, 2026
Reading Time
2 min read
Updated
April 17, 2026

Hardening Notes
Baselines, access reduction, and default settings that stand up in production.
Best For
Teams preparing, launching, or maintaining WordPress as a backend service in a production stack.
Primary Topics
Editorial Focus
Control Ledger: Baselines, access reduction, and default settings that stand up in production. Updated on April 17, 2026.
Full Report
Last reviewed: April 17, 2026
A WordPress Settings API example is useful because many plugins start with a simple option and then quietly turn into fragile admin code. The common bad pattern is direct $_POST handling inside an options page, missing capability checks, missing nonces, unescaped values, and no clear sanitization boundary.
This guide shows a small plugin settings page using the WordPress Settings API. The goal is not to build a complex admin interface. The goal is to create a safe baseline that stores options predictably and can survive production use.
Who this guide is for
This article is for WordPress plugin developers and teams maintaining custom admin tools. If your plugin needs toggles, API endpoint URLs, display messages, feature flags, or integration settings, start here before building custom form handling.
What the Settings API gives you
The Settings API helps register options, render fields, output nonce fields, group settings, and run sanitization callbacks. It does not remove the need for careful escaping, capability checks, or a clear data model, but it gives you the correct WordPress plumbing.
Plugin example: a safer settings page
The example below creates a settings page under Settings -> VulnWP Alerts. It stores an enabled flag and a short message in a single option array. The page is only available to administrators who can manage options.
<?php
/**
* Plugin Name: VulnWP Alert Settings
* Description: Example Settings API page with sanitization and escaping.
* Version: 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
const VULNWP_ALERT_OPTION = 'vulnwp_alert_settings';
add_action( 'admin_menu', 'vulnwp_alert_add_settings_page' );
add_action( 'admin_init', 'vulnwp_alert_register_settings' );
function vulnwp_alert_add_settings_page() {
add_options_page(
__( 'VulnWP Alerts', 'vulnwp-alert-settings' ),
__( 'VulnWP Alerts', 'vulnwp-alert-settings' ),
'manage_options',
'vulnwp-alert-settings',
'vulnwp_alert_render_settings_page'
);
}
function vulnwp_alert_register_settings() {
register_setting(
'vulnwp_alert_group',
VULNWP_ALERT_OPTION,
array(
'type' => 'array',
'sanitize_callback' => 'vulnwp_alert_sanitize_settings',
'default' => array(
'enabled' => false,
'message' => '',
),
)
);
add_settings_section(
'vulnwp_alert_main',
__( 'Alert Display', 'vulnwp-alert-settings' ),
'vulnwp_alert_render_section_intro',
'vulnwp-alert-settings'
);
add_settings_field(
'enabled',
__( 'Enable alert', 'vulnwp-alert-settings' ),
'vulnwp_alert_render_enabled_field',
'vulnwp-alert-settings',
'vulnwp_alert_main'
);
add_settings_field(
'message',
__( 'Alert message', 'vulnwp-alert-settings' ),
'vulnwp_alert_render_message_field',
'vulnwp-alert-settings',
'vulnwp_alert_main'
);
}
function vulnwp_alert_sanitize_settings( $input ) {
$input = is_array( $input ) ? $input : array();
return array(
'enabled' => ! empty( $input['enabled'] ),
'message' => isset( $input['message'] )
? sanitize_text_field( wp_unslash( $input['message'] ) )
: '',
);
}
function vulnwp_alert_get_settings() {
$defaults = array(
'enabled' => false,
'message' => '',
);
$settings = get_option( VULNWP_ALERT_OPTION, $defaults );
return wp_parse_args( $settings, $defaults );
}
function vulnwp_alert_render_section_intro() {
echo '<p>' . esc_html__( 'Configure the frontend alert message.', 'vulnwp-alert-settings' ) . '</p>';
}
function vulnwp_alert_render_enabled_field() {
$settings = vulnwp_alert_get_settings();
printf(
'<label><input type="checkbox" name="%1$s[enabled]" value="1" %2$s> %3$s</label>',
esc_attr( VULNWP_ALERT_OPTION ),
checked( true, (bool) $settings['enabled'], false ),
esc_html__( 'Show the alert', 'vulnwp-alert-settings' )
);
}
function vulnwp_alert_render_message_field() {
$settings = vulnwp_alert_get_settings();
printf(
'<input type="text" class="regular-text" name="%1$s[message]" value="%2$s" maxlength="140">',
esc_attr( VULNWP_ALERT_OPTION ),
esc_attr( $settings['message'] )
);
}
function vulnwp_alert_render_settings_page() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have permission to access this page.', 'vulnwp-alert-settings' ) );
}
?>
<div class="wrap">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
<form method="post" action="options.php">
<?php
settings_fields( 'vulnwp_alert_group' );
do_settings_sections( 'vulnwp-alert-settings' );
submit_button();
?>
</form>
</div>
<?php
}
Why this is safer than manual form handling
The form posts to options.php, and settings_fields() outputs the correct hidden fields and nonce for the registered settings group. The stored values pass through vulnwp_alert_sanitize_settings(), which is the single input boundary for the option.
That still leaves output escaping as a separate requirement. Values printed into HTML attributes use esc_attr(). Text printed into normal HTML should use esc_html(). Sanitizing on save and escaping on output are related, but they are not the same control.
Checklist for production settings pages
- Use
add_options_page()or another admin menu function with the narrowest useful capability. - Use
register_setting()with a sanitization callback. - Keep all option sanitization in one function per option group.
- Use
settings_fields()anddo_settings_sections()instead of hand-building hidden fields. - Escape every value when rendering form fields.
- Store related settings in one option array when they are loaded together.
- Document default values so empty installs behave predictably.
Common mistakes
- Trusting admin input. Administrators can still paste unsafe values, and compromised admin sessions are a real incident path.
- Using
sanitize_text_field()for everything. URLs, integers, booleans, emails, and HTML each need different handling. - Escaping only on save. Escaping belongs at output time because output context changes.
- Skipping capability checks. Menu capabilities control visibility, but explicit checks make the page harder to misuse.
- Spreading option names across the codebase. Constants reduce typos and make migrations easier.
Adding frontend output safely
If the option controls frontend output, read the stored option and escape for the final context. This example prints a simple message only when the toggle is enabled.
add_action( 'wp_footer', 'vulnwp_alert_render_frontend_message' );
function vulnwp_alert_render_frontend_message() {
$settings = vulnwp_alert_get_settings();
if ( empty( $settings['enabled'] ) || '' === $settings['message'] ) {
return;
}
printf(
'<div class="vulnwp-alert" role="status">%s</div>',
esc_html( $settings['message'] )
);
}
Where this fits with plugin security
A settings page is often the first privileged surface in a plugin. Treat it like any other write action: capability boundary, nonce boundary, sanitization, escaping, and predictable defaults. If the setting later controls REST API output, cron behavior, email delivery, or external integrations, this baseline matters even more.
Related reading
Pair this with our WordPress nonce example for custom admin actions that cannot use options.php, and with the administrator access audit checklist for reviewing who can change production settings.


