Build a Secure WordPress Settings Page Plugin: Options, Permissions, Validation, and Uninstall
A secure WordPress settings plugin starts with a data contract, not an admin-page mockup. Register one option through the Settings API, restrict access with the correct capability.

A secure WordPress settings plugin starts with a data contract, not an admin-page mockup. Register one option through the Settings API, restrict access with the correct capability, validate and sanitize every submitted field, escape each value when it is rendered, and make a deliberate decision about whether uninstall removes stored data.
This guide produces a small but reviewable plugin rather than a copied snippet. The finished evidence includes the option schema, hook map, page code, valid and invalid test cases, lower-privilege access checks, stored-value inspection, and a documented deactivate-versus-uninstall policy.
Use a local or disposable WordPress site and non-sensitive sample values. The example stores a support email, a result-limit integer, and an enabled flag. It does not store API secrets, create custom tables, or claim that a nonce is an authorization control.
What You Need Before You Start
Create a clean plugin folder, enable WordPress debugging locally, and prepare an administrator account plus an editor account. Keep the plugin in version control so every security decision can be reviewed.
- A local WordPress installation and code editor
- Basic PHP arrays, functions, hooks, and namespaces or prefixes
- Administrator and editor test accounts
- A test checklist and permission to delete the plugin from the local site
Keep these boundaries in place:
- No production credentials, payment data, or personal records
- No custom database table or multisite network setting
- No custom AJAX save route when the core Settings API can own submission
- No automatic data deletion without a documented user expectation
1. Define the option contract before writing hooks
Action and reason: Define one option array named re_lab_settings with support_email, result_limit, and enabled keys, including type, default, allowed range, and empty behavior. A stable contract prevents the renderer, sanitizer, and tests from making different assumptions about the same stored value.
Inputs: A three-row field table and explicit defaults. Treat these inputs as the working boundary; adding an unreviewed dependency changes the task and should trigger a new check.
Expected output: An option contract that another developer can implement without guessing. Keep the artifact with the project so another person can inspect the decision instead of relying on a finished screenshot.
Verification: Reviewing every key for type, default, validation rule, storage rule, and output context. Record the input, expected result, observed result, and decision. A pass is credible only when another person can repeat the same check.
Failure condition: The design stores raw form fields or lets missing keys change meaning unpredictably. Correction: Allowlist the keys, normalize missing values, and document the exact returned shape. Repeat the original verification after the correction and retain the failed observation as part of the evidence trail.
2. Create the plugin bootstrap and hook map
Action and reason: Create the plugin header, guard direct access, prefix all public identifiers, and register admin_menu and admin_init callbacks. WordPress lifecycle ownership should be visible before page markup and form processing are added.
Inputs: The plugin slug, text domain, option name, menu slug, and callback names. Treat these inputs as the working boundary; adding an unreviewed dependency changes the task and should trigger a new check.
Expected output: An activatable plugin with no settings UI yet and no PHP notices. Keep the artifact with the project so another person can inspect the decision instead of relying on a finished screenshot.
Verification: Activating and deactivating it twice while reviewing the debug log and hook names. Record the input, expected result, observed result, and decision. A pass is credible only when another person can repeat the same check.
Failure condition: Activation produces output, a naming collision, or a callback loads on public requests unnecessarily. Correction: Remove executable bootstrap output, prefix identifiers, and attach admin-only work to the documented admin hooks. Repeat the original verification after the correction and retain the failed observation as part of the evidence trail.
3. Register the setting, section, and fields
Action and reason: Call register_setting, add_settings_section, and add_settings_field from admin_init and give the setting an array type, defaults, and one sanitize callback. The core Settings API can own form submission, nonce fields, and options-page integration when registration is consistent.
Inputs: The option contract and three field-render callbacks. Treat these inputs as the working boundary; adding an unreviewed dependency changes the task and should trigger a new check.
Expected output: A registered option group and a page schema that WordPress can process. Keep the artifact with the project so another person can inspect the decision instead of relying on a finished screenshot.
Verification: Checking that the page renders all fields, settings_errors can display feedback, and a valid administrator submission reaches the sanitize callback. Record the input, expected result, observed result, and decision. A pass is credible only when another person can repeat the same check.
Failure condition: The option group, page slug, or settings_fields value differs between registration and rendering. Correction: Use shared constants for the group, option, and page identifiers, then repeat a clean save. Repeat the original verification after the correction and retain the failed observation as part of the evidence trail.
4. Enforce capability and nonce boundaries
Action and reason: Register the menu page with manage_options, reject rendering when current_user_can fails, and let settings_fields generate the Settings API nonce for the options.php submission. A nonce helps detect unintended requests but does not prove the user is allowed to change settings.
Inputs: Administrator and editor accounts plus the generated settings form. Treat these inputs as the working boundary; adding an unreviewed dependency changes the task and should trigger a new check.
Expected output: An admin-only page whose submission follows the core options route. Keep the artifact with the project so another person can inspect the decision instead of relying on a finished screenshot.
Verification: Opening the menu and attempting the save as both roles, then submitting a stale or missing nonce in the disposable environment. Record the input, expected result, observed result, and decision. A pass is credible only when another person can repeat the same check.
Failure condition: An editor can reach or save the page, or custom code treats wp_verify_nonce as authorization. Correction: Restore the capability check at the menu, page, and any custom action boundary and keep nonce verification separate. Repeat the original verification after the correction and retain the failed observation as part of the evidence trail.
5. Validate and sanitize the complete array
Action and reason: Build one sanitize callback that starts from defaults, accepts only known keys, validates the email, clamps the integer to 1 through 50, normalizes the checkbox to 0 or 1, and reports rejected input. Sanitizing each field independently without reconstructing the array can preserve unexpected keys or produce inconsistent state.
Inputs: The raw option array and the prior stored value where recovery is appropriate. Treat these inputs as the working boundary; adding an unreviewed dependency changes the task and should trigger a new check.
Expected output: One normalized array matching the declared contract. Keep the artifact with the project so another person can inspect the decision instead of relying on a finished screenshot.
Verification: Submitting valid, empty, malformed, oversized, negative, and unexpected-key payloads and inspecting the stored option. Record the input, expected result, observed result, and decision. A pass is credible only when another person can repeat the same check.
Failure condition: Malformed email is stored, result_limit escapes its range, or an injected key survives. Correction: Reject or normalize at the sanitize callback, add a settings error, and rerun the exact failing payload. Repeat the original verification after the correction and retain the failed observation as part of the evidence trail.
6. Render fields with context-aware escaping
Action and reason: Read the option through a getter that merges defaults, then use esc_attr for input values, checked for the checkbox state, and esc_html for explanatory text. Safe storage does not remove the need to escape values for the HTML context in which they are printed.
Inputs: The normalized option array and field labels. Treat these inputs as the working boundary; adding an unreviewed dependency changes the task and should trigger a new check.
Expected output: An accessible form with associated labels, descriptions, and stable values. Keep the artifact with the project so another person can inspect the decision instead of relying on a finished screenshot.
Verification: Saving characters such as quotes and angle brackets in the disposable site and inspecting both the visible form and generated HTML. Record the input, expected result, observed result, and decision. A pass is credible only when another person can repeat the same check.
Failure condition: Raw values are echoed, labels are not associated, or output changes the stored data. Correction: Escape only at output, select the function for the exact context, and keep storage normalization separate. Repeat the original verification after the correction and retain the failed observation as part of the evidence trail.
7. Test lifecycle behavior and negative paths
Action and reason: Run activation, valid save, invalid save, permission, nonce, deactivation, reactivation, and deletion cases in a fixed matrix. The happy-path screenshot does not prove permissions, persistence, error handling, or cleanup behavior.
Inputs: Two user roles, the payload set, expected database values, and a fresh plugin install. Treat these inputs as the working boundary; adding an unreviewed dependency changes the task and should trigger a new check.
Expected output: A test record with expected result, observed result, and pass or fix decision for every case. Keep the artifact with the project so another person can inspect the decision instead of relying on a finished screenshot.
Verification: Repeating each failed case after correction and comparing get_option output with the contract. Record the input, expected result, observed result, and decision. A pass is credible only when another person can repeat the same check.
Failure condition: Deactivation deletes preferences, invalid input silently replaces valid settings, or a denied request mutates data. Correction: Move cleanup out of deactivation, preserve prior values deliberately, and repair the authorization or sanitization boundary. Repeat the original verification after the correction and retain the failed observation as part of the evidence trail.
8. Choose and implement an uninstall policy
Action and reason: Decide whether deletion should retain preferences or remove the option, document that choice, and if removing data use uninstall.php guarded by WP_UNINSTALL_PLUGIN. WordPress treats deactivation as temporary and uninstall as deletion, so cleanup should match an explicit lifecycle promise.
Inputs: The option name, product expectation, privacy impact, and multisite scope decision. Treat these inputs as the working boundary; adding an unreviewed dependency changes the task and should trigger a new check.
Expected output: Either a documented retention policy or a minimal guarded uninstall routine. Keep the artifact with the project so another person can inspect the decision instead of relying on a finished screenshot.
Verification: Deactivating and reactivating first, then deleting the plugin on the disposable site and checking the option table. Record the input, expected result, observed result, and decision. A pass is credible only when another person can repeat the same check.
Failure condition: The deactivation hook erases user configuration or uninstall.php can run directly. Correction: Restore temporary-state behavior, add the WordPress uninstall guard, and repeat the lifecycle test from a clean install. Repeat the original verification after the correction and retain the failed observation as part of the evidence trail.
Review the Evidence Before You Call It Complete
Run the work as a review, not as a presentation. Start with the promised outcome: Only an authorized administrator can open and save the settings page. Ask a second person to follow the documented inputs and checks without receiving a private explanation. Record where they cannot reproduce a result, where a decision lacks evidence, and where the artifact depends on hidden knowledge. Those gaps are part of the work and should be corrected before screenshots or portfolio copy are finalized.
Use the remaining acceptance criteria as release conditions: The stored array contains only declared keys and normalized values; Every rendered dynamic value is escaped for its HTML context; Invalid and stale requests fail without changing the option; Deactivation and uninstall follow the documented lifecycle policy. A failed condition should identify the smallest upstream step that owns the defect. Correct that step, repeat the same check, and preserve the before-and-after result. This review discipline is what turns an exercise into credible evidence of skill without claiming client experience, production success, or testing that did not occur.
Completion Standard
The plugin is complete when its behavior can be explained from the option contract and reproduced through both normal and hostile test cases.
- Only an authorized administrator can open and save the settings page
- The stored array contains only declared keys and normalized values
- Every rendered dynamic value is escaped for its HTML context
- Invalid and stale requests fail without changing the option
- Deactivation and uninstall follow the documented lifecycle policy
Learners in Gujrat and live online WordPress learners across Pakistan can use this bounded plugin as supervised practice because the reviewer can inspect code, stored values, denied requests, and lifecycle evidence instead of judging only the final admin screen.
When you want guided review of the complete workflow, the WordPress Development course provides a structured path from fundamentals to supervised project evidence. The article remains a self-contained method; the program is the next step for learners who need feedback, correction, and repeated practice.
Sources And Verification Notes
- Settings API: Supports core settings registration, options.php submission, capability behavior, nonce fields, and sanitization integration.
- Nonces: Supports separating request-intent checks from authorization.
- Uninstall Methods: Supports lifecycle distinction and guarded uninstall cleanup.
- Escaping Data: Supports late, context-appropriate output escaping.
FAQ
Does the WordPress Settings API make a plugin secure automatically?
No. It supplies a consistent submission path and security helpers, but the plugin still needs a correct capability, a strict sanitize callback, context-aware escaping, and negative tests.
Should plugin settings be deleted on deactivation?
Usually no. Deactivation is commonly temporary. Delete persistent settings only during uninstall when that behavior is documented and appropriate for the user.
Why test with an editor account?
The lower-privilege account proves that menu visibility and save behavior are enforced by capabilities rather than hidden interface elements.
Want to Build Practical Technology Skills?
Explore RisingEdge courses designed to help students learn real skills, build projects, and prepare for career opportunities.



