kein Bug, sondern ein fehlendes Hidden-Field
Wer in Hyvä-Themes ein eigenes Kontakt-, Anfrage- oder Newsletter-Formular baut, vergisst regelmäßig ein einziges, unscheinbares Hidden-Field und wundert sich anschließend über Fehlermeldungen, die wie ein Bug aussehen. Dieser Artikel zeigt, wie der Form-Key CSRF-Schutz in Magento technisch funktioniert und wie er sauber in phtml-Templates, AJAX-Requests mit Alpine.js, eigenen Controllern und im Zusammenspiel mit dem Full Page Cache eingebunden wird, mit echten Codebeispielen aus Magento 2.4.8-p4 und PHP 8.4.
Inhaltsverzeichnis
- 1. Warum eigene Formulare in Hyvä-Themes den Form-Key oft vergessen
- 2. Wie Magentos Form-Key-Mechanismus technisch funktioniert
- 3. Form-Key in eigenen phtml-Formularen korrekt einbinden
- 4. AJAX-Formulare mit Alpine.js: Form-Key im Request mitschicken
- 5. Controller-seitige Validierung: CsrfAwareActionInterface korrekt implementieren
- 6. Form-Key vs. CSRF-Token bei REST- und GraphQL-Endpunkten
- 7. Form-Key und Full Page Cache: veraltete Keys im gecachten Formular
- 8. Häufige Fehler: fehlender Form-Key nach AJAX-Login und veraltete Cache-Antworten
- 9. Zusätzliche Sicherheits-Härtung: SameSite-Cookies und Rate-Limits
- 10. Zusammenfassung
- 11. FAQ
1. Warum eigene Formulare in Hyvä-Themes den Form-Key oft vergessen
In Luma war das Feld für den Form-Key CSRF-Schutz fast unsichtbar, weil viele Core-Blöcke es über gemeinsame Template-Fragmente oder UI-Components automatisch mitgerendert haben. Hyvä hat diesen ganzen Unterbau bewusst entfernt: kein Knockout.js, keine UI-Components, kein jQuery-Widget-System, das im Hintergrund ein Hidden-Field einschleust. Wer in einem Hyvä-Theme ein eigenes Kontaktformular, ein Anfrageformular oder ein individuelles Newsletter-Feld baut, schreibt das phtml-Template meist komplett neu und übernimmt dabei genau das eine Element nicht, das in Luma vorher unsichtbar mitgeliefert wurde.
Die Folge ist ein Formular, das optisch und funktional völlig normal wirkt, bis der Nutzer auf Absenden klickt und eine Fehlermeldung wie „Invalid Form Key. Please refresh the page and try again.“ erhält. Für Entwickler, die den Form-Key CSRF-Schutz nicht auf dem Schirm haben, sieht das nach einem Bug im Theme aus, dabei fehlt schlicht ein einziges Hidden-Field im Formular. Genau dieser Fehler taucht in der Praxis am häufigsten bei schnell gebauten Zusatzformularen auf: Anfrage-Buttons auf Produktseiten, individuelle Angebotsformulare oder eigens entwickelte Rückrufbitten, die nie gegen die Referenzimplementierung in Magento_Customer oder Magento_Newsletter geprüft wurden.
2. Wie Magentos Form-Key-Mechanismus technisch funktioniert
Der Form-Key CSRF-Schutz basiert nicht auf einem reinen Cookie-Wert, sondern auf einem serverseitig in der Session gespeicherten Zufallsstring. Die Klasse \Magento\Framework\Data\Form\FormKey erzeugt beim ersten Zugriff über getFormKey() einen zufälligen String und speichert ihn in der Session, die über \Magento\Framework\Session\SessionManagerInterface verwaltet wird. Dieser Wert wird dann bei jedem Seitenaufruf in ein Hidden-Field gerendert und beim nächsten POST-Request mit dem in der Session hinterlegten Wert verglichen. Stimmen beide überein, gilt der Request als legitim, andernfalls schlägt die Validierung fehl.
Das Session-Cookie, meist PHPSESSID, übernimmt dabei nur die Aufgabe, den Browser eindeutig einer Server-Session zuzuordnen, es trägt selbst keinen Vergleichswert. Der eigentliche Form-Key CSRF-Schutz entsteht erst durch den Vergleich zwischen dem serverseitig gespeicherten Wert und dem im Formular mitgesendeten Wert, ein Angreifer auf einer fremden Domain kann diesen Wert nicht kennen, selbst wenn er das Session-Cookie automatisch mitschickt. Genau darin unterscheidet sich Magentos Ansatz von einfacheren Double-Submit-Cookie-Mustern, bei denen der Vergleichswert selbst im Cookie liegt.
Wichtig für das Verständnis der folgenden Abschnitte: Der Form-Key ist an die konkrete Session gebunden, nicht an den Nutzer oder das Gerät. Wechselt die Session, etwa durch Session-Regeneration nach einem Login oder durch eine neue anonyme Session nach Ablauf der Cookie-Lebensdauer, ändert sich zwangsläufig auch der gültige Form-Key-Wert, ein zuvor im DOM gerenderter Wert wird damit ungültig, ohne dass sich am Formular selbst etwas geändert hat.
3. Form-Key in eigenen phtml-Formularen korrekt einbinden
Per Projektkonvention werden ViewModels gegenüber Block-Klassen bevorzugt, das gilt auch für den Form-Key CSRF-Schutz in eigenen Templates. Statt den Form-Key über eine eigene Block-Klasse mit Objektmanager-Zugriff zu beziehen, injiziert ein schlankes ViewModel \Magento\Framework\Data\Form\FormKey direkt per Constructor Property Promotion und stellt eine einzige öffentliche Methode getFormKey() bereit. Das hält die Template-Logik testbar und entkoppelt sie von der konkreten Block-Implementierung.
Im phtml-Template selbst genügt ein einziges Hidden-Field, dessen Wert über escapeHtmlAttr() ausgegeben wird, damit auch ein manipulierter Form-Key-Wert nicht zu einer XSS-Lücke im Attribut wird. Wichtig für einen belastbaren Form-Key CSRF-Schutz ist außerdem, dass dieses Hidden-Field bei jedem Server-Rendering neu erzeugt wird und nicht etwa aus einem statischen Fragment kopiert wurde, das clientseitig zwischengespeichert ist.
<!-- app/design/frontend/Mironsoft/default/Mironsoft_Core/templates/form/quote-request.phtml -->
<?php
/** @var \Magento\Framework\View\Element\Template $block */
/** @var \Mironsoft\Core\ViewModel\FormKeyProvider $viewModel */
$viewModel = $block->getViewModel();
?>
<form action="<?= $block->escapeUrl($block->getUrl('mironsoft_core/form/submit')) ?>"
method="post" class="space-y-4" id="quote-request-form">
<!-- Hidden field carrying the current session form key -->
<input type="hidden" name="form_key" value="<?= $block->escapeHtmlAttr($viewModel->getFormKey()) ?>">
<label class="block text-sm font-medium text-slate-700">E-Mail</label>
<input type="email" name="email" required
class="w-full rounded-lg border border-slate-300 px-3 py-2">
<button type="submit"
class="bg-orange-600 text-white font-semibold px-5 py-2.5 rounded-lg">
Anfrage senden
</button>
</form>
<?php
declare(strict_types=1);
namespace Mironsoft\Core\ViewModel;
use Magento\Framework\Data\Form\FormKey;
use Magento\Framework\View\Element\Block\ArgumentInterface;
/**
* Provides the current session form key to custom Hyva phtml forms.
*/
class FormKeyProvider implements ArgumentInterface
{
/**
* @param FormKey $formKey Magento core form key generator/reader.
*/
public function __construct(
private readonly FormKey $formKey
) {
}
/**
* Returns the current form key value for the active session.
*
* @return string
*/
public function getFormKey(): string
{
return $this->formKey->getFormKey();
}
}
4. AJAX-Formulare mit Alpine.js: Form-Key im Request mitschicken
Sobald ein Formular nicht mehr klassisch per Full-Page-POST, sondern per fetch() aus einer Alpine.js-Komponente heraus abgeschickt wird, entfällt der automatische Formular-Encoding-Mechanismus des Browsers vollständig. Der Form-Key CSRF-Schutz funktioniert bei AJAX-Requests nur dann zuverlässig, wenn der Wert explizit aus dem Hidden-Field ausgelesen und entweder im JSON-Body oder als eigener Header an den Server mitgesendet wird, ein vergessener Wert führt exakt zum gleichen 302-Redirect mit Fehlermeldung wie ein komplett fehlendes Feld.
Entscheidend ist außerdem, den Form-Key-Wert bei jedem Submit frisch aus dem DOM zu lesen statt ihn einmalig beim Initialisieren der Alpine-Komponente in eine JavaScript-Variable zu kopieren und dauerhaft zwischenzuspeichern. Läuft die Session währenddessen ab oder wird sie durch einen zwischenzeitlichen Login regeneriert, liest die Komponente sonst weiterhin den alten, ungültigen Wert aus dem Speicher, während im Hidden-Field längst ein neuer Wert stünde. Für einen robusten Form-Key CSRF-Schutz in AJAX-Formularen gilt deshalb: Wert erst unmittelbar vor dem Request auslesen, nicht vorher cachen.
<!-- app/design/frontend/Mironsoft/default/Mironsoft_Core/templates/form/quote-request-ajax.phtml -->
<div x-data="quoteRequestForm()">
<form @submit.prevent="submitForm" class="space-y-4">
<input type="hidden" name="form_key" x-ref="formKeyField"
value="<?= $block->escapeHtmlAttr($viewModel->getFormKey()) ?>">
<input type="email" name="email" x-model="email" required
class="w-full rounded-lg border border-slate-300 px-3 py-2">
<button type="submit" :disabled="loading"
class="bg-orange-600 text-white font-semibold px-5 py-2.5 rounded-lg">
<span x-show="!loading">Anfrage senden</span>
<span x-show="loading">Wird gesendet...</span>
</button>
<p x-show="message" x-text="message" class="text-sm text-slate-600"></p>
</form>
</div>
<script>
function quoteRequestForm() {
return {
email: '',
loading: false,
message: '',
async submitForm() {
this.loading = true;
// Read the form key fresh from the DOM right before sending the request
const formKey = this.$refs.formKeyField.value;
const response = await fetch('/mironsoft_core/form/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ form_key: formKey, email: this.email })
});
const data = await response.json();
this.message = data.message;
this.loading = false;
}
};
}
</script>
<?php /* $hyvaCsp->registerInlineScript(); */ ?>
5. Controller-seitige Validierung: CsrfAwareActionInterface korrekt implementieren
Jeder eigene frontend-seitige POST-Controller in Magento muss seit den 2.3er-Versionen entweder auf dem Standard-Formular-Validierungspfad aufbauen oder explizit \Magento\Framework\App\CsrfAwareActionInterface implementieren, sonst wirft das Framework beim Registrieren der Route eine Ausnahme. Für einen sauberen Form-Key CSRF-Schutz auf Controller-Ebene bedeutet das konkret: Die Methode validateForCsrf() entscheidet, ob eine eigene Validierungslogik greift oder mit null an die Standard-Form-Key-Prüfung delegiert wird, während createCsrfValidationException() im Fehlerfall die passende Redirect-Exception erzeugt.
In den meisten Fällen genügt es, beide Methoden schlicht null zurückgeben zu lassen und damit die eingebaute Form-Key-Prüfung zu übernehmen, ein häufiger Fehler besteht jedoch darin, versehentlich true zurückzugeben, was jede CSRF-Prüfung für diesen Controller vollständig deaktiviert. Wer den Form-Key CSRF-Schutz für einen eigenen AJAX-Endpunkt bewusst um zusätzliche Prüfungen erweitern will, etwa einen zusätzlichen Header-Vergleich, sollte das immer zusätzlich zur Standard-Prüfung tun, niemals als Ersatz dafür.
<?php
declare(strict_types=1);
namespace Mironsoft\Core\Controller\Form;
use Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
use Magento\Framework\App\CsrfAwareActionInterface;
use Magento\Framework\App\Request\InvalidRequestException;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Controller\Result\Json;
use Magento\Framework\Controller\Result\JsonFactory;
/**
* Handles quote request form submissions with explicit CSRF awareness.
*/
class Submit extends Action implements CsrfAwareActionInterface
{
/**
* @param Context $context Standard action context.
* @param JsonFactory $resultJsonFactory Factory for JSON responses.
*/
public function __construct(
Context $context,
private readonly JsonFactory $resultJsonFactory
) {
parent::__construct($context);
}
/**
* Creates the exception thrown when CSRF validation fails.
*
* @param RequestInterface $request
* @return InvalidRequestException|null
*/
public function createCsrfValidationException(RequestInterface $request): ?InvalidRequestException
{
return null;
}
/**
* Explicitly validates the request for CSRF, null delegates to the default form key check.
*
* @param RequestInterface $request
* @return bool|null
*/
public function validateForCsrf(RequestInterface $request): ?bool
{
return null;
}
/**
* Processes the submitted quote request.
*
* @return Json
*/
public function execute(): Json
{
$result = $this->resultJsonFactory->create();
return $result->setData(['message' => 'Vielen Dank fuer Ihre Anfrage.']);
}
}
6. Form-Key vs. CSRF-Token bei REST- und GraphQL-Endpunkten
Bei klassischen /rest/V1-Endpunkten spielt der Form-Key CSRF-Schutz praktisch keine Rolle, weil die Authentifizierung über Bearer-Token oder OAuth erfolgt und nicht über das automatisch mitgesendete Session-Cookie. Ein Angreifer, der einen Nutzer auf eine fremde Seite lockt, kann zwar das Session-Cookie automatisch mitschicken lassen, aber keinen gültigen Bearer-Token erraten, der separat im Authorization-Header übertragen werden muss. Genau deshalb verzichtet die REST-API bewusst auf die Form-Key-Prüfung, das zugrunde liegende Bedrohungsmodell ist ein anderes als bei formularbasierten Session-Requests.
Bei GraphQL-Mutationen über /graphql wird es unübersichtlicher, sobald ein Kunde über das reguläre Session-Cookie statt über einen expliziten Bearer-Token authentifiziert ist, denn dann greift wieder das klassische CSRF-Risiko ambient mitgesendeter Anmeldedaten. Ein eigenes GraphQL-Resolver-Modul, das sich bewusst auf die Kunden-Session statt auf einen Token verlässt, muss diesen Unterschied kennen und darf sich nicht darauf verlassen, dass die GraphQL-Route automatisch denselben Form-Key CSRF-Schutz mitbringt wie ein klassischer frontend-Controller, denn webapi_rest- und graphql-Routen sind bewusst von der Standard-Form-Key-Middleware ausgenommen.
{
"_comment_rest": "REST calls authenticate via Bearer token, not via form_key/session",
"request_rest": {
"method": "POST",
"url": "/rest/V1/carts/mine/items",
"headers": {
"Authorization": "Bearer eyJhbGciOi...",
"Content-Type": "application/json"
}
},
"_comment_graphql": "Cookie-authenticated GraphQL mutations still need their own CSRF safeguards",
"request_graphql_session_based": {
"method": "POST",
"url": "/graphql",
"headers": {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest"
},
"body": "mutation { addProductsToCart(cartId: \"abc\", cartItems: []) { cart { id } } }"
}
}
7. Form-Key und Full Page Cache: veraltete Keys im gecachten Formular
Der Full Page Cache speichert das gerenderte HTML einer Seite inklusive aller darin enthaltenen Hidden-Fields. Wird ein Block mit einem eingebetteten Form-Key-Feld versehentlich als cacheable behandelt, landet der zum Zeitpunkt der Cache-Erzeugung gültige Form-Key CSRF-Schutz-Wert fest im gespeicherten HTML, unabhängig davon, welche Session ein späterer Besucher tatsächlich hat. Jeder Nutzer, der diese gecachte Seite ausliefert bekommt, sieht denselben, längst überholten Form-Key-Wert im Formular, während seine eigene Session einen völlig anderen Wert erwartet.
Die Konsequenz ist ein Formular, das für jeden Besucher der gecachten Seite mit „Invalid Form Key“ fehlschlägt, bis der Full Page Cache invalidiert wird oder der Nutzer die Seite manuell neu lädt. Der korrekte Umgang mit einem solchen Formular-Block ist deshalb entweder cacheable="false" im Layout-XML oder, deutlich sauberer, das Auslagern des Form-Key-Felds in einen über Hyväs Private-Content- beziehungsweise Ajax-Reload-Mechanismus nachgeladenen Fragment, damit die gecachte HTML-Antwort selbst niemals einen sessionabhängigen Wert enthält.
8. Häufige Fehler: fehlender Form-Key nach AJAX-Login und veraltete Cache-Antworten
Ein besonders tückischer Fehler betrifft AJAX-Logins über ein Alpine.js-Modal: Aus Sicherheitsgründen regeneriert Magento die Session-ID nach einem erfolgreichen Login, um Session-Fixation-Angriffe zu verhindern. Damit wird automatisch auch der zuvor im DOM gerenderte Form-Key-Wert ungültig, auch wenn optisch nichts an der Seite verändert wurde. Wird nach dem Login kein vollständiger Seiten-Reload ausgelöst, scheitert der Form-Key CSRF-Schutz beim nächsten Formular-Submit garantiert, weil das Hidden-Field noch den alten, vor dem Login gültigen Wert enthält.
Eine zweite, verwandte Fehlerquelle ist der Browser Back-Forward-Cache in Kombination mit dem Full Page Cache: Navigiert ein Nutzer nach dem Logout per Zurück-Button auf eine zuvor eingeloggt betrachtete Seite, zeigt der Browser unter Umständen eine aus dem bfcache stammende, veraltete Version mit einem Form-Key-Wert, der zur alten Session gehört. Bei der Fehlersuche hilft ein Blick in bin/log exception.log nach „Invalid Form Key“-Einträgen kombiniert mit einem Vergleich des im DevTools-Netzwerktab gesendeten form_key-Werts gegen den aktuellen Session-Cookie-Wert, meist zeigt sich dabei sofort, ob ein Cache- oder ein Session-Problem vorliegt.
9. Zusätzliche Sicherheits-Härtung: SameSite-Cookies und Rate-Limits für sensible Formulare
Der Form-Key CSRF-Schutz ist die primäre Verteidigungslinie gegen Cross-Site-Request-Forgery, sollte aber nicht die einzige bleiben. Eine zusätzliche, unabhängige Schutzebene entsteht durch das SameSite-Attribut auf dem Session-Cookie: Mit SameSite=Lax oder, wo funktional möglich, SameSite=Strict wird das Session-Cookie bei Cross-Site-Requests erst gar nicht mitgesendet, wodurch ein Angriffsversuch schon auf Cookie-Ebene ins Leere läuft, bevor der Form-Key-Vergleich überhaupt greifen müsste.
Für besonders sensible Formulare wie Login, Passwort-Reset oder Kontaktformulare mit Dateiupload lohnt sich zusätzlich ein serverseitiges Rate-Limit, etwa über limit_req in Nginx oder ein eigenes Plugin um die Controller-execute()-Methode. Das schützt zwar nicht direkt vor CSRF, verhindert aber, dass ein kompromittierter oder fehlerhaft konfigurierter Endpunkt für automatisierte Massenanfragen missbraucht wird. Wichtig ist dabei, diese Härtungsmaßnahmen klar von der Content-Security-Policy zu trennen: CSP schützt vor eingeschleustem Script-Code, nicht vor gefälschten Formular-Requests, beide Mechanismen ergänzen den Form-Key CSRF-Schutz, ersetzen ihn aber nicht.
Situationen im direkten Vergleich: Die folgende Übersicht zeigt, welche konkreten Konfigurationsentscheidungen den Form-Key CSRF-Schutz in der Praxis stärken oder unbemerkt aushebeln.
| Situation | Falsch | Empfohlen | Effekt |
|---|---|---|---|
| Hidden-Field | Formular ohne form_key-Feld |
Feld über ViewModel gerendert | Request wird akzeptiert statt geblockt |
| AJAX-Request | Plain POST ohne Header/Body-Wert | Form-Key im Body plus X-Requested-With | Konsistente Validierung bei jedem Submit |
| Controller | Kein CsrfAwareActionInterface | Interface implementiert, null delegiert | Kontrollierte statt zufällige Ausnahme |
| Caching | Formular-Block cacheable mit fixem Key | cacheable="false" oder Ajax-Reload |
Kein veralteter Form-Key im HTML |
| Session-Cookie | Kein SameSite-Attribut gesetzt | SameSite=Lax als Zusatzschutz |
Cookie fehlt bei Cross-Site-Requests |
Mironsoft
Hyvä-Entwicklung, Security-Audits und Magento-2-Betrieb
Eigene Formulare, aber unsicher beim Form-Key?
Wir prüfen eure Hyvä-Formulare, AJAX-Endpunkte und Controller auf sauberen Form-Key CSRF-Schutz und sorgen dafür, dass Full Page Cache und Sicherheit dabei nicht gegeneinander arbeiten.
Security-Audit
Prüfung aller eigenen Formulare, Controller und AJAX-Endpunkte auf CSRF-Lücken
Umsetzung
ViewModels, CsrfAwareActionInterface und cache-sichere Formular-Blöcke im Theme
Härtung
SameSite-Cookies und Rate-Limits für Login, Reset und sensible Formulare
10. Zusammenfassung
Ein zuverlässiger Form-Key CSRF-Schutz in eigenen Hyvä-Formularen entsteht nicht durch Zufall, sondern durch drei bewusste Entscheidungen: das Hidden-Field wird über ein ViewModel statt eine Block-Klasse ausgegeben, jeder eigene Controller implementiert CsrfAwareActionInterface explizit statt sich stillschweigend auf Altverhalten zu verlassen, und AJAX-Requests lesen den Form-Key-Wert unmittelbar vor dem Absenden frisch aus dem DOM. Wer diese drei Punkte konsequent umsetzt, vermeidet die häufigsten Support-Tickets rund um „Invalid Form Key“-Fehler von vornherein.
Der zweite wichtige Baustein betrifft das Zusammenspiel mit Infrastruktur: Ein sauberer Form-Key CSRF-Schutz nützt wenig, wenn der Full Page Cache einen veralteten Wert dauerhaft einfriert oder REST- und GraphQL-Endpunkte fälschlich mit demselben Bedrohungsmodell behandelt werden wie klassische Session-Formulare. SameSite-Cookies und Rate-Limits ergänzen den Schutz zusätzlich, ersetzen ihn aber nicht, und CSP bleibt für ein völlig anderes Angriffsszenario zuständig.
Form-Key CSRF-Schutz: Das Wichtigste auf einen Blick
phtml-Einbindung
Hidden-Field über ViewModel und \Magento\Framework\Data\Form\FormKey, nicht über eine eigene Block-Klasse.
AJAX & Alpine.js
Form-Key erst unmittelbar vor dem Request aus dem DOM lesen, nie beim Init zwischenspeichern.
Controller-Validierung
CsrfAwareActionInterface implementieren, null delegiert an die Standard-Prüfung.
Caching & Härtung
Formular-Blöcke uncacheable halten, SameSite-Cookies und Rate-Limits als Zusatzschutz.