> For the complete documentation index, see [llms.txt](https://docs.boldminded.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.boldminded.com/speedy/docs/configuration/dynamic-template-values.md).

# Dynamic Template Values

## &#x20;<a href="#dynamic-values" id="dynamic-values"></a>

Static caching works by writing a page to disk once and serving those same bytes to everyone, without booting ExpressionEngine. That is what makes it fast, and it is also why anything that must differ per request — a CSRF token, a CSP nonce — cannot simply be baked into the cached file.

Speedy solves this with a two-sided placeholder swap:

1. **On write**, the live value is stripped out of the rendered page and replaced with a placeholder such as `{csp_nonce}`, so the real value never touches disk.
2. **On read**, every time the cached page is served, a fresh value is generated and substituted back in — along with any HTTP headers that value feeds.

Speedy has always done this for `csrf_token`. As of this release the mechanism is configurable, so you can add your own per-request values.

***

### Quick start: a CSP nonce <a href="#quick-start-a-csp-nonce" id="quick-start-a-csp-nonce"></a>

```php
$config['speedy_dynamic_values'] = [
    'csp_nonce' => [
        'generate' => 'random_base64:16',
        'capture'  => ['/nonce="[A-Za-z0-9+\/=]{16,}"/um' => 'nonce="{csp_nonce}"'],
        'headers'  => ["Content-Security-Policy: script-src 'nonce-%s' 'strict-dynamic'"],
    ],
];
```

In your template, output the nonce as you normally would for an uncached page. Speedy's `capture` pattern rewrites it to a placeholder on the way into the cache:

```html
<script nonce="{your_nonce_variable}">…</script>
```

Every cached hit then gets a fresh nonce in both the markup and the `Content-Security-Policy` header, and the two always match.

> **Read** [**Caching and per-request values**](http://localhost:63342/markdownPreview/711297828/markdown-preview-index-9q8s30cuovlb8lj2k5um0pfsh2.html#caching-and-per-request-values) **before deploying a CSP nonce.** A per-request nonce is incompatible with a shared CDN cache in front of your origin.

### Configuration reference <a href="#configuration-reference" id="configuration-reference"></a>

Each entry is keyed by its **placeholder name**. The name must match `[a-z0-9_]{1,64}` and becomes the `{placeholder}` token used in your content.

| Key        | Required | Description                                                                                                                                                               |
| ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `generate` | yes      | Which generator produces the value. See [Generators](http://localhost:63342/markdownPreview/711297828/markdown-preview-index-9q8s30cuovlb8lj2k5um0pfsh2.html#generators). |
| `capture`  | no       | Write-side patterns that strip a live value back to a placeholder.                                                                                                        |
| `replace`  | no       | Read-side substitutions. Defaults to replacing the bare `{name}` token.                                                                                                   |
| `headers`  | no       | Response headers to send, each a `sprintf` template receiving the value.                                                                                                  |

#### Generators <a href="#generators" id="generators"></a>

| Generator         | Argument               | Produces                                                                                                                                                                             |
| ----------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `random_base64:N` | byte length (required) | Base64 of N random bytes. `random_base64:16` → `pEtSB2wbOHy+13LcHh/7Yw==`                                                                                                            |
| `random_hex:N`    | byte length (required) | Hex of N random bytes. `random_hex:8` → `9f3a1c72b40de815`                                                                                                                           |
| `uuid4`           | —                      | A version 4 UUID.                                                                                                                                                                    |
| `timestamp`       | —                      | The current Unix timestamp, as a string.                                                                                                                                             |
| `cookie:name`     | cookie name (required) | The named cookie's value. See the [safety note](http://localhost:63342/markdownPreview/711297828/markdown-preview-index-9q8s30cuovlb8lj2k5um0pfsh2.html#cookie-values-are-filtered). |
| `csrf_token`      | —                      | The current user's ExpressionEngine CSRF token.                                                                                                                                      |

Anything not in this list is rejected. There is deliberately no way to supply arbitrary PHP: generator names are compiled into the static cache files Speedy writes, so keeping the set closed means a typo produces a clear error in the control panel rather than broken pages.

#### `capture` — keeping live values off disk <a href="#capture--keeping-live-values-off-disk" id="capture--keeping-live-values-off-disk"></a>

`capture` runs when a page is cached. It is a map of regular expression to replacement, applied to the rendered HTML before it is written:

```php
'capture' => [
    '/nonce="[A-Za-z0-9+\/=]{16,}"/um' => 'nonce="{csp_nonce}"',
],
```

You can also give a single pattern as a plain string, or a list of patterns. In both cases the replacement defaults to the entry's own `{placeholder}` token:

```php
'capture' => '/nonce="[a-f0-9-]{36}"/',        // → replaced with {csp_nonce}
'capture' => ['/pattern-a/', '/pattern-b/'],   // → both replaced with {csp_nonce}
```

**`capture` is optional.** If your template outputs the literal placeholder text `{csp_nonce}` rather than a real value, there is nothing to strip and you can omit it entirely. That is the simpler setup, but note the *uncached* first request will then contain the literal placeholder too — so it only works if the page is never served uncached, which is rarely true. Most sites want `capture`.

#### `replace` — putting the value back <a href="#replace--putting-the-value-back" id="replace--putting-the-value-back"></a>

`replace` runs every time a cached page is served. It maps a literal search string to a `sprintf` template that receives the generated value:

```php
'replace' => [
    '{csp_nonce}' => '%s',
],
```

The default — replacing the bare `{name}` token with the raw value — is what you want most of the time, so `replace` is usually omitted. Specify it when one value needs to appear in several different shapes; the built-in `csrf_token` entry does exactly this to handle links, form fields, and the `{speedy_csrf_token}` variable.

Substitution uses plain string replacement, never a regex, so a generated value containing `$1` or `\0` is inserted literally.

#### `headers` <a href="#headers" id="headers"></a>

Each header is a `sprintf` template receiving the generated value:

```php
'headers' => [
    "Content-Security-Policy: script-src 'nonce-%s' 'strict-dynamic'",
],
```

The value generated for the header is the *same* value substituted into the markup on that request — that is what makes a CSP nonce work. Headers containing a line break are rejected, to prevent one entry from injecting others.

Headers are only sent if the value is non-empty, and only if the page has not already started output.

***

### The built-in `csrf_token` entry <a href="#the-built-in-csrf_token-entry" id="the-built-in-csrf_token-entry"></a>

Speedy ships this entry by default. You do not need to configure it, and the behaviour is identical to previous releases:

```php
'csrf_token' => [
    'generate' => 'csrf_token',
    'capture' => [
        '/csrf_token=([a-zA-Z0-9]{40})/um'
            => 'csrf_token={csrf_token}',
        '/<input type="hidden" name="csrf_token" value="([a-zA-Z0-9]{40})"\s?\/?>/um'
            => '<input type="hidden" name="csrf_token" value="{csrf_token}" />',
    ],
    'replace' => [
        'csrf_token={csrf_token}' => 'csrf_token=%s',
        '{speedy_csrf_token}' => '%s',
        '<input type="hidden" name="csrf_token" value="{csrf_token}" />'
            => '<input type="hidden" name="csrf_token" value="%s" data-updated="true" />',
        // …plus unspaced and unclosed variants of the same field
    ],
],
```

#### Overriding it <a href="#overriding-it" id="overriding-it"></a>

Defining your own `csrf_token` key **replaces Speedy's entry entirely** — the two are not merged, so that the result is predictable. Copy the block above and edit it rather than writing a partial entry.

A common reason to override is a form field Speedy's default patterns do not match, for example one rendered with single quotes:

```php
$config['speedy_dynamic_values'] = [
    'csrf_token' => [
        'generate' => 'csrf_token',
        'capture' => [
            '/csrf_token=([a-zA-Z0-9]{40})/um' => 'csrf_token={csrf_token}',
            "/<input type='hidden' name='csrf_token' value='([a-zA-Z0-9]{40})'\s?\/?>/um"
                => "<input type='hidden' name='csrf_token' value='{csrf_token}' />",
        ],
        'replace' => [
            'csrf_token={csrf_token}' => 'csrf_token=%s',
            '{speedy_csrf_token}' => '%s',
            "<input type='hidden' name='csrf_token' value='{csrf_token}' />"
                => "<input type='hidden' name='csrf_token' value='%s' />",
        ],
    ],
];
```

#### Disabling it <a href="#disabling-it" id="disabling-it"></a>

Set the entry to `false`:

```php
$config['speedy_dynamic_values'] = [
    'csrf_token' => false,
];
```

Only do this if your site has no forms served from cached pages. With CSRF disabled, Speedy skips building its CSRF machinery on cached requests entirely, which avoids a database lookup — but any form on a cached page will submit an unusable token.

***

### More examples <a href="#more-examples" id="more-examples"></a>

#### Request correlation <a href="#request-correlation" id="request-correlation"></a>

Emit an identifier into the page so a CDN log line can be matched to an origin log line:

```php
$config['speedy_dynamic_values'] = [
    'request_id' => ['generate' => 'uuid4'],
    'served_at'  => ['generate' => 'timestamp'],
];
```

```html
<!-- req {request_id} served {served_at} -->
```

#### Echoing an A/B bucket <a href="#echoing-an-ab-bucket" id="echoing-an-ab-bucket"></a>

If a cookie assigns visitors to a test bucket, a cached page can reflect it:

```php
$config['speedy_dynamic_values'] = [
    'ab_variant' => ['generate' => 'cookie:ab_test'],
];
```

```html
<body data-variant="{ab_variant}">
```

#### A nonce plus a request id together <a href="#a-nonce-plus-a-request-id-together" id="a-nonce-plus-a-request-id-together"></a>

Entries are independent and can be combined freely:

```php
$config['speedy_dynamic_values'] = [
    'csp_nonce' => [
        'generate' => 'random_base64:16',
        'capture'  => ['/nonce="[A-Za-z0-9+\/=]{16,}"/um' => 'nonce="{csp_nonce}"'],
        'headers'  => ["Content-Security-Policy: script-src 'nonce-%s'"],
    ],
    'request_id' => ['generate' => 'uuid4'],
];
```

### Caching and per-request values <a href="#caching-and-per-request-values" id="caching-and-per-request-values"></a>

**A per-request value cannot survive a shared cache.** If a CDN, reverse proxy, or Varnish instance sits in front of your origin and caches the page publicly, every visitor receives the same stored copy — including the same nonce. A CSP nonce reused across visitors provides no protection.

If you use Speedy's reverse-proxy purgers (Cloudflare, Fastly, CloudFront) with a `public` cache-control, dynamic values will **not** behave correctly for pages cached at the edge. Dynamic values are for pages served from your origin's static cache, where PHP still runs on each request.

The same caveat applies to any server rule that serves Speedy's cached `.html` files directly. Speedy writes both an `index.php` and (with `speedy_static_use_html_file`) an `index.html`; substitution happens in the PHP file. An Nginx `try_files` rule that serves the `.html` and bypasses PHP will serve raw, unsubstituted placeholders.

#### Changing configuration does not rewrite existing cache files <a href="#changing-configuration-does-not-rewrite-existing-cache-files" id="changing-configuration-does-not-rewrite-existing-cache-files"></a>

Each static cache file embeds a snapshot of the definitions in force when it was written. Editing `speedy_dynamic_values` affects pages cached *after* the change; already-cached pages keep their old behaviour until they expire or are cleared.

**After changing this setting, clear your cache.** Existing files continue to work — they are never left broken — but a newly added placeholder will not be substituted in a page cached before you added it.

Cache files written by a version of Speedy predating this feature still have their CSRF tokens replaced correctly; they fall back to the built-in behaviour.

#### Regenerate static utility files <a href="#regenerate-static-utility-files" id="regenerate-static-utility-files"></a>

This feature adds a file to your static cache's `utilities/` directory. After upgrading, go to Speedy's control panel and use **Regenerate files** if prompted. Until you do, dynamic values are skipped rather than applied — pages still serve correctly, but new placeholders are not substituted.

### Safety notes <a href="#safety-notes" id="safety-notes"></a>

#### Cookie values are filtered <a href="#cookie-values-are-filtered" id="cookie-values-are-filtered"></a>

Cookies are attacker-controlled. A value from `cookie:` is only used if it matches `[A-Za-z0-9_\-.:]` — anything else becomes an empty string rather than being written into your page. This prevents a crafted cookie from breaking out of an HTML attribute or injecting a response header.

Even so, treat a cookie-derived value as untrusted input, and do not place it anywhere a partial value could change the meaning of surrounding markup.

#### Invalid configuration is reported, not fatal <a href="#invalid-configuration-is-reported-not-fatal" id="invalid-configuration-is-reported-not-fatal"></a>

An entry that fails validation is skipped and an error is shown in Speedy's control panel. Other entries continue to work. Validation rejects unknown generators, missing or malformed generator arguments, zero-length random values, invalid placeholder names, malformed regular expressions, and headers containing line breaks.

If a placeholder is never being replaced, check the control panel first.

#### `config.php` is trusted <a href="#configphp-is-trusted" id="configphp-is-trusted"></a>

`speedy_dynamic_values` is read from `config.php`, which is already trusted PHP. Capture patterns are compiled into generated cache files, so treat this setting with the same care as the rest of that file.

### Troubleshooting <a href="#troubleshooting" id="troubleshooting"></a>

**The placeholder appears verbatim on the page.** The read side did not run. Check that you have regenerated the static utility files, that the page is being served through `index.php` rather than a direct `.html` rule, and that the control panel shows no configuration errors.

**The live value is on disk instead of a placeholder.** Your `capture` pattern is not matching the rendered output. View the cached file under your static path and compare it to the pattern. Remember that `capture` runs against the fully rendered HTML, after EE has parsed the template.

**Every visitor gets the same nonce.** A shared cache is serving one stored copy. See [Caching and per-request values](http://localhost:63342/markdownPreview/711297828/markdown-preview-index-9q8s30cuovlb8lj2k5um0pfsh2.html#caching-and-per-request-values).

**Forms started failing CSRF validation after overriding `csrf_token`.** An override replaces the default entirely. Make sure your `capture` and `replace` lists together cover every place a token appears — links, hidden fields, and any JavaScript that reads one.
