CSRF und SameSite Cookie: Schutzmechanismen in Spring Security

1. Überblick

CSRF (Cross-Site Request Forgery) ist eine der klassischen Weblücken, aber immer noch verwirrend: Viele denken, es genügt, SameSite=Strict zu aktivieren, und CSRF-Token sind nicht mehr nötig. Dieser Artikel erklärt den Mechanismus von CSRF-Angriffen, die tatsächliche Rolle von SameSite-Cookies und wie man CSRF-Schutz richtig in Spring Security konfiguriert. Nach dem Lesen wissen Sie, wann Sie CSRF-Token benötigen, wann SameSite ausreicht, und wie Sie Tests zur Verifikation schreiben.

2. So funktioniert CSRF

Angenommen, Sie haben einen PaymentService mit einem Endpoint zur Geldüberweisung, authentifiziert durch Session-Cookie:

@RestController
@RequestMapping("/api/payments")
public class PaymentController {

    private final PaymentService paymentService;

    public PaymentController(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    @PostMapping("/transfer")
    public ResponseEntity<Void> transfer(@RequestParam String toAccount,
                                          @RequestParam BigDecimal amount,
                                          HttpServletRequest request) {
        String customerId = (String) request.getSession().getAttribute("customerId");
        paymentService.transfer(customerId, toAccount, amount);
        return ResponseEntity.ok().build();
    }
}

Wenn der customer bereits angemeldet ist (der Browser hat das Session-Cookie), muss eine bösartige Seite evil.com nur dieses Formular einbetten:

<form action="https://bank.example.com/api/payments/transfer" method="POST">
  <input type="hidden" name="toAccount" value="attacker-account">
  <input type="hidden" name="amount" value="5000">
</form>
<script>document.forms[0].submit();</script>

Wenn der customer evil.com öffnet, fügt der Browser automatisch das Cookie von bank.example.com an die Anfrage an — weil der Browser nur die Zieldomain beachtet, nicht, von wo die Anfrage stammt. Der Server erhält eine legitim aussehende Anfrage und führt die Überweisung aus. Das ist die Essenz von CSRF: Missbrauch der Tatsache, dass der Browser automatisch Credentials (Cookies) mitschickt, unabhängig davon, von welcher Seite die Anfrage stammt.

Hinweis: CSRF ist nur gefährlich mit “umgebungs”-basierten Authentifizierungsmechanismen wie Cookies. Wenn Sie Authorization: Bearer <token> Header verwenden und der Client diesen Header selbst setzen muss (JS kann das cross-site nicht machen wegen CORS), funktioniert das klassische CSRF nicht.

SameSite ist ein Flag auf einem Cookie, das dem Browser sagt: Soll dieses Cookie cross-site bei Anfragen mitgesendet werden? Es gibt 3 Werte:

Wert Verhalten
Strict Cookie wird nur gesendet, wenn die Anfrage von derselben Site kommt (First-Party-Navigation). Nicht mal, wenn der Benutzer von einer anderen Site einen Link anklickt.
Lax Cookie wird bei Top-Level-Navigation GET gesendet (wenn Benutzer Link anklickt), aber nicht bei POST/Sub-Requests von anderen Sites.
None Cookie wird immer cross-site gesendet, muss mit Secure kombiniert werden.

Konfiguration in Spring Boot über application.yml:

server:
  servlet:
    session:
      cookie:
        same-site: lax
        secure: true
        http-only: true

Mit same-site: lax wird die CSRF-Anfrage aus dem obigen Beispiel (automatisch eingereichte Form POST von evil.com) kein Session-Cookie mitführen — moderne Browser blockieren das. Das ist der Grund, warum Lax seit 2020 zum Chrome-Standard geworden ist und die meisten Form-POST-CSRF-Angriffe blockiert.

3.1. Warum SameSite nicht ausreicht

SameSite ist keine vollständige Lösung aus mehreren Gründen:

