Ein einziger fehlender CSRF-Token kann reichen, damit ein Angreifer im Namen eines eingeloggten Benutzers Aktionen ausloest. 2024 wurde eine bekannte E-Commerce-Plattform kompromittiert, weil ein Entwickler skip_before_action :verify_authenticity_token in einem Admin-Controller hinterlassen hatte, der eigentlich nur fuer einen temporaeren Webhook gedacht war. Der Angriff: ein praepariertes Bild-Tag in einer E-Mail, das einen POST-Request auf den ungeschuetzten Endpoint ausloeste.

Rails bringt soliden CSRF-Schutz mit. Das Problem liegt selten am Framework, sondern an der Konfiguration. Dieser Artikel zeigt, wo die typischen Fehler passieren und wie man sie vermeidet.

Rails Cross-Site Request Forgery (CSRF)

Rails

Wie CSRF-Angriffe in Rails funktionieren

Bevor wir in die Konfiguration einsteigen: Was passiert bei einem CSRF-Angriff konkret?

  1. Ein Benutzer ist in deiner Rails-App eingeloggt (Session-Cookie aktiv)
  2. Er besucht eine manipulierte Website oder oeffnet eine praeparierte E-Mail
  3. Ein verstecktes Formular oder Bild-Tag loest einen Request an deine App aus
  4. Der Browser sendet automatisch den Session-Cookie mit
  5. Deine App fuehrt die Aktion aus, weil die Session gueltig ist

Der Angriff funktioniert, weil der Browser Cookies automatisch an die Ziel-Domain sendet. Ohne Token-Validierung kann der Server nicht unterscheiden, ob der Request vom eigenen Frontend oder von einer fremden Seite kommt.

Ein realistisches Angriffsszenario:

<!-- Auf einer Angreifer-Website -->
<img src="https://deine-app.com/admin/users/42/promote?role=admin" />

<!-- Oder per verstecktem Formular -->
<form action="https://deine-app.com/transfers" method="POST">
  <input type="hidden" name="amount" value="10000" />
  <input type="hidden" name="to_account" value="ATTACKER-IBAN" />
</form>
<script>document.forms[0].submit();</script>

protect_from_forgery: Die drei Strategien im Detail

Die Methode protect_from_forgery im ApplicationController ist die erste Verteidigungslinie:

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
end

Rails bietet drei Strategien, die sich grundlegend unterscheiden:

StrategieVerhalten bei ungueltigem TokenSeiteneffekt
:exceptionWirft ActionController::InvalidAuthenticityTokenRequest wird komplett abgebrochen, Fehler wird geloggt
:null_sessionSetzt request.session auf ein leeres HashRequest wird ausgefuehrt, aber ohne Session-Daten
:reset_sessionLoescht die gesamte SessionBenutzer wird ausgeloggt, Request wird ausgefuehrt

Der kritische Unterschied, den viele uebersehen

Bei :null_session und :reset_session wird der Request trotzdem ausgefuehrt. Nur die Session-Daten sind weg. Wenn dein Controller eine Aktion hat, die nicht auf Session-Daten angewiesen ist (z.B. ein oeffentliches Formular), wird diese Aktion auch ohne gueltigen Token ausgefuehrt.

# Gefaehrlich mit :null_session
class TransfersController < ApplicationController
  def create
    # current_user ist nil wegen null_session
    # Aber wenn die Aktion keinen current_user braucht...
    Transfer.create!(params[:transfer])  # wird trotzdem ausgefuehrt!
  end
end

Empfehlung: Verwende :exception als Standard. Du willst, dass ungueltige Requests sofort fehlschlagen.

USEO’s Take

In ueber 50 Rails-Projekten, die wir gewartet oder modernisiert haben, war :null_session die haeufigste Fehlkonfiguration. Teams setzen es ein, weil sie denken, es sei “weniger aggressiv”. In Wirklichkeit verschleiert es Sicherheitsprobleme: Der Request schlaegt leise fehl, niemand bemerkt den fehlenden Token, und im schlimmsten Fall werden Aktionen ohne Session-Kontext ausgefuehrt. Nutze :exception und baue einen Error-Handler, der ActionController::InvalidAuthenticityToken sauber abfaengt.

