Leaking the Laravel APP_KEY Through Statamic SEO Pro Meta Fields
Statamic is a flat-file CMS built on Laravel. It is popular with small and mid-size publishers, agencies, and marketing teams. Statamic ships with first-party addons like SEO Pro for search optimization and Pro for member sites and ecommerce. It also has its own templating language called Antlers, used throughout content and views.

TL;DR
- A low-level “Author” account can type Antlers code into a Statamic SEO Pro meta field. When SEO Pro renders that field, the whole Laravel config is in reach of the code.
- Statamic blocks direct access to
config.app.key, so{{ config:app:key }}comes back empty. But{{ config:app | to_json }}dumps the whole parent object as JSON, and the block never fires. - That JSON dump goes straight into the page’s
<title>andog:titletags. So theAPP_KEY, database passwords, mail passwords, and third-party API keys leak to any unauthenticated visitor of the page. - Every config namespace can be reached the same way:
{{ config:database | to_json }},{{ config:mail | to_json }},{{ config:services | to_json }}, and so on. - Once the
APP_KEYis leaked, an attacker can decrypt encrypted data, forge signed payloads, and reach full remote code execution. The RCE path runs through Laravel cookie deserialization whenSESSION_DRIVER=cookie. - Tracked publicly as CVE-2026-28425 (GHSA-cpv7-q2wx-m8rw). Fixed in Statamic 5.73.16 and 6.7.2. Verified on Statamic 6.3.3 with SEO Pro 7.0.3.
Summary
Statamic’s SEO Pro addon runs meta fields as Antlers code. When it does, the whole Laravel config sits inside the template’s variable scope. The core guardedVariables block only matches exact protected paths like config.app.key. Serializing the parent object with a modifier like to_json walks right around the block and dumps every secret in the config to the public page.
Danger (Author role in, every secret out)
This needs only an account that can edit and publish an entry. The payload is stored once in a SEO field and then leaks the Laravel APP_KEY, DB credentials, mail passwords, and API secrets to every unauthenticated visitor of that page, on every view, until the field is reverted.
The interesting part is not that a CMS lets editors write Antlers. That is a feature. The problem is that SEO Pro renders those fields with the full config()->all() available. And the one block meant to keep APP_KEY out of templates only protects the exact key path. It does not protect the parent object that holds the key.
Introduction
Statamic’s SEO Pro is the first-party SEO addon. It adds a SEO tab to every entry and lets editors template meta values with Antlers. This is genuinely useful. For example, you can write description: "{{ content | strip_tags | truncate(160) }}" and it resolves per entry. The SEO values are stored on each entry and rendered into the page head by the {{ seo_pro:meta }} tag.
When a templating engine runs user-controlled input as code, the first question to ask is simple. What variables can that code reach? If it can only reach the entry’s own fields, you are fine. If it can reach the whole app’s config, you have a secret leak waiting to happen. SEO Pro turned out to be the second case.
Statamic did anticipate part of this. Its guardedVariables list blocks well-known sensitive paths. {{ config:app:key }} really does come back empty. So at a glance, the dangerous value looks protected. The bypass lives in how the block matches paths, and in how Antlers modifiers run.
Root Cause Analysis
At its core, this is a textbook case of CWE-94: Improper Control of Generation of Code (Code Injection). Attacker-controlled Antlers code is evaluated inside a privileged template scope. The exact path I show in this post is information disclosure (leaking APP_KEY and the rest of the Laravel config). The underlying bug is still code injection, and that same bug is what powers the wider RCE chain bundled under this CVE.
SEO Pro Renders Meta Fields With the Full Config in Scope
The core of it is in Cascade::parseAntlers().
$viewCascade = array_merge( app(ViewCascade::class)->toArray(), $this->current ?? [], ['___tmpValue' => $value, 'config' => config()->all()], // [1] the entire Laravel config goes into the Antlers scope $this->hydrateGlobals());
return (string) Antlers::parse('{{ ___tmpValue }}', $viewCascade); // [2] the field value is parsed as AntlersAt [1], SEO Pro merges config()->all() (the full Laravel config) into the rendering context under the config key. At [2], the field value itself is parsed as an Antlers template against that context. So any SEO field value with {{ ... }} inside it can reach anything in the application config. The ViewCascade and globals already provide the variables SEO fields actually use (like {{ title }} and {{ site_name }}). The full config does not need to be in scope at all.
Any SEO Field Containing Antlers Triggers This Path
SEO Pro auto-injects its fields into every blueprint and parses any value that contains the Antlers opener. An attacker does not have to enable anything special. Setting a meta field’s source to “Custom” and typing an Antlers expression is enough to reach [2].
The Guard Matches the Key, Not the Object Around It
Statamic’s block lives in PathDataManager::guardRuntimeAccess(). That function compares the resolved variable’s normalized reference against the guardedVariablePatterns list using Str::is().
| Payload | Normalized reference | Outcome |
|---|---|---|
{{ config:app:key }} | config.app.key | Matches the guard. Blocked. |
{{ config:app | to_json }} | config.app | Does not match. to_json then serializes the whole object, key included. |
For {{ config:app:key }}, the reference resolves to config.app.key. That matches a guarded pattern, so the value is blocked. For {{ config:app | to_json }}, the reference is only config.app, which is not guarded. The variable resolves to the whole app config array. The to_json modifier then runs after resolution, serializing that array, APP_KEY and all. The block never gets a chance to fire, because the thing being serialized is the parent object, not the protected leaf.
That is the whole bypass. The block protects a leaf path. But a serialization modifier on the parent path carries the leaf out inside the parent.
Note (What is Antlers? (and why pentesters should care))
Antlers is Statamic’s templating language. It is a small scripting language baked into HTML templates that the server runs when it builds the page. Anything inside {{ ... }} is Antlers code that gets evaluated at render time. A few examples:
{{ title }}outputs the entry’s title.{{ title | upper }}does the same, but in uppercase. The|is called a modifier. It transforms the value on its left.{{ content | strip_tags | truncate(160) }}shows that modifiers can be chained.
If you have used Twig (Symfony) or Blade (Laravel), Antlers is the same idea. If you have not, just think of {{ ... }} as little code expressions inside an HTML page.
Why this matters for pentesters: any time a templating engine like Antlers evaluates attacker-controlled input, you are in server-side template injection (SSTI) territory. SSTI is the vulnerability class where an attacker writes template syntax into stored or reflected data, and the template engine evaluates it as code. The impact depends on what variables and modifiers the engine exposes. It can be data disclosure, file reads, or full RCE. This vulnerability is exactly that. It is SSTI in Antlers. The “code” injected is a one-liner like {{ config:app | to_json }}, and the “variables in scope” include the entire Laravel application configuration.
Exploitation
Preconditions
- SEO Pro installed (first-party addon) with the standard
{{ seo_pro:meta }}integration. - An authenticated control panel user with
editandpublishpermission on a collection. Verified with the author role. - That is it. The reader of the leaked data is any unauthenticated visitor.
Leaking the APP_KEY
The proof of concept is point-and-click in the control panel:
-
Log in to
/cpas a content editor (author role). -
Open Collections → Blog and edit any entry.

-
Open the SEO tab (SEO Pro injects it on every entry).
-
Under Meta Title, switch the source dropdown to Custom.
-
Enter
{{ config:app | to_json }}as the custom title value.
-
Save & Publish.
-
Open the entry’s public URL in an incognito (unauthenticated) window.
-
View source. The
<title>tag and theog:titlemeta now contain the serializedappconfig, including theAPP_KEY, cipher, environment, and debug state.
Important (The secret leaks to the public page, not just the panel)
The payload is written in the control panel, but it renders into the public page head. The person who reads the APP_KEY does not need an account. They just view the page. That is what turns an editor-level injection into an unauthenticated disclosure.
Maximising Impact
app is only the start. The same to_json trick works on every config namespace. A single low-level account can walk the entire secret store:
| Payload | What leaks |
|---|---|
{{ config:app:key }} | Empty (this path is guarded) |
{{ config:app | to_json }} | APP_KEY, cipher, environment, debug state |
{{ config:database | to_json }} | Host, port, username, password for every connection (MySQL, PostgreSQL, SQLite, SQL Server, Redis) |
{{ config:mail | to_json }} | SMTP host, port, username, password, encryption |
{{ config:services | to_json }} | Third-party API keys and secrets (Stripe, Mailgun, AWS, and so on) |
{{ config:filesystems | to_json }} | S3 bucket names, access keys, secrets |
{{ config:cache | to_json }} | Redis / Memcached connection credentials |
Everything that lives in a Laravel config file is reachable. The leak repeats on every page view until the field is changed back.
From APP_KEY to Remote Code Execution
The APP_KEY is not just another secret. Laravel uses it to encrypt and sign cookies and other payloads. Once an attacker has the key, they can decrypt anything the app encrypted and forge anything it signs. When SESSION_DRIVER=cookie, Laravel deserializes session data carried in a cookie. With the APP_KEY in hand, the attacker can forge that cookie. The forged cookie then turns into a PHP object-injection chain, which leads to remote code execution. This is the same bug class as the well-known Laravel APP_KEY RCE precedent (CVE-2018-15133). I am keeping the deserialization chain itself out of this write-up. The point here is that a config leak like this is not a low-severity information disclosure. It is the first step in a chain to RCE.
Detection
The payloads live in stored content, so they are findable. Look for SEO meta fields whose source is set to “Custom” and whose value contains an Antlers opener ({{) referencing a config: path. Pay special attention to any serialization modifier like to_json, to_yaml, dump, dd, or ray. A simple grep across the content tree for config: and | to_json inside SEO fields will surface the obvious cases. On the front end, audit rendered <title> and og:* meta values for serialized JSON that should not be there. If you find evidence of any of this, treat every secret in the application config as compromised, not just the APP_KEY.
Remediation
Tip (Upgrade, then rotate everything)
Update Statamic to 5.73.16 or 6.7.2 (or later) for the branch you run. Make sure SEO Pro is current too. Patching stops new leaks, but it does not un-leak anything already exposed. If exploitation is possible, rotate the APP_KEY and re-encrypt any encrypted data. Then rotate database, mail, and all third-party credentials that were reachable through the config.
The longer-term fixes follow the root cause:
- Do not inject
config()->all()into the SEO Pro Antlers scope. The cascade and globals already expose the variables SEO fields legitimately use. The full config does not belong in the template context. - Make the block cover parents, not just leaves. When
config.app.keyis blocked,config.appandconfigshould be blocked too. Serializing any parent exposes the protected child. - Block the serialization modifiers. Statamic ships a
guardedModifiersmechanism that is empty by default. Addingto_json,to_yaml,dump,dd, andrayto it blocks the easy serialization escape. Blocking parent paths is still the more complete fix. - Strip or escape Antlers in SEO field values at save time as defense in depth.
Until you can patch, restrict control panel access to trusted users only. You can also disable Antlers parsing on user-editable fields where it is not needed.
Disclosure Timeline
Conclusion
The headline impact is an unauthenticated APP_KEY leak from a low-level Author account. But the real lesson is in the guard. Statamic did try to keep APP_KEY out of templates, and {{ config:app:key }} really is blocked. The guard just matched at the wrong level. It protected a single leaf path, but it left the parent object fully serializable. A one-modifier expression like {{ config:app | to_json }} slid the protected value out inside the parent. Guarding a secret means guarding everything that can serialize it, not just the exact path. Pair that with an addon that put the entire application config in template scope to begin with, and the SEO field became a key-leaking oracle.