Firebase Auth mit eigenem PHP-Server: Token-Prüfung und Kontolöschung ohne Composer
Die folgenreichste Entscheidung beim Bau von Anılog war, Firebase nur für die Identität zu nutzen. Gruppen, Clips, Kommentare, Mediendateien — alles liegt auf meinem eigenen Hostinger-Server, auf reinem PHP und MySQL.
Diese Trennung hat einen Preis: der Server muss selbst prüfen, dass das vom Client gesendete Firebase-ID-Token wirklich von Google signiert wurde. Und da ich auf Shared Hosting kein Composer ausführen kann, gab es auch kein firebase/php-jwt.
Dieser Beitrag zeigt zwei Dinge vollständig:
- Ein Firebase-ID-Token in abhängigkeitsfreiem PHP prüfen
- Den von den Stores geforderten Kontolöschungs-Ablauf aufbauen — in der App und im Web
Teil 1: das Token prüfen
Ein Firebase-ID-Token ist ein mit RS256 signiertes JWT. Die Prüfung besteht aus drei Schritten:
- Die Signatur gegen Googles öffentlichen Schlüssel prüfen
- Prüfen, dass Empfänger (
aud) und Aussteller (iss) zum eigenen Projekt gehören - Prüfen, dass es nicht abgelaufen ist
PHPs openssl-Erweiterung deckt alle drei ab.
Googles Zertifikate holen
Google veröffentlicht die x509-Zertifikate zur Signaturprüfung unter einer festen Adresse:
const GOOGLE_CERT_URL =
'https://www.googleapis.com/robot/v1/metadata/x509/[email protected]';Sie rotieren regelmäßig, aber sie bei jeder Anfrage zu laden wäre absurd — das wäre ein ausgehender HTTP-Aufruf pro API-Zugriff. Die Antwort gehört auf die Platte zwischengespeichert, mit Respekt vor der Lebensdauer im Cache-Control-Header.
Die Signatur prüfen
Der entscheidende Teil sind diese Zeilen:
[$h, $p, $s] = explode('.', $jwt);
$header = json_decode(b64url_decode($h), true);
if (($header['alg'] ?? '') !== 'RS256') {
fail('Nicht unterstützter Signaturtyp.', 401, 'token_bozuk');
}
$certs = google_certs();
$pem = $certs[$header['kid']] ?? null;
if ($pem === null) fail('Unbekannter Schlüssel.', 401, 'token_bozuk');
$pub = openssl_pkey_get_public($pem);
$valid = openssl_verify("$h.$p", b64url_decode($s), $pub, OPENSSL_ALGO_SHA256);
if ($valid !== 1) fail('Signaturprüfung fehlgeschlagen.', 401, 'token_bozuk');Signiert wird der Text "$h.$p" — Header und Payload in base64url, mit dem Punkt dazwischen. Sie zu dekodieren und neu zu kodieren zerstört die Signatur; man muss die rohen Zeichenketten verwenden.
Die Prüfung von alg ist nicht optional. Ohne sie kann ein Angreifer alg: none senden und ein unsigniertes Token akzeptieren lassen — die bekannteste JWT-Schwachstelle überhaupt.
Die Claims prüfen
Eine gültige Signatur allein genügt nicht. Das Token könnte aus einem anderen Firebase-Projekt stammen:
$iss = 'https://securetoken.google.com/' . FIREBASE_PROJECT_ID;
if (($claims['iss'] ?? '') !== $iss) fail('Falscher Aussteller.', 401);
if (($claims['aud'] ?? '') !== FIREBASE_PROJECT_ID) fail('Falscher Empfänger.', 401);
if (($claims['exp'] ?? 0) < time()) fail('Abgelaufen.', 401);Die aud-Prüfung auszulassen heißt, dass ein Token aus irgendeinem Firebase-Projekt gegen die eigene API gültig ist. Ein häufiger Fehler.
Auf den eigenen Nutzer abbilden
Nach bestandener Prüfung steht die Firebase-UID in sub. In der eigenen users-Tabelle liegt eine Zeile mit dieser UID, angelegt bei der ersten Anmeldung.
Die Feinheit: nicht die E-Mail als Schlüssel nehmen. "Anmelden mit Apple" unterstützt "E-Mail verbergen", und sie kann sich später ändern. Der einzige stabile Anker ist die UID.
Teil 2: Kontolöschung
App Store und Play Store verlangen beide, dass eine App, in der man ein Konto anlegen kann, es auch löschen lässt. Play geht einen Schritt weiter: die Löschung muss ohne Installation der App über eine Webadresse möglich sein.
Man braucht also zwei getrennte Wege.
Weg 1: aus der App heraus
Profil → Konto löschen. Serverdaten und Firebase-Identität werden entfernt.
Die Reihenfolge ist entscheidend. In meiner ersten Fassung löschte ich erst die Serverdaten und versuchte dann, den Firebase-Nutzer zu löschen. Scheiterte dieser Aufruf mit requires-recent-login, blieb ein Zustand übrig: Daten weg, aber weiterhin angemeldet.
Die richtige Reihenfolge:
Future<void> hesabiSil() async {
// Zuerst die Anmeldung auffrischen: Löschen verlangt einen frischen Login.
await _yenidenDogrula();
await _api.delete('/me');
try {
await _auth.currentUser?.delete();
} finally {
// Auch wenn die Firebase-Löschung scheitert: abmelden. Die Serverdaten
// sind weg, niemand darf drinnen bleiben.
await cikisYap();
}
}Der finally-Block ist der Punkt: sind die Serverdaten gelöscht, geht die Person hinaus, egal was Firebase tut.
Weg 2: im Web, ohne App
Das ist die schwierigere Hälfte. Wie handelt eine Webseite sicher auf ein "lösche dieses Konto" von einer nicht angemeldeten Person?
Der Ablauf hat zwei Schritte:
- Die Person gibt ihre E-Mail ein. Existiert ein Konto, erzeugt der Server einen sechsstelligen Code und schickt ihn per E-Mail.
- Die Person gibt den Code ein. Stimmt er, erfolgt die Löschung sofort — keine manuelle Freigabeschlange.
Worauf die Serverseite achtet:
- Der Code wird gehasht gespeichert (HMAC-SHA256). Wer die Datenbank sieht, liest den Code nicht.
- Ob ein Konto existiert, wird nie verraten. Die Antwort ist identisch, bis hin zur angeglichenen Antwortzeit. Sonst wird das Formular zum Werkzeug, um registrierte Adressen abzufragen.
- Ratenbegrenzung pro E-Mail und pro IP.
- Die Löschung läuft unter einer
FOR UPDATE-Zeilensperre; zwei gleichzeitige Anfragen können nicht beide durchgehen.
Den Firebase-Nutzer vom Server aus löschen
Im Web-Ablauf gibt es keine Sitzung, ein clientseitiges currentUser.delete() fällt also aus. Man muss mit Firebase-Admin-Rechten vom Server löschen. Und wieder ohne Composer.
Die Kette hat drei Schritte:
Dienstkonto-Schlüssel (JSON)
→ ein mit RS256 signiertes JWT bauen
→ es bei oauth2.googleapis.com/token gegen ein Zugriffstoken tauschen
→ accounts:lookup / accounts:delete auf identitytoolkit.googleapis.com aufrufenDiesmal signiert man das JWT selbst, statt eines zu prüfen:
$jwt_govde = [
'iss' => $sa['client_email'],
'scope' => 'https://www.googleapis.com/auth/identitytoolkit',
'aud' => $sa['token_uri'] ?? 'https://oauth2.googleapis.com/token',
'iat' => time(),
'exp' => time() + 3600,
];Danach macht accounts:lookup aus der E-Mail eine UID, und accounts:delete entfernt sie.
Eine Warnung zum Dienstkonto-Schlüssel: diese JSON-Datei ist die volle Kontrolle über das Projekt. Sie gehört in ein Verzeichnis, das das Web nicht erreicht, mit einer .htaccess, die Require all denied enthält. Danach die Adresse der Datei im Browser öffnen und den 403 bestätigen. Ich habe das bestätigt; sonst hätte ich es nicht gewusst.
Was dieses Vorgehen kostet und bringt
Kosten: man schreibt, was ein SDK geschrieben hätte. Rund 200 Zeilen PHP — und ein Fehler in der Prüflogik ist eine Sicherheitslücke, kein Bug.
Nutzen: Medien und Daten auf dem eigenen Server, vorhersehbare Kosten, Herstellerbindung nur bei der Identität. Bei einer App, die Videos hostet, ist dieser Unterschied nicht klein.
Die Gesamtarchitektur von Anılog und den Grund für diese Entscheidung habe ich in einem eigenen Beitrag beschrieben.
Verwandte Artikel
Wie ich Anılog entwickelt habe: ein Entscheidungsprotokoll von der Idee bis zum Store
Die Architekturentscheidungen hinter Anılog, einer App für tägliche Clips in Freundesgruppen: warum die Identität bei Firebase liegt, die Daten aber auf meinem eigenen Server, warum ich die sozialen Funktionen gelöscht habe und welche Entscheidungen unumkehrbar waren.
Videos auf dem Handy mit ffmpeg zusammenfügen: gemischte Seitenverhältnisse in einem Vlog
Mal hochkant, mal quer, mal ein Foto. Wie Anılog diese Mischung auf dem Handy statt auf einem Server zu einem einzigen 1920x1080-Vlog macht: unscharfe Ränder, Texteinblendungen mit Sonderzeichen, Musik und eine tatsächlich gemessene Kompression.
Flutter-Kamera ohne erzwungene Ausrichtung: drei Fehler und ihre Lösungen
Eine gedrehte Vorschau, ein Einfrieren beim Aufnahmestart und eine tote Kamera nach dem Öffnen der Mitteilungszentrale. Die drei konkreten Fehler beim Bau des Kamerabildschirms von Anılog in Flutter — und der Code, der sie behoben hat.