Unterschiede zwischen Rails 5, 6 und 7

Die CSRF-Implementierung hat sich ueber die Versionen weiterentwickelt:

Rails 5.2: Per-Form CSRF Tokens

Rails 5.2 fuehrte per-form CSRF Tokens ein. Statt eines einzigen Tokens pro Session generiert Rails fuer jedes Formular einen eigenen Token, der an die Ziel-Action gebunden ist:

# config/application.rb
config.action_controller.per_form_csrf_tokens = true

Das verhindert, dass ein Token aus Formular A fuer Formular B missbraucht wird. Seit Rails 5.2 ist diese Option standardmaessig aktiviert.

Rails 6: Origin-Pruefung

Ab Rails 6 prueft protect_from_forgery zusaetzlich den Origin-Header:

# config/application.rb
config.action_controller.forgery_protection_origin_check = true

Wenn der Origin-Header nicht mit dem Host der App uebereinstimmt, wird der Request abgelehnt, selbst wenn der Token korrekt ist. Das ist eine zusaetzliche Verteidigungsschicht.

Rails 7: Verschluesselte Cookies als Standard

Rails 7 setzt standardmaessig auf verschluesselte Cookies statt signed Cookies:

# Rails 7 Standard
Rails.application.config.action_dispatch.cookies_serializer = :json
Rails.application.config.session_store :cookie_store,
  key: '_app_session',
  same_site: :lax,
  secure: Rails.env.production?

Zusaetzlich verwendet Rails 7 SameSite: Lax als Standard fuer alle Cookies, was CSRF-Angriffe ueber Cross-Site-Requests deutlich erschwert.

csrf_meta_tags: Warum sie fehlen koennen

Der csrf_meta_tags Helper im Layout generiert die Meta-Tags, die JavaScript braucht:

<!-- app/views/layouts/application.html.erb -->
<head>
  <%= csrf_meta_tags %>
</head>

Das erzeugt:

<meta name="csrf-param" content="authenticity_token" />
<meta name="csrf-token" content="dein_token_wert" />

Haeufige Fehlerquelle: Bei Turbo Drive (Rails 7) oder SPA-Setups werden Seiten oft per AJAX nachgeladen. Dabei werden die Meta-Tags im <head> nicht aktualisiert. Das fuehrt zu abgelaufenen Tokens.

Loesung fuer Turbo:

// Turbo aktualisiert csrf-token automatisch bei Turbo-Visits
// Aber bei manuellen fetch-Requests brauchst du:
function getCsrfToken() {
  return document.querySelector('meta[name="csrf-token"]')?.content;
}

// Nach einem Turbo-Visit wird der Token im Meta-Tag aktualisiert
document.addEventListener('turbo:load', () => {
  // Token ist jetzt aktuell
});

Formulare ohne Rails-Helper: Das vergessene Token

Rails-Formulare mit form_with fuegen den Token automatisch ein:

<%= form_with model: @project do |form| %>
  <%= form.label :name %>
  <%= form.text_field :name %>
  <%= form.submit "Speichern" %>
<% end %>

Wenn du HTML-Formulare ohne Rails-Helper erstellst, fehlt der Token:

<!-- FALSCH: Kein CSRF-Token -->
<form action="/projects" method="post">
  <input type="text" name="project[name]" />
  <button type="submit">Speichern</button>
</form>

<!-- RICHTIG: Token manuell einfuegen -->
<form action="/projects" method="post">
  <input type="hidden" name="authenticity_token"
         value="<%= form_authenticity_token %>" />
  <input type="text" name="project[name]" />
  <button type="submit">Speichern</button>
</form>

Das passiert haeufig bei:

  • Formularen in Markdown/CMS-Content
  • Eingebetteten Widgets von Drittanbietern
  • Legacy-Views, die vor der Rails-Migration erstellt wurden

Warum GET-Requests fuer Zustandsaenderungen gefaehrlich sind