  • Subdomain-Übernahme: Wenn Ihre Site blog.example.com kompromittiert ist, wird sie immer noch als “same site” mit app.example.com angesehen — das Cookie wird trotzdem gesendet.
  • Alte Browser: Einige Browser/Versionen unterstützen SameSite nicht vollständig und senden Cookies wie vorher.
  • GET-Anfragen mit Seiteneffekten: Wenn Ihr Endpoint versehentlich GET erlaubt, um Daten zu schreiben (Anti-Pattern, aber es kommt vor), lässt Lax Top-Level-Navigation GET mit Cookie zu.
// Anti-Pattern: GET mit Seiteneffekt, Lax kann das nicht blockieren
@GetMapping("/orders/{id}/cancel")
public ResponseEntity<Void> cancelOrder(@PathVariable Long id) {
    orderService.cancel(id); // gefährlich wenn geleckt über <img src="...">
    return ResponseEntity.ok().build();
}

Daher sollte SameSite als zusätzliche Schutzebene (Defense in Depth) betrachtet werden, nicht als Ersatz für CSRF-Token.

4. CSRF-Token: Der Hauptschutzmechanismus

CSRF-Token funktioniert nach dem Prinzip Double-Submit / Synchronizer Token: Der Server generiert einen zufälligen Token, speichert ihn in der Session oder einem separaten Cookie und verlangt, dass alle Anfragen, die Daten ändern (POST/PUT/DELETE), diesen Token im Body oder Header mitführen. Ein Angreifer auf evil.com kann den Token nicht lesen (Same-Origin-Policy blockiert Lesezugriff auf Response/Cookie anderer Domains), kann also keine legitime Anfrage fälschen.

4.1. Spring Security hinzufügen

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

Spring Security 6.x aktiviert standardmäßig CSRF-Schutz für alle Anfragen außer GET/HEAD/OPTIONS/TRACE.

Bei SPA (React, Vue), die API über AJAX aufrufen, verwenden Sie CookieCsrfTokenRepository, damit JS den Token aus dem Cookie ausliest und in den Header setzt:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/payments/**").authenticated()
                .anyRequest().permitAll()
            );
        return http.build();
    }
}

Erklärung:

  • withHttpOnlyFalse(): Das Cookie XSRF-TOKEN muss von JS lesbar sein (document.cookie), daher kann nicht HttpOnly gesetzt werden. Das ist ein bewusstes Trade-off — dieser Token ist nicht so sensibel wie das Session-Cookie, und wenn er gestohlen wird, ist er ohne Session-Cookie nicht ausreichend für einen Angriff.
  • CsrfTokenRequestAttributeHandler: Verwaltet das Lazy-Loading des Tokens, vermeidet, den Token gleich bei der ersten Anfrage zu rendern (optimiert für SPA).

Auf der Client-Seite muss jede Anfrage das Cookie XSRF-TOKEN auslesen und den Header X-XSRF-TOKEN setzen:

function getCsrfToken() {
  return document.cookie
    .split('; ')
    .find(row => row.startsWith('XSRF-TOKEN='))
    ?.split('=')[1];
}

fetch('/api/payments/transfer', {
  method: 'POST',
  headers: { 'X-XSRF-TOKEN': getCsrfToken(), 'Content-Type': 'application/json' },
  body: JSON.stringify({ toAccount: 'ACC123', amount: 100 })
});

Ein Angreifer auf evil.com kann das Cookie XSRF-TOKEN von bank.example.com nicht lesen (Same-Origin-Policy), daher kann der korrekte Header nicht gesetzt werden. Ein automatisch eingereichtetes Formular kann einen benutzerdefinierten Header nicht setzen, daher wird die Anfrage auch bei SameSite=None blockiert.

4.3. Konfiguration mit traditionellen Forms (Server-Side-Render)

Bei Verwendung von Thymeleaf wird der Token direkt ins Formular gerendert:

<form th:action="@{/api/orders}" method="post">
  <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
  <input type="text" name="productName"/>
  <button type="submit">Order erstellen</button>
</form>

Spring MVC injiziert automatisch _csrf in das Modell dank CsrfRequestDataValueProcessor, keine zusätzliche Code erforderlich.

5. SameSite und CSRF-Token kombinieren: Defense in Depth

Diese beiden Ebenen lösen verschiedene Aspekte des gleichen Problems, daher sollten Sie sie zusammen verwenden:

server:
  servlet:
    session:
      cookie:
        same-site: lax
        secure: true
        http-only: true
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.csrf(csrf -> csrf
        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
    );
    return http.build();
}
  • SameSite=Lax auf dem Session-Cookie blockiert die meisten cross-site Anfragen direkt im Browser, bevor sie den Server erreichen.
  • CSRF-Token blockiert Fälle, die SameSite nicht abdeckt (alte Browser, Subdomain-Übernahme, oder wenn Sie bewusst SameSite=None verwenden müssen, weil Sie Cross-Site-Embedding unterstützen müssen).

6. Tests schreiben, um CSRF-Schutz zu verifizieren

Verwenden Sie MockMvc mit spring-security-test, um zu überprüfen, dass Endpoints blockiert werden, wenn der Token fehlt:

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class)
class OrderControllerCsrfTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void shouldRejectPostWithoutCsrfToken() throws Exception {
        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"productName\":\"Laptop\"}"))
            .andExpect(status().isForbidden());
    }

    @Test
    void shouldAcceptPostWithValidCsrfToken() throws Exception {
        mockMvc.perform(post("/api/orders")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"productName\":\"Laptop\"}"))
            .andExpect(status().isOk());
    }
}

Verwenden Sie AssertJ für Business-Logik, die nicht an den HTTP-Layer gebunden ist, z.B. überprüfen, dass Token pro Session einzigartig sind:

@Test
void csrfTokenShouldBeUniquePerSession() {
    CsrfTokenRepository repository = new HttpSessionCsrfTokenRepository();
    CsrfToken token1 = repository.generateToken(new MockHttpServletRequest());
    CsrfToken token2 = repository.generateToken(new MockHttpServletRequest());

    assertThat(token1.getToken()).isNotEqualTo(token2.getToken());
}
  • Der erste Test bestätigt, dass eine POST-Anfrage ohne Token 403 Forbidden zurückgibt — das Standard-Verhalten von Spring Security.
  • with(csrf()) ist ein Helper von spring-security-test, der automatisch einen gültigen Token in die simulierte Anfrage einfügt.
  • Der dritte Test stellt sicher, dass Tokens zwischen Sessions nicht wiederverwendet/erraten werden können.

7. Häufige Fehler und wann Sie CSRF-Token nicht brauchen

Fehler 1 — CSRF global für die API “zum einfacheren Testen” deaktivieren:

// Anti-Pattern: Machen Sie das nicht in Production
http.csrf(csrf -> csrf.disable());

Das ist nur sinnvoll, wenn Ihre API vollständig stateless ist — Authentifizierung über JWT in Authorization Header, keine Cookies zur Authentifizierung. In diesem Fall gibt es keine “Ambient Credentials”, die ein Angreifer ausnutzen könnte.

Fehler 2 — SameSite=None verwenden, ohne die Konsequenzen zu verstehen:

server:
  servlet:
    session:
      cookie:
        same-site: none  # nötig für Cross-Site-iFrame, öffnet aber CSRF-Risiko wieder
        secure: true

Wenn Sie gezwungen sind, None zu verwenden (z.B. um die App in einem iFrame von Drittanbietern einzubetten), müssen Sie CSRF-Token haben, nicht optional.

Fehler 3 — CSRF-Schutz auf GET-Anfragen mit Seiteneffekten anwenden:

Designen Sie nicht, dass GET zum Löschen/Aktualisieren von Daten verwendet wird. Der CSRF-Filter von Spring Security schützt standardmäßig nicht GET (weil GET als sicherer Mechanismus laut RFC 7231 gilt), also wenn Sie gegen REST-Prinzipien verstoßen, öffnen Sie selbst ein Sicherheitsloch.

Hinweis: Wenn Ihr Backend sowohl eine Web-App (Cookie-basiert) als auch eine Mobile/Third-Party-API (Token-basiert) auf der gleichen Domain bedient, trennen Sie die CSRF-Konfiguration nach RequestMatcher — aktivieren Sie CSRF nur für Paths, die Cookies verwenden, deaktivieren Sie es für Paths mit Bearer-Token.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.csrf(csrf -> csrf
        .ignoringRequestMatchers("/api/mobile/**") // authentifiziert mit JWT, nicht Cookie
    );
    return http.build();
}

8. Fazit

CSRF-Token und SameSite-Cookies lösen zwei verschiedene Aspekte desselben Problems: Browser automatisch Credentials bei Anfragen von anderen Sites mitschicken verhindern. SameSite ist eine Schutzschicht auf Browser-Ebene, einfach zu aktivieren aber nicht vollständig. CSRF-Token ist eine Schutzschicht auf Anwendungsebene, zuverlässiger aber erfordert zusätzlichen Code auf Server und Client. Für Systeme, die Session-Cookies verwenden, nutzen Sie beide zusammen; für stateless APIs, die mit Bearer-Token authentifizieren, können Sie den CSRF-Filter deaktivieren, sollten aber trotzdem SameSite beibehalten, um andere cookiegebundene Risiken zu mindern.

web-securitycsrfspring-securitycookiespring-boot