Skip to content

How to protect a form with a CSRF token?

A CSRF (Cross-Site Request Forgery) attack exploits the trust that a site places in the browser of the logged-in user.

What is a CSRF attack?

The user of your application is logged in and therefore has an active session on your site. A malicious site visited during the same session can trigger, without the user knowing, an HTTP request to your site (Example: a hidden image, a form submitted automatically in JavaScript).

Since the browser automatically sends the session cookie with this request, the server believes it recognizes the legitimate user and performs the requested action (Example: changing their email, deleting data, making a bank transfer ...).

sequenceDiagram
    autonumber
    actor U as User
    participant S as Your site
    participant M as Malicious site

    U->>S: Login
    Note over U,S: The browser stores the session cookie

    U->>M: Visit to the malicious site
    M-->>U: Booby-trapped page (hidden image, JS auto-submit)

    M->>S: Forged request
    Note over M,S: The session cookie is sent automatically!

    alt Without CSRF protection
        S-->>M: The request is executed
    else With a CSRF token
        S-->>M: 403 - Request rejected
    end

The protection principle: the Synchronizer Token

The framework provides the Studoo\EduFramework\Core\Security\CsrfService service, based on three principles:

  • Generation: the server generates an unpredictable random token (random_bytes) and stores it in the session
  • Transmission: the form sends this token in a hidden _csrf_token field
  • Validation: the server compares the received token with the session token (hash_equals, protected against timing attacks)

A malicious site cannot read the token (it has no access to the content of your site's pages), so its forged request is rejected with an HTTP 403 error.

The service adds two reinforcements:

  • Per-route isolation: each route name has its own token. The token of the /medecin/import form is not accepted on the /user/{id}/update route
  • Rotation: the token is renewed after each successful validation, which limits the replay window of an intercepted token

Protecting a form in 2 steps

Step 1: the token in the twig file

Add the twig function csrf_token() to your form with the route name defined in the app/Config/routes.yaml file:

{% extends "base.html.twig" %}

{% block content %}
    <form action="{{ getNameToPath('ville') }}" method="post">
+       {{ csrf_token('ville') | raw }}
        <label for="nom_ville">City</label>
        <input type="text" id="nom_ville" name="nom_ville">
        <input type="submit" value="Send">
    </form>
{% endblock %}

The function generates the hidden token field:

<input type="hidden" name="_csrf_token" value="6734a87fa57df870bb485735ceca7fd17e9fc114decc80cc358562003724080f">

Step 2: validation in the controller

When processing the form, validate the token with the isCsrfTokenValid() method of the Request class, using the same route name:

 public function execute(Request $request): string|null
 {
+    if ($request->getHttpMethod() === "POST"
+        && $request->isCsrfTokenValid('ville') === false) {
+        throw new ErrorHttpStatusException("Invalid or expired CSRF token", 403);
+    }
+
     if ($request->get('nom_ville') !== null) {
         // Process the form ...
     }

Warning

The route name passed to csrf_token() in the template and to isCsrfTokenValid() in the controller must be identical, otherwise the submission is always rejected (403).

If the token is invalid, the ErrorHttpStatusException class triggers the HTTP 403 error page through the framework's error pipeline. If the token is valid, it is immediately renewed for the next submission.

Tip

After a successful submission, redirect to a GET page (POST/Redirect/GET pattern) rather than displaying the form again: the user will not be able to submit an outdated form by refreshing the page.

Complete example

You can test the protection on the /medecin/import route of the demo application. The MedecinController::import() controller validates the token before processing the file upload:

if ($request->getHttpMethod() === "POST") {
    // Security: the form's CSRF token must be valid
    if ($request->isCsrfTokenValid('medecin_import') === false) {
        throw new ErrorHttpStatusException("Invalid or expired CSRF token", 403);
    }
    // Process the form ...
}

The app/Template/medecin/import.html.twig form contains the hidden token field:

<form action="{{ getNameToPath('medecin_import') }}" method="post" enctype="multipart/form-data">
    {{ csrf_token('medecin_import') | raw }}
    <input type="file" name="fichier">
    <input type="submit" value="Import">
</form>

AJAX calls

For a JavaScript request that does not go through a form, send the token in the X-CSRF-TOKEN HTTP header:

const token = document.querySelector('input[name="_csrf_token"]').value;

fetch('/medecin/import', {
    method: 'POST',
    headers: {
        'X-CSRF-TOKEN': token
    },
    body: formData
});

The CsrfService class

The service can be used directly (without going through the Request class):

Method Role
CsrfService::getToken(string $routeName) Generates (on the first call) and returns the token of the route
CsrfService::validate(string $routeName, ?string $token) Validates the received token, returns true/false (rotation on success)
CsrfService::getTokenField(string $routeName) Returns the hidden HTML field with the route's token

The tokens are stored in the session in $_SESSION['_csrf_tokens'][nomDeRoute].

Limits of rotation

Since the token is renewed after each successful validation:

  • the same form open in two tabs: submitting the first tab invalidates the token of the second one (403 error)
  • using the browser's back button and then submitting again is rejected

This is the expected behavior: the user must display the form again to get a fresh token. Two forms of different routes open at the same time remain valid (per-route isolation limits the impact of rotation).