Rails prueft CSRF-Tokens nur bei POST, PUT, PATCH und DELETE. GET-Requests werden nicht geprueft, weil sie laut HTTP-Spezifikation idempotent sein sollen.

Das wird zum Problem, wenn Entwickler GET-Requests fuer Zustandsaenderungen verwenden:

# GEFAEHRLICH: GET-Route fuer Zustandsaenderung
get '/users/:id/deactivate', to: 'users#deactivate'

# In einer E-Mail oder fremden Website:
# <img src="https://deine-app.com/users/42/deactivate" />
# -> Benutzer 42 wird deaktiviert, ohne CSRF-Schutz
HTTP-MethodeCSRF-Token geprueft?Fuer Zustandsaenderungen geeignet?
GETNeinNein
POSTJaJa
PUT/PATCHJaJa
DELETEJaJa

Regel: Jede Aktion, die Daten veraendert, muss hinter einem nicht-GET HTTP-Verb stehen.

skip_forgery_protection: Wann es legitim ist (und wann nicht)

Suche in deinem Code nach:

grep -rn "skip_forgery_protection\|skip_before_action.*verify_authenticity_token" app/controllers/

Legitime Faelle

  1. API-only Controller mit Token-Authentifizierung
class Api::V1::BaseController < ActionController::API
  # ActionController::API hat keinen CSRF-Schutz
  # Das ist korrekt, weil API-Clients keine Browser-Cookies verwenden
  before_action :authenticate_api_token!
end
  1. Webhook-Endpoints mit Signatur-Verifizierung
class WebhooksController < ApplicationController
  skip_forgery_protection only: [:stripe, :github]

  before_action :verify_webhook_signature

  private

  def verify_webhook_signature
    # Stripe/GitHub senden eine Signatur im Header
    # Die wird gegen das Shared Secret verifiziert
    payload = request.body.read
    sig = request.headers['Stripe-Signature']
    Stripe::Webhook::Signature.verify_header(payload, sig, endpoint_secret)
  end
end
  1. Health-Check Endpoints
class HealthController < ApplicationController
  skip_forgery_protection only: [:show]

  def show
    render json: { status: 'ok' }  # Keine Zustandsaenderung, nur Lesezugriff
  end
end

Nicht legitime Faelle

  • “Es funktioniert nicht mit meinem JavaScript” (dann fehlt das Token im Request)
  • “Wir brauchen es nicht, wir haben ja ein Login” (Login schuetzt nicht vor CSRF)
  • “Es ist nur ein internes Tool” (interne Tools sind genauso angreifbar)

USEO’s Take

Wir sehen in Code-Audits regelmaessig skip_before_action :verify_authenticity_token im ApplicationController oder in Base-Controllern, weil ein Entwickler irgendwann ein AJAX-Problem damit “geloest” hat. Wenn du CSRF-Schutz global deaktivierst, hast du keinen CSRF-Schutz. Klingt offensichtlich, passiert aber erstaunlich oft. Unser Vorgehen: Wir behandeln jedes skip_forgery_protection wie ein TODO und dokumentieren den Grund direkt im Code.

AJAX und SPA: Token-Handling ohne Rails-Formular

Bei modernen JavaScript-Frontends musst du den CSRF-Token manuell mitschicken:

Vanilla JavaScript / Fetch API

const token = document.querySelector('meta[name="csrf-token"]').content;

fetch('/posts', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': token
  },
  body: JSON.stringify({ post: { title: 'Neuer Beitrag' } }),
  credentials: 'same-origin'  // Wichtig: Cookies mitsenden
});

Axios (React, Vue)

import axios from 'axios';

// Einmal konfigurieren
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
axios.defaults.headers.common['X-CSRF-Token'] = csrfToken;
axios.defaults.withCredentials = true;

// Danach automatisch in jedem Request
axios.post('/posts', { post: { title: 'Neuer Beitrag' } });

Problem: Token-Rotation bei langen Sessions

Wenn ein Benutzer eine SPA stundenlang offen hat, kann der Token in den Meta-Tags veralten. Rails rotiert Tokens bei jedem Request. Loesungen:

# Option 1: Token per API-Endpoint bereitstellen
class CsrfTokensController < ApplicationController
  def show
    render json: { token: form_authenticity_token }
  end
end
// Option 2: Token aus Response-Header lesen
// Rails setzt den neuen Token im X-CSRF-Token Response-Header
fetch('/posts', { method: 'POST', /* ... */ })
  .then(response => {
    const newToken = response.headers.get('X-CSRF-Token');
    if (newToken) {
      document.querySelector('meta[name="csrf-token"]').content = newToken;
    }
  });

Die richtige Cookie-Konfiguration ist eine zusaetzliche Schutzschicht:

# config/initializers/session_store.rb
Rails.application.config.session_store :cookie_store,
  key: '_app_session',
  same_site: :lax,
  secure: Rails.env.production?,
  httponly: true

Was jedes Attribut bewirkt:

AttributWirkungEmpfehlung
same_site: :laxCookie wird bei Cross-Site-Requests nur bei Top-Level-Navigation gesendetStandard fuer die meisten Apps
same_site: :strictCookie wird nie bei Cross-Site-Requests gesendetFuer Hochsicherheits-Apps (Banking, Gesundheitswesen)
secure: trueCookie nur ueber HTTPSPflicht in Produktion
httponly: trueCookie nicht per JavaScript lesbarImmer aktivieren

Achtung: SameSite: Strict kann dazu fuehren, dass Benutzer nach dem Klick auf einen Link (z.B. aus einer E-Mail) nicht eingeloggt sind, weil der Cookie nicht mitgesendet wird. Fuer die meisten Anwendungen ist :lax der bessere Kompromiss.

XSS als CSRF-Verstaerker

Eine XSS-Schwachstelle macht jeden CSRF-Schutz wirkungslos. Wenn ein Angreifer JavaScript in deiner App ausfuehren kann, liest er den Token einfach aus:

// Ein Angreifer mit XSS-Zugang:
const token = document.querySelector('meta[name="csrf-token"]').content;
// Jetzt kann er beliebige Requests mit gueltigem Token senden
fetch('/admin/users', {
  method: 'DELETE',
  headers: { 'X-CSRF-Token': token },
  credentials: 'same-origin'
});

Rails schuetzt mit automatischem HTML-Escaping in ERB-Templates. Kritische Stellen, an denen XSS trotzdem entstehen kann:

  • raw() oder html_safe auf Benutzereingaben
  • render inline: mit unkontrollierten Parametern
  • JavaScript-Templates, die Daten unescaped einfuegen
  • Content Security Policy (CSP) fehlt oder ist zu permissiv
# GEFAEHRLICH
<%= raw @user.bio %>
<%= @comment.body.html_safe %>

# SICHER
<%= @user.bio %>
<%= sanitize @comment.body %>

Implementiere CSP-Header als zusaetzliche Schicht:

# config/initializers/content_security_policy.rb
Rails.application.configure do
  config.content_security_policy do |policy|
    policy.default_src :self
    policy.script_src  :self
    policy.style_src   :self, :unsafe_inline
  end
end

CSRF in API-only Apps: Wann du ihn wirklich deaktivieren kannst

Wenn deine Rails-App ausschliesslich als API dient (kein Browser-Frontend, keine Cookies), ist CSRF-Schutz nicht noetig. Der Grund: CSRF-Angriffe funktionieren ueber automatisch gesendete Cookies. Ohne Cookies kein CSRF.

# API-only Application
class ApplicationController < ActionController::API
  # ActionController::API hat keinen CSRF-Schutz
  # Authentifizierung laeuft ueber Bearer Token / API Key
end

USEO’s Take

Vorsicht bei “hybriden” Apps. Wir hatten einen Fall, wo eine Rails-App als API-only gestartet wurde, dann aber ein Admin-Panel mit Session-Cookies dazukam. Die API-Controller erbten von ActionController::API, die Admin-Controller von ActionController::Base, aber einige Shared-Controller waren falsch zugeordnet. Ergebnis: Admin-Aktionen ohne CSRF-Schutz. Wenn du hybrid arbeitest, pruefe die Controller-Hierarchie genau.

Entscheidungsmatrix:

SetupCSRF noetig?Authentifizierung
Klassische Web-App mit SessionsJaSession-Cookie
SPA mit Cookie-basierter AuthJaSession-Cookie via AJAX
API mit Bearer TokenNeinAuthorization Header
API mit API-Key im HeaderNeinCustom Header
Hybrid (Web + API)Ja, fuer Web-RoutenGemischt

Token-Leaks verhindern

CSRF-Tokens gehoeren nicht in URLs. Sie koennen ueber Referer-Header, Browser-History, Server-Logs und geteilte Links exponiert werden.

Checkliste fuer Token-Sicherheit:

  • Tokens nur in HTTP-Headern, Hidden Fields oder HttpOnly-Cookies uebertragen
  • Keine Tokens in URL-Parametern
  • Keine Tokens in localStorage oder sessionStorage (XSS-angreifbar)
  • Tokens nicht in Log-Dateien schreiben (Parameter-Filter konfigurieren)
  • Tokens nicht in Fehlermeldungen an den Client senden
# config/initializers/filter_parameter_logging.rb
Rails.application.config.filter_parameters += [
  :authenticity_token, :password, :token
]

Checkliste

Setup

  • protect_from_forgery with: :exception im ApplicationController
  • csrf_meta_tags im Layout-Head
  • Per-form CSRF Tokens aktiviert (Rails 5.2+)
  • Origin-Pruefung aktiviert (Rails 6+)

Formulare und Requests

  • Alle Formulare verwenden form_with oder fuegen Token manuell ein
  • Keine GET-Routes fuer Zustandsaenderungen
  • AJAX-Requests senden X-CSRF-Token Header
  • credentials: 'same-origin' bei fetch-Requests

Ausnahmen

  • Jedes skip_forgery_protection ist dokumentiert und begruendet
  • API-Endpoints haben alternative Authentifizierung (Bearer Token, API Key)
  • Webhook-Endpoints verifizieren Signaturen

Cookies

  • SameSite: Lax oder Strict konfiguriert
  • Secure: true in Produktion
  • HttpOnly: true fuer Session-Cookies

XSS-Praevention

  • Kein html_safe oder raw auf Benutzereingaben
  • Content Security Policy konfiguriert
  • Regelmaessige Security-Audits

Token-Sicherheit

  • Tokens nie in URLs
  • authenticity_token in Parameter-Filter
  • Keine Tokens in localStorage/sessionStorage

FAQs

Wie konfiguriere ich CSRF-Schutz in einer hybriden Rails-App (Web + API)?

Trenne die Controller-Hierarchie klar:

# Web-Controller: voller CSRF-Schutz
class WebController < ActionController::Base
  protect_from_forgery with: :exception
end

# API-Controller: kein CSRF, aber Token-Auth
class ApiController < ActionController::API
  before_action :authenticate_api_token!
end

Stelle sicher, dass kein Controller versehentlich von der falschen Basisklasse erbt. Pruefe insbesondere Shared-Concerns und Module, die in beide Hierarchien eingebunden werden.

Was passiert, wenn ich CSRF-Schutz fuer einen Controller deaktiviere?

Der Controller akzeptiert Requests ohne gueltigen CSRF-Token. Das bedeutet: Jede Website im Internet kann Requests an diesen Controller senden, solange der Benutzer eingeloggt ist. Wenn der Controller Daten aendert, ist das ein Sicherheitsproblem. Wenn du CSRF deaktivieren musst, stelle sicher, dass eine alternative Authentifizierung vorhanden ist (z.B. API-Token im Header).

Warum reicht SameSite allein nicht als CSRF-Schutz?

SameSite: Lax blockiert nur Cross-Site-Requests, die nicht ueber Top-Level-Navigation erfolgen. Ein Angreifer kann immer noch ein Formular erstellen, das den Benutzer auf deine Seite weiterleitet (Top-Level-Navigation). Ausserdem unterstuetzen aeltere Browser SameSite nicht vollstaendig. CSRF-Tokens und SameSite-Cookies ergaenzen sich, ersetzen sich aber nicht.

Verwandte Artikel