Alle Artikel
DjangoPythonwebhook debugginglocal testing

Django-Webhooks: an CSRF vorbei in den Handler

Standardmäßig weist Django jeden Webhook-POST mit einem 403 ab. Schuld ist die CSRF-Middleware, die ihren Job macht — Schutz vor Cross-Site Request Forgery —, nur ist der Anbieter kein Browser und trägt weder Session-Cookie noch CSRF-Token. Der Fix sind ein Decorator und eine Zeile Bewusstsein über den Zugriff auf den Request-Body.

CSRF für Webhook-Views überspringen

Der Django-Weg:

from django.views.decorators.csrf import csrf_exempt
from django.http import HttpResponse, HttpResponseBadRequest
import hmac, hashlib

@csrf_exempt
def stripe_webhook(request):
    if request.method != 'POST':
        return HttpResponseBadRequest('POST only')

    payload = request.body            # rohe Bytes, unangetastet
    signature = request.headers.get('Stripe-Signature', '')

    # Signatur prüfen (im echten Code das Stripe-SDK nutzen)
    if not verify(payload, signature, settings.STRIPE_WEBHOOK_SECRET):
        return HttpResponse(status=401)

    event = json.loads(payload)
    handle_stripe_event(event)
    return HttpResponse(status=200)

Der Decorator @csrf_exempt entfernt die Middleware-Prüfung nur für diese View — deine anderen Formulare behalten den Schutz. request.body liefert die rohen Bytes, wie sie ankamen, genau das, was die Signaturprüfung braucht.

Wenn du Django REST Framework nutzt

DRF verpackt Requests in ein eigenes Request-Objekt und startet Parser je nach Content-Type. Wenn dein View-Code request.data liest, hat der JSON-Parser den Body bereits verbraucht. Zwei Auswege:

Option A: als einfache Django-View belassen

Webhook-Handler brauchen DRFs Parser, Browsable API oder Auth-Maschinerie nicht. Eine einfache @csrf_exempt-View neben deinen DRF-Endpoints ist das sauberste Muster.

Option B: DRF mit Raw-Zugriff

Willst du wirklich DRF, lies request.body (den darunterliegenden Django-Request) statt request.data:

from rest_framework.decorators import api_view, permission_classes, authentication_classes
from rest_framework.permissions import AllowAny

@api_view(['POST'])
@authentication_classes([])         # keine Auth für Webhooks
@permission_classes([AllowAny])     # Anbieter authentifiziert bereits per Signatur
def stripe_webhook(request):
    payload = request._request.body  # darunterliegender Django-Request, rohe Bytes
    signature = request.META.get('HTTP_STRIPE_SIGNATURE', '')
    # ...prüfen und behandeln

Die Indirektion request._request ist umständlich, aber der offizielle Ausweg, wenn du DRFs Parsing umgehen musst.

Tunnel einrichten

  1. Starte deinen Django-Dev-Server: python manage.py runserver 8000.
  2. Füge den Hostnamen des Tunnels in settings.py zu ALLOWED_HOSTS hinzu. Am einfachsten ist ['*'] im lokalen Dev, oder eine spezifische Tunnel-Domain, wenn du das bevorzugst.
  3. npx portpreview 8000 in einem separaten Terminal.
  4. Füge die Tunnel-URL plus deinen Webhook-Pfad ins Dashboard des Anbieters ein.
  5. Löse ein Test-Event aus und sieh zu, wie es in deiner View landet.

ALLOWED_HOSTS zu vergessen erzeugt einen generischen Django-400 ohne offensichtliche Ursache. Gibt deine erste Webhook-Zustellung 400 zurück und du siehst keine View-Logs, ist das der Grund.

Häufige Stolperfallen

Body zweimal lesen

Wie jedes Framework gibt dir Django den Body als Stream, der nur einmal gelesen werden kann. Hat Middleware oder ein Logging-Wrapper request.body bereits angefasst, sieht dein View-Code ein leeres Bytes-Objekt. Gib zur Kontrolle len(request.body) aus.

Zeitzonen und Timestamp-Toleranz

Vergleichst du den signierten Timestamp mit datetime.now(), nutze timezone.now() oder vergleiche in UTC. Naive Datetime-Vergleiche scheitern sporadisch bei Sommerzeitumstellungen.

Async-Views

Django 4+ unterstützt async def-Views. Webhook-Handler können async sein, aber beachte, dass Signaturprüfungs-SDKs (Stripe Python usw.) synchron sind. Führe sie mit asgiref.sync_to_async aus oder halte die ganze View synchron.

Hinweise zum Produktions-Deployment

Bist du am lokalen Testen vorbei, läuft dieselbe View in Produktion. Gunicorn, uWSGI, Daphne — keiner fasst request.body an, bevor deine View ihn sieht. Dein Reverse-Proxy vielleicht (z. B. nginx mit eingeschaltetem proxy_buffering), aber das betrifft Pufferung, nicht Body-Veränderung.

Zur zugrunde liegenden Signaturtheorie siehe den Leitfaden zur Signaturprüfung. Für Stripe-spezifische Muster behandelt der Stripe-Local-Testing-Guide die Dashboard-Seite. Tritt der PortPreview-Warteliste bei für Tunnel, die mit Python-Dev-Servern gut harmonieren.

Häufig gestellte Fragen

Warum gibt Django bei jedem Webhook-POST einen 403 zurück?
Djangos CSRF-Middleware weist POSTs ohne gültiges CSRF-Token ab. Webhook-Anbieter senden keines. Füge @csrf_exempt zu deiner Webhook-View hinzu (nur dieser View), um die Prüfung zu umgehen, und prüfe stattdessen die Signatur.
Sollten Webhook-Views einfaches Django oder DRF sein?
Einfaches Django ist für Webhooks simpler. DRFs Parser verbrauchen den Body, bevor deine View ihn sieht, was die Signaturprüfung bricht. Behältst du DRF, lies request._request.body, um die rohen Bytes zu erhalten.
Welche ALLOWED_HOSTS-Einstellung braucht es für Tunnel in Django?
Füge deine Tunnel-Domain in settings.py zu ALLOWED_HOSTS hinzu. Im lokalen Dev funktioniert ['*']; für striktere Setups liste den spezifischen Tunnel-Hostnamen. Ohne das gibt Django einen 400 ohne klare Log-Meldung zurück.