---
url: /foehn-framework/guide/security.md
---
# 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:

```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](/guide/shortcodes#security-best-practices) 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`:

| 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 `|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

* [WordPress Data Validation](https://developer.wordpress.org/plugins/security/data-validation/)
* [WordPress Escaping](https://developer.wordpress.org/apis/security/escaping/)
* [OWASP XSS Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html)
