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
| Function | Use Case | Example |
|---|---|---|
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 tags | echo wp_kses($html, $allowed_tags); |
When to Use Each Function
esc_html() — Plain Text
Use for any text that should not contain HTML:
// ✅ 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:
// ✅ 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:
// ✅ 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):
// ✅ 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:
$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:
#[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
// ❌ 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:
{# ✅ 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:
- Comes from WordPress's post content (already sanitized on save)
- Is generated by trusted code (not user input)
- Has been explicitly sanitized with
wp_kses_post()or similar
{# ✅ 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:
#[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:
| Hook | Effect |
|---|---|
wp_robots | <meta name="robots" content="noindex, nofollow"> in the document |
send_headers | X-Robots-Tag: noindex, nofollow on the response |
robots_txt | User-agent: * followed by Disallow: / |
wp_sitemaps_enabled | Core 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
|rawon 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)