Skip to content

Security

When building WordPress themes with Føhn, security should be a top priority. This guide covers essential security practices, focusing on output escaping to prevent Cross-Site Scripting (XSS) vulnerabilities.

Output Escaping

Never trust user input. All dynamic content must be escaped before output, using the appropriate function for the context.

Escaping Functions

FunctionUse CaseExample
esc_html()Text content inside HTML elements<p><?php echo esc_html($text); ?></p>
esc_attr()HTML attribute values<div class="<?php echo esc_attr($class); ?>">
esc_url()URLs (href, src, etc.)<a href="<?php echo esc_url($url); ?>">
esc_textarea()Textarea content<textarea><?php echo esc_textarea($text); ?></textarea>
esc_js()Inline JavaScript strings<script>var x = '<?php echo esc_js($val); ?>';</script>
wp_kses_post()HTML content (allows safe tags)<div><?php echo wp_kses_post($html); ?></div>
wp_kses()HTML with custom allowed tagsecho wp_kses($html, $allowed_tags);

When to Use Each Function

esc_html() — Plain Text

Use for any text that should not contain HTML:

php
// ✅ Good: Escaped output
echo '<h1>' . esc_html($title) . '</h1>';

// ❌ Bad: Unescaped user input
echo '<h1>' . $title . '</h1>';

esc_attr() — HTML Attributes

Use for values inside HTML attributes:

php
// ✅ Good: Escaped attribute
echo '<input type="text" value="' . esc_attr($value) . '">';
echo '<div class="' . esc_attr($classes) . '">';
echo '<div data-config="' . esc_attr(json_encode($config)) . '">';

// ❌ Bad: Unescaped attribute (XSS via " onclick="alert(1))
echo '<input type="text" value="' . $value . '">';

esc_url() — URLs

Use for any URL output:

php
// ✅ Good: Escaped URL
echo '<a href="' . esc_url($link) . '">Click</a>';
echo '<img src="' . esc_url($image_url) . '">';

// ❌ Bad: Allows javascript: protocol XSS
echo '<a href="' . $link . '">Click</a>';

wp_kses_post() — Rich HTML Content

Use when you need to allow safe HTML (like post content):

php
// ✅ Good: Allows safe HTML, strips dangerous tags
echo '<div class="content">' . wp_kses_post($content) . '</div>';

// ❌ Bad: Allows all HTML including <script>
echo '<div class="content">' . $content . '</div>';

wp_kses() — Custom Allowed Tags

Use when you need fine-grained control over allowed HTML:

php
$allowed = [
    'a' => ['href' => [], 'title' => [], 'target' => []],
    'strong' => [],
    'em' => [],
];

echo wp_kses($user_bio, $allowed);

Shortcode Security

Shortcodes are particularly vulnerable because they process user-provided attributes and content. Always escape output:

php
#[AsShortcode('greeting')]
public function greeting(array $atts, ?string $content = null): string
{
    $atts = shortcode_atts([
        'name' => 'World',
        'class' => 'greeting',
    ], $atts);

    // ✅ Every dynamic value is escaped appropriately
    return sprintf(
        '<div class="%s"><p>Hello, %s!</p>%s</div>',
        esc_attr($atts['class']),      // Attribute context
        esc_html($atts['name']),       // Text context
        wp_kses_post($content)         // HTML content context
    );
}

Common Shortcode Mistakes

php
// ❌ DANGEROUS: No escaping
#[AsShortcode('unsafe')]
public function unsafe(array $atts): string
{
    return '<div class="' . $atts['class'] . '">' . $atts['content'] . '</div>';
}

// ✅ SAFE: Properly escaped
#[AsShortcode('safe')]
public function safe(array $atts): string
{
    $atts = shortcode_atts([
        'class' => 'default',
        'content' => '',
    ], $atts);

    return sprintf(
        '<div class="%s">%s</div>',
        esc_attr($atts['class']),
        esc_html($atts['content'])
    );
}

See Shortcodes Guide for more examples.

Twig Template Security

Timber/Twig auto-escapes output by default, but be aware of the |raw filter:

twig
{# ✅ Safe: Auto-escaped by Twig #}
<h1>{{ post.title }}</h1>
<p>{{ user_input }}</p>

{# ⚠️ Careful: |raw disables escaping - only use for trusted HTML #}
<div>{{ post.content|raw }}</div>

{# ✅ Safe: Use e() filter with context for explicit escaping #}
<a href="{{ url|e('url') }}">{{ text|e('html') }}</a>
<div data-value="{{ value|e('html_attr') }}">

When to Use |raw

Only use |raw for content that:

  1. Comes from WordPress's post content (already sanitized on save)
  2. Is generated by trusted code (not user input)
  3. Has been explicitly sanitized with wp_kses_post() or similar
twig
{# ✅ OK: Post content from WordPress #}
{{ post.content|raw }}

{# ✅ OK: Rendered shortcode output #}
{{ shortcode_output|raw }}

{# ❌ NEVER: Direct user input #}
{{ request.get('comment')|raw }}

REST API Security

When building REST endpoints, validate and sanitize all input:

php
#[AsRestRoute('/contact', methods: ['POST'])]
public function handleContact(\WP_REST_Request $request): \WP_REST_Response
{
    // ✅ Validate required fields
    $email = sanitize_email($request->get_param('email'));
    $message = sanitize_textarea_field($request->get_param('message'));

    if (!is_email($email)) {
        return new \WP_REST_Response(['error' => 'Invalid email'], 400);
    }

    // Process the sanitized data...
}

Non-Production Indexing

Every environment that is not production is kept out of the search index automatically. A staging copy is the same site with the same content on a crawlable hostname, and indexed it competes with the real site for the real site's own pages — usually noticed long after the damage is done.

Føhn applies four measures as soon as wp_get_environment_type() reports anything other than production:

HookEffect
wp_robots<meta name="robots" content="noindex, nofollow"> in the document
send_headersX-Robots-Tag: noindex, nofollow on the response
robots_txtUser-agent: * followed by Disallow: /
wp_sitemaps_enabledCore sitemaps off, so /wp-sitemap.xml returns a 404

Contradictory index and follow directives are removed from the robots array, so an SEO plugin that adds them cannot produce content="follow, noindex, nofollow". Directives about how to present an indexed page, such as max-image-preview, are left alone.

The meta tag is the protection that matters, not robots.txt. Føhn serves cached pages from a file: nginx or the drop-in answers and PHP does not run at all on later requests, so a rule carried only by a header PHP sends applies to the first visitor and to nobody after. The directive in the document is stored with the page and is still there whichever reader hands the file over. robots.txt is advisory and is fetched once for the whole host.

Nothing here is configurable. There is no option, no filter and no opt-in: an indexing guard that a project can forget to enable is a staging site in the search results.

Production is untouched. In production the module registers no hooks at all, so a production response is byte-for-byte what it would be without it. There is no filter to misbehave and no header to send by accident.

blog_public is never written. Marking a site private through the option would put the policy in the database, and a database copied from staging to production — which is how staging databases usually arrive anywhere — would carry the staging answer with it and de-index the live site. Deployment configuration owns WP_ENVIRONMENT_TYPE; Føhn only reads it.

Known limit: this reaches only what PHP and WordPress emit. A CDN or web server that adds an X-Robots-Tag of its own is outside it, and so is a robots.txt served as a real file before WordPress is reached. Inspect the public HTTP response when the guarantee has to hold end to end.

Security Checklist

Before deploying, verify:

  • [ ] All shortcode output uses appropriate escaping functions
  • [ ] Twig templates don't use |raw on untrusted content
  • [ ] REST API endpoints validate and sanitize input
  • [ ] Database queries use prepared statements ($wpdb->prepare())
  • [ ] Nonces are verified for form submissions
  • [ ] User capabilities are checked before privileged operations
  • [ ] File uploads are validated (type, size, content)

Additional Resources

Released under the MIT License.