Zum Inhalt springen
← Alle Beiträge

11. September 2026

Deine App schaltet sich selbst frei

Der Kauf-Dialog meldet Erfolg, die App setzt ein Flag, Pro ist offen. Damit entscheidet der Client, wer bezahlt hat. In der Browser-Konsole dauert das eine Zeile.

Der Checkout ist zurück, status: 'success', die App setzt isPro = true und schaltet frei. Der Nutzer sieht sofort, was er gekauft hat. Kein Laden, kein Warten, keine zweite Anfrage.

Genau daran hängt das Problem. Die App hat gerade selbst entschieden, dass jemand bezahlt hat.

Im Post davor ging es darum, wer die Nachricht geschickt hat. Hier geht es eine Ebene höher: wer entscheidet, was der Nutzer darf.

Was die KI dir gebaut hat

Bei einem Stripe-Abo baut Lovable inzwischen von selbst eine Server-Funktion mit Webhook. Bei einer einmaligen Freischaltung, in einer nativen App oder wenn du im Prompt einfach nur nach dem Flag fragst, kommt weiter das hier:

// nach der Rückkehr aus dem Checkout
if (result.status === 'success') {
  localStorage.setItem('isPro', 'true');
  setIsPro(true);
}

// beim Start
const isPro = localStorage.getItem('isPro') === 'true';

Das läuft. Der Testkauf schaltet frei, der Reload behält es, das Feature ist fertig.

Der ganze Beweis für die Zahlung steht im Browser des Nutzers. In einer nativen App heißt die Stelle SharedPreferences oder UserDefaults, das Muster ist dasselbe.

Der Test dauert zehn Sekunden

Browser-Konsole auf, eine Zeile:

localStorage.setItem('isPro', 'true')

Neu laden. Wenn Pro jetzt offen ist, weißt du Bescheid. Kein Werkzeug, kein Angriff, keine Anmeldung. Nur die Konsole, die in jedem Browser eingebaut ist.

Die Variante mit dem State ohne Speicher ist keinen Deut besser. Da öffnest du die React DevTools und setzt den Wert direkt.

Die zweite Fassung: der Client schreibt in die Datenbank

Wenn ein Backend im Spiel ist, kommt oft diese Version:

await supabase.from('profiles')
  .update({ is_pro: true })
  .eq('id', user.id);

Das sieht serverseitig aus, weil eine Datenbank dranhängt. Es ist trotzdem der Client, der schreibt. Wer den öffentlichen Schlüssel aus dem Bundle nimmt, schickt dieselbe Anfrage von Hand. Die Spalte is_pro gehört dem Server. Der Client liest sie und schreibt sie nie.

Was stattdessen dahingehört

Der Server beantwortet eine Frage: Was darf dieser Nutzer? Die Grundlage ist das, was der Zahlungsanbieter bestätigt hat, eingetragen über den geprüften Webhook.

// Server entscheidet
app.get('/api/entitlements', requireAuth, async (req, res) => {
  const sub = await db.getSubscription(req.user.id);   // aus dem Webhook
  const aktiv = sub?.status === 'active' && sub.periodEnd > Date.now();
  res.json({ pro: aktiv, gueltigBis: sub?.periodEnd ?? null });
});

// Client fragt
const { pro, gueltigBis } = await fetch('/api/entitlements')
  .then(r => r.json());

// Rückkehr aus dem Checkout: neu fragen, nichts setzen
onCheckoutReturn(() => refetchEntitlements());

Der Unterschied steht in der letzten Zeile. Der Kauf-Callback löst eine neue Abfrage aus. Er ist ein Signal, kein Beweis. Er darf auch ausbleiben, wenn der Nutzer den Tab schließt, und der Server weiß trotzdem Bescheid.

Ein Punkt, den man dabei leicht überliest: Die Antwort steuert die Oberfläche. Sie ersetzt die Prüfung an der Funktion nicht. Wenn pro: false nur den Export-Button ausgraut, der Endpunkt /api/export aber jeden bedient, hast du die Entscheidung wieder im Client. Jede bezahlte Funktion prüft die Berechtigung selbst, an der Stelle, an der sie ausgeführt wird.

Die Stelle, an der es auch dann noch klemmt

Niemand fragt bei jedem Klick beim Server nach. Also cachest du die Antwort. Und damit hast du wieder einen lokalen Wert, der Zugang gewährt, nur diesmal einen, der mal richtig war.

Drei Dinge, die dabei geklärt sein müssen:

Wie lange gilt der Cache. Die Cache-Dauer ist das Zeitfenster, in dem ein gekündigter oder zurückerstatteter Nutzer weiter Pro hat. Minuten sind in Ordnung. Eine Woche ist eine Woche Gratis-Zugang nach der Kündigung. Bei jedem Kaltstart und beim Zurückkehren in den Vordergrund neu fragen.

Was bei Ablauf passiert. Der Server liefert gueltigBis mit. Ist der Zeitpunkt vorbei und die App bekommt keine frische Antwort, fällt sie auf den freien Stand zurück. Ohne diesen Ablauf ist der Cache dauerhaft und du bist beim localStorage-Flag vom Anfang.

Was offline gilt. Ein zahlender Nutzer im Zug darf seine App benutzen. Der letzte bekannte Status gilt bis gueltigBis, danach ist Schluss. Bei jährlichen Abos deckelst du das zusätzlich, sonst läuft eine App elf Monate ohne einen einzigen Serverkontakt weiter.

Der Cache ist eine Bequemlichkeit. Die Quelle bleibt der Server.

Wo das gesammelt liegt

Webhook-Signatur, Entitlement-Endpunkt, Prüfung an der Funktion, Cache-Dauer: Das hängt zusammen und keine dieser Stellen meldet sich von selbst, wenn sie fehlt. Dazu kommt der Teil, der erst in Produktion auftaucht. Was passiert bei einer Rückerstattung mitten in der Periode. Wie Trial und erste Abbuchung auseinanderlaufen. Wann ein Abo als gekündigt gilt und ab wann es zumacht.

Der Payment-Guide stellt das geordnet zusammen. Die Grundlagen stehen in der Anbieter-Doku. Hier geht es um die Stellen, an denen man beim ersten Mal danebengreift.

Heute reicht die Konsole. Setz dir dein eigenes Flag und schau, was danach offen ist.

Michael Schreier
Michael Schreier

Diplom-Informatiker · 25 Jahre Entwicklung · LinkedIn

paymentsicherheitvibe-codingentitlement
praxis-guides
$ guide --open "Payment & Web-Checkout Best Practice"
[i] Bezahlung steht einmal richtig: Web-Checkout und In-App-Payment sauber getrennt, Store-Gebühren umgangen wo die Regeln es zulassen, keine Neuimplementierung ein Jahr später.

PDF plus Beispiele · 197 € · framework-unabhängig

Payment & Web-Checkout Best Practice
Michael Schreier

Fragen zum Thema?

Schreib mir — ich freue mich auf den Austausch.