Ein Browser schickt Ihre Cookies bei jeder Anfrage an Ihre Domain mit, gleich wer die Anfrage ausgelöst hat. Eine Seite auf einer fremden Website kann also ein Formular enthalten, das an Ihre postet — und ist Ihr Besucher angemeldet, kommt es authentifiziert an und sieht in Ihren Logs völlig rechtmäßig aus.

Was damit möglich ist

  • Eine E-Mail-Adresse ändern und dann einen Passwort-Reset an die neue anfordern.
  • Eine Bestellung aufgeben, etwas überweisen, etwas löschen.
  • Alles, was Ihre Website mit einem POST und einer Sitzung tut.

Das Token

Ein Zufallswert, der in der Sitzung liegt und im Formular wiederholt wird. Eine Seite auf einer fremden Website kann Ihre Sitzung nicht lesen und den Wert deshalb nicht kennen.

// when rendering the form
$_SESSION["csrf"] = $_SESSION["csrf"] ?? bin2hex(random_bytes(32));
?>
<input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
// when handling it
if (!hash_equals($_SESSION["csrf"] ?? "", $_POST["csrf"] ?? "")) {
    http_response_code(400); exit;
}
Vergleichen Sie mit hash_equals, nicht mit ==. Ein einfacher Vergleich bricht beim ersten abweichenden Zeichen ab, und die dafür benötigte Zeit verrät, wie viel vom Token stimmte.

Und das Cookie-Attribut

session.cookie_samesite = Lax

SameSite=Lax sagt dem Browser, das Cookie bei einem seitenübergreifenden POST gar nicht erst mitzuschicken — das stoppt diese Klasse, bevor Ihr Code sie sieht. Es ist eine zweite Schicht, kein Ersatz: Es schützt nur dort, wo der Browser aktuell ist und sich benimmt.

GET darf nichts verändern

Ein Link, der etwas löscht, ist ein CSRF im Wartezustand — schlimmer noch: Ein Crawler oder eine Link-Vorschau löst ihn von selbst aus. Alles, was Zustand verändert, ist ein POST mit Token.
Ein Framework hat das eingebaut und eingeschaltet. Haben Sie es für einen Endpunkt abgeschaltet, damit etwas funktioniert, dann ist genau dieser Endpunkt der, den Sie sich ansehen.