Zwei Gedanken tragen das meiste. Dein Konto ist eine echte Auth-Sitzung und keine Zeile, gegen die wir ein Passwort vergleichen. Und die Datenbank entscheidet, was du lesen darfst, nicht die App — ein veränderter Client oder jemand, der mit dem öffentlichen Schlüssel direkt mit der API spricht, bekommt exakt dieselben Antworten wie die App.
Konten: bcrypt, eine echte Sitzung und eine Brücke zum Profil
Die Identität liegt in Supabase Auth. Passwörter werden serverseitig mit bcrypt gehasht; die App sieht nie einen Passwort-Hash, und im Anmeldeweg gibt es nirgends einen selbstgebauten Vergleich. Die Anmeldung erzeugt ein auf dich ausgestelltes JWT, und eine Spalte auf deiner Profilzeile (auth_uid) ist die einzige Verbindung zwischen Auth-Nutzer und Profil — erst sie macht jede Regel weiter unten formulierbar.
Anmelden geht per E-Mail und Passwort oder über Google mit Androids Credential Manager, der ein echtes Google-ID-Token zum Tausch an Supabase reicht. Ein „überall abmelden“ beendet die Sitzung global.
Es gibt eine übrig gebliebene PIN-Spalte aus der Zeit vor Supabase Auth. Nichts liest sie, neue Konten entstehen mit leerem Wert, und sie steht zur Löschung an. Erwähnt wird sie nur, weil sie existiert und dir auffallen würde.
Passkeys: auf dem Server geprüft, mit Replay-Schutz
Korat unterstützt Passkeys — ein Schlüsselpaar, das in der sicheren Hardware deines Telefons entsteht, mit Fingerabdruck oder Gesicht entsperrt wird und das Gerät nie verlässt. Die Relying Party ist koratland.com, über assetlinks.json an die Android-App gebunden, und die Prüfung läuft in einem Cloudflare Worker statt in einer Bibliothek:
- Die Challenge ist einmalig gültig mit fünf Minuten Lebensdauer, und die Zeile wird beim Einlösen gelöscht
- der
rpIdHashin den Authenticator-Daten muss dem SHA-256 vonkoratland.comentsprechen - das User-Present-Flag muss gesetzt sein
- die Signatur wird mit WebCrypto geprüft — ECDSA P-256 oder RS256
- der Signaturzähler muss steigen; ein Zähler, der sich wiederholt oder zurückgeht, wird als Replay abgelehnt
Die Registrierung setzt eine bestehende Sitzung voraus und nimmt die Identität aus dem Bearer-Token, nie aus einer Nutzer-Id im Anfragekörper — sonst könnte jeder einen Passkey auf ein fremdes Konto registrieren. Die gespeicherte Credential-Zeile hat überhaupt keine Update-Regel, selbst der Eigentümer kann also weder eigenen öffentlichen Schlüssel noch Signaturzähler überschreiben; das Umbenennen läuft über eine Funktion, die nur die Beschriftung anfassen kann.
Eine bewusste Auslassung: Attestation wird nicht geprüft. Korat prüft nicht, welcher Hersteller den Authenticator gebaut hat — nichts hier ist also als hardwarebestätigte Aussage zu lesen.
Ehrlicher Stand. Die Passkey-*Registrierung* ist nachweislich funktionsfähig. Die Passkey-*Anmeldung* wurde noch nie durchgehend ausgeführt, und die Web-App unterstützt Passkeys überhaupt nicht — es ist eine Android-Funktion im Rollout. Im täglichen Einsatz sind E-Mail mit Passwort und die Google-Anmeldung.
Row-Level-Security: die Datenbank antwortet, nicht die App
Row-Level-Security ist über die ganze Datenbank aktiv. Regeln sind über vier Funktionen formuliert, und fast jede Regel in Korat ist eine davon:
- app_uid()
- Die Profil-Id der anfragenden Person, oder null, wenn niemand angemeldet ist. Grundlage jeder Nur-eigene-Zeile-Regel.
- is_member_of(business_id)
- Wahr für den Eigentümer oder jede Person mit aktiver Mitgliedszeile für diesen Betrieb.
- has_capability(business_id, capability)
- Löst die Rollenvorlagen — Eigentümer, Manager, Service, Küche — serverseitig auf, sodass die Datenbank der Rollenauffassung des Clients nie glaubt.
- can_review(business_id)
- Liest die Berechtigungssicht und macht „nur echte Kundschaft darf bewerten“ zu einer Eigenschaft der Datenbank statt zu einem Versprechen der App.
Drei Regeln haben wir teuer genug gelernt, um sie hinzuschreiben. Eine Regel, die nur aus Sicht des Ladens geschrieben ist, schließt die Kundschaft aus — eine Tischsitzung, ein Personalruf und eine Transaktion haben zwei berechtigte Lesende, sie lauten deshalb user_id = app_uid() or is_member_of(business_id). Eine Kindtabelle muss genau so weit lesbar sein wie ihre Elterntabelle, Kommentare und Likes delegieren also an den Beitrag, statt eine eigene Vorstellung davon zu tragen, wer lesen darf. Und eine Definer-Funktion läuft als Eigentümerin, sie muss die Berechtigung also selbst prüfen.
Was RLS nicht kann, und was an die Stelle tritt
Row-Level-Security kann beantworten, *wer anfragt*. Sie kann nie beantworten, *ob diese Anfrage ein gültiges Token trug* — einen Tisch-QR, einen Freigabelink, eine Einladung. Jeder tokengesteuerte Weg in Korat ist deshalb eine SECURITY DEFINER-Funktion, die das Token selbst prüft, während die Tabelle geschlossen bleibt.
Einem Tisch beitreten. Wer gleich einen QR-Code scannt, ist weder Gastgeber der Sitzung noch Teil des Personals und kann die Tabelle der Tischsitzungen gar nicht lesen — nicht einmal, um das Token nachzuschlagen. join_session_by_token gleicht das Token serverseitig ab, macht den ersten Scanner zum Gastgeber, fügt die Teilnahme hinzu und gibt bei falschem Token gar nichts zurück, statt einer Meldung, mit der sich ein Rateversuch eingrenzen ließe.
Freigabelinks. resolve_share gibt Art und Ziel eines Links zurück und sonst nichts — nie, wer geteilt hat, nie die Klickzahl. Das Zählen läuft über record_share_click, sodass jeder gezählt werden kann ohne jedes Schreibrecht auf der Auswertungstabelle; eine Insert-Regel gibt es dort bewusst für niemanden.
Geld. Eine Bestellung aufzugeben läuft vollständig auf dem Server. Er bepreist jede Zeile selbst und liest die Liefergebühr aus der Zeile des Betriebs, gleich was der Client geschickt hat — denn eine Gebühr, die der Client setzen kann, ist ein Rabatt, den der Client sich selbst gewährt.
Kontaktwege, Geräte und die Anmeldung, die nicht von dir war
Einen Bestätigungscode für eine Ersatz-E-Mail erzeugt eine Funktion, die nur die Service-Rolle aufrufen darf. Gespeichert wird nur der SHA-256 des Codes, er läuft nach zehn Minuten ab, erlaubt fünf Versuche und ist auf eine Anfrage pro Minute begrenzt. Der Worker, der die Mail verschickt, gibt den Code nie an die App zurück.
Du kannst einen eigenen Kontaktweg nicht selbst als bestätigt markieren. Ein Datenbank-Trigger erzwingt bei jedem Client-Schreibvorgang das Kennzeichen „nicht bestätigt“, und setzen kann es nur eine Definer-Funktion, die tatsächlich einen Code geprüft hat. Das wirkt paranoid, bis man es zu Ende denkt: Ohne diese Regel wäre es eine vollständige Übernahme, die eigene Adresse in ein fremdes Konto einzutragen und „Passwort vergessen“ zu drücken. Aus demselben Grund muss ein Kontaktweg bestätigt sein, bevor er der primäre werden kann — an die primäre Adresse schreibt das System.
Jedes angemeldete Gerät steht in den Einstellungen und lässt sich einzeln widerrufen. Die Gerätetabelle hat weder Insert- noch Update- noch Delete-Regel, denn ein gerade widerrufenes Gerät hält noch ein gültiges Token und könnte sonst seinen eigenen Widerruf aufheben. Ein Widerruf löscht die zugrunde liegende Sitzung, das Refresh-Token stirbt also mit.
Eine Anmeldung von einem neuen Gerät schreibt den In-App-Hinweis in derselben Transaktion, die die Gerätezeile anlegt, er kann also nicht verloren gehen, und schickt außerdem eine Mail — nur an bestätigte Kontaktwege, denn eine unbestätigte Adresse kann jemand anderem gehören. Die Mail geht höchstens einmal je Gerät und nur für ein in der letzten Stunde angelegtes Gerät, damit der Endpunkt nicht zum Fluten des Kontoinhabers taugt.
Die Grenze des Widerrufs, klar gesagt. Ein bereits ausgestelltes Zugriffstoken lebt etwa eine Stunde und lässt sich unterwegs nicht zurückrufen. Ein widerrufenes Gerät, das offline ist, funktioniert weiter, bis sein Token abläuft. Das liegt in der Natur von JWTs und ist kein Fehler — und es ist der Grund, warum es die Geräteliste gibt statt der Behauptung, ein Widerruf wirke sofort.
Was du mit deinem eigenen Konto tun kannst
Das Konto löschst du selbst, in den Einstellungen — niemandem eine Mail schreiben. Es ist eine erfasste und stornierbare *Anfrage*, kein Knopf, den du um zwei Uhr nachts drückst und nicht mehr zurücknehmen kannst. Ihr Stand ist in der App einsehbar. privacy@koratland.com bleibt für die übrigen PDPA-Rechte: Auskunft, Berichtigung, Übertragbarkeit und Widerruf der Einwilligung.
Melden umfasst Inhalte ebenso wie Personen — Profile, Beiträge, Kommentare, Storys, Nachrichten und Betriebsseiten. Die Funktion, die eine Meldung entgegennimmt, ist SECURITY INVOKER, als einzige im ganzen Backoffice. Als Definer *sähe* sie verborgene und private Beiträge und antwortete für sie anders — damit würde der Melde-Knopf zum Orakel über Inhalte, die man nicht sehen darf. Als Aufrufende ausgeführt fallen „diesen Beitrag gibt es nicht“ und „diesen Beitrag darfst du nicht sehen“ in eine Antwort zusammen — ehrlich, denn aus Sicht der meldenden Person ist das dasselbe. Das Limit steckt in einem Trigger, nicht in der App.
Was Korat nicht tut
Diese Liste ist der Grund, warum der Rest der Seite lesenswert ist. Alles hier trifft heute zu.
- Nichts ist Ende-zu-Ende-verschlüsselt. Nachrichten, Dateien und Anrufe sind unterwegs durch TLS und ruhend durch Row-Level-Security geschützt. Der Server kann sie lesen. Korat hat keine Geräteschlüssel und keine Schlüsselverwaltung und sollte für nichts gewählt werden, das beides braucht.
- Es gibt keine anwendungsseitige Verschlüsselung im Ruhezustand über das hinaus, was die verwaltete Datenbank mitbringt.
- Es wurde nie ein Ausweisdokument geprüft. Es gibt kein eKYC. Nirgends in Korat steht „Identität verifiziert“, und die Vertrauensabzeichen nennen stattdessen die vorhandenen Belege.
- Telefonnummern lassen sich nicht bestätigen — es ist kein SMS-Anbieter angebunden, und der Endpunkt sagt das, statt so zu tun als ob.
- Hochgeladene Medien sind über ihre URL öffentlich lesbar. Profilbilder, Beitragsbilder und Chatbilder liegen in öffentlichen Buckets; Schreiben braucht eine Sitzung, aber eine durchgesickerte URL ist eine abrufbare Datei. Signierte URLs gibt es noch nicht.
- Die Rechteprüfungen in der Betriebskonsole laufen derzeit auf dem Gerät, mit derselben Matrix serverseitig als
has_capability(). Behandle Konsolenrechte als organisatorische Maßnahme und nicht als Sicherheitsgrenze, bis dieser Rollout abgeschlossen ist. - Anrufe haben keinen TURN-Server und sind nicht Ende-zu-Ende-verschlüsselt; manche Verbindungen über Netzgrenzen hinweg kommen nicht zustande.
- Auf dem aktuellen Tarif gibt es keine Datenbanksicherungen und keine automatisierten Tests.
- Die Web-App hat keine Push-Benachrichtigungen. Push ist eine Funktion der Android-App; im Web siehst du eine Benachrichtigung beim Öffnen des Tabs, nicht wenn sie eintrifft.
Ändert sich etwas davon, ändert sich diese Seite mit. Es wäre einfacher, einen Absatz über Verschlüsselung auf Bankniveau zu schreiben und weiterzugehen; das hier ist nützlicher. Die Überlegungen hinter diesen Entscheidungen stehen auf der Seite Über uns.