URL-Token-Portal-Auth: Login durch einen Hash-URL-Bearer ersetzen
Wir haben Login-Formular und JWT-Session eines externen Kundenportals durch ein Hash-URL-Bearer-Token ersetzt — kein Passwort, kein Cookie, keine Session. Der Refactor war klein; die vier Dinge, über die er uns danach nachdenken liess, nicht. Das ist, was uns das Ausrollen eines passwortlosen Portals in ein produktives Recruiting-Produkt über Rate-Limit-Pooling, Rotate-auf-abgelaufenen-Links, Check-then-Insert-Races und Proxy-Vertrauen gelehrt hat.
URL-Token-Portal-Auth: Login durch einen Hash-URL-Bearer ersetzen
Die verlockendste Sorte von Refactor ist die, die ein Subsystem löscht. Sechs Monate lang hatte unser externes Kundenportal ein Login-Formular, eine Passwort-Spalte, eine JWT-Session, einen Rotate-Version-Zähler und einen dedizierten Brute-Force-Limiter auf der Login-Route. Es hatte auch eine Support-Ticket-Rate, die grösstenteils aus "Ich finde mein Passwort nicht" und "Der Einladungslink fragt mich nach nichts" bestand. Wir haben es umgeschrieben auf ein einzelnes sha256-gehashtes Bearer-Token, das in der URL lebt. Kein Passwort. Kein Cookie. Keine Session. Kein JWT. Das ist die Aufzeichnung des URL-Token-Portal-Auth-Rewrites, der letzte Woche ausgeliefert wurde — und, nützlicher noch, der vier Produktions-Gotchas, die sich zeigten, nachdem der "einfache" Teil erledigt war.
Der Rewrite lebt im AC-Center von Wield, unserem Recruiting-Intelligence-Produkt (Repo-Name intern cvflow). Das AC-Center ist der Ort, an dem Kunden strukturierte Assessment-Tage durchführen — Firmen buchen sie, Kandidat:innen kommen hin, ein Assessor bewertet Übungen, und sowohl Firma als auch Kandidat:in sehen später einen Report über ein Portal, das ausserhalb der Hauptanwendung lebt. Zwei Zielgruppen, beide ausserhalb Ihres Identity-Providers, beide brauchen zeitbegrenzten Zugriff auf ein konkretes Artefakt. Klassisches Magic-Link-Terrain. Der Fehler, den wir beim ersten Mal machten, war, "Portal" und "User" als dasselbe Problem zu behandeln.
Der Login, den wir entfernt haben
Das Original-Portal ging im Juli in der Form live, die man erwarten würde. ac_portal_credential speicherte einen Username, ein bcrypt-gehashtes Passwort und eine Integer-token_version. POST /portal/login nahm diese Credentials entgegen, stellte ein JWT mit eingebackener Version aus, und jeder folgende Request lief durch eine FastAPI-Dependency, die das JWT validierte, die aktuelle Version nachschlug und den Request ablehnte, wenn beide nicht übereinstimmten — der Standardweg, "diesen User zurückrufen" ohne Session-Store zum Laufen zu bringen. Rate-Limits sassen auf dem Login-Endpoint mit einem Bucket pro IP gegen Passwort-Brute-Force und auf jedem anderen Endpoint mit einem zweiten Bucket gegen authentifizierten Missbrauch. Zwei kleine SQLAlchemy-Modelle, ein Auth-Modul, eine JWT-Config, ein bcrypt-Work-Factor zum Justieren und eine Tabelle voller token_version-Spalten, weil das der Preis stateless Sessions ist.
Nichts daran war falsch. Es machte nur eine Menge Arbeit für ein Feature, dessen ganzer Zweck der Ein-Klick-Zugriff auf einen Report war. Jede Kundin, die es ausprobierte, lief gegen dieselbe Wand: die Einladungs-E-Mail kam mit einem Link, der Link führte zu einem Login-Formular, das Login-Formular wollte ein Passwort, das sie sich nicht erinnerte gesetzt zu haben, sie klickte "Passwort vergessen", eine weitere E-Mail kam mit — einem Link. Wir hatten einen Workflow gebaut, der einen Link zum Einloggen versendete, damit die Nutzerin einen Link erhalten konnte, damit die Nutzerin einen Link öffnen konnte. Zweimal.
Das Hash-URL-Bearer-Design
Das Muster, das es ersetzt hat, ist absichtlich langweilig. Eine Tabelle pro Zielgruppe — ac_company_access für die einstellende Firma, ac_candidate_access für die Kandidat:in — speichert nur den sha256-Hash eines secrets.token_hex(32)-Strings, dazu die Ressource, auf die sie Zugriff gewährt, und ein optionales expires_at. Das rohe Token berührt die Datenbank nie und verlässt die URL nie. Jeder Request kommt als /portal/company/{token}/… oder /portal/candidate/{token}/…, eine FastAPI-Dependency hasht den Pfad-Parameter, schlägt die Zeile nach, prüft Ablauf und reicht den aufgelösten Principal an die Route weiter. Rotation überschreibt token_hash in place. Widerruf löscht die Zeile. Es gibt keine Session, kein Cookie, keine token_version, keinen Login-Endpoint.
Diese Löschung hat eine Eigenschaft, die man explizit ausbuchstabieren muss: die URL ist die Credential. Leitet ein Kunde die E-Mail weiter, ist die Person am anderen Ende so lange authentifiziert wie sie, bis das Token rotiert oder abläuft. Das ist ein echter Trade — Magic-Link- und Zahlungsbeleg-Links haben denselben, und die Industrie ist bei "ja, das ist okay, mit vernünftiger Ablauffrist und einem Rotate-Button" gelandet. Für ein Report-Portal ist das ein absolut fairer Deal. Für Ihre Bank offensichtlich nicht. Den Trade in der Design-Phase explizit zu machen, hält das Review-Gespräch kurz.
Was wir dafür bekommen, ist ein ernsthafter Rückgang an Angriffsfläche. Das Auth-Modul verliert seine JWT-Config, seinen bcrypt-Handler und den token_version-Abgleich-Zweig. Zwei Rate-Limit-Buckets kollabieren zu einem — es gibt keinen separaten Login-Endpoint zu drosseln, also bekommt das gesamte Portal einen einzigen gepoolten Bucket mit 60/Minute pro IP über alle Routen hinweg (in unserem Fall 12 Endpoints). Retention wird simpler: den Zugriff einer Firma zu purgen ist ein DELETE, kein JWT-Invalidierungstanz. Die Testsuite ist kleiner geworden und deckt strikt mehr Fälle ab. Und der Failure Mode "Kunde findet den Link nicht" hat keinen zwischengeschalteten Passwort-Schritt mehr, der ihn verschärft.
Vier Dinge, die erst nach dem Refactor auftauchen
Der Rewrite selbst ist ein Wochenende. Die vier Dinge, die in der Woche danach auftauchten, sind der eigentliche Bericht. Jedes davon war im Staging still und wurde erst unter Produktions-Shape sichtbar.
1. Rate-Limit-Pooling und die "Route", die man zu zählen glaubte. Mit einem Login-Endpoint kann man Limits pro Route keyen — 5 Login-Versuche pro IP pro Minute ist eindeutig. Ohne einen teilen alle Portal-Routen denselben Origin — derselbe Browser derselben Kundin ballert parallel jeden Endpoint auf der Report-Seite an — und alle in einen einzigen Per-IP-Bucket zu poolen ist das, was man will. Aber Pooling ist nicht automatisch; der @limiter.limit(...)-Decorator von slowapi keyt per Default pro Route. Wir haben auf ein einziges benanntes Limit — portal_default_limit — konsolidiert und es innerhalb der require_company/require_candidate-Dependency angesetzt statt auf den Routen selbst, damit derselbe 60/min-Bucket belastet wird, egal ob der Request /notes oder /report trifft. Der Test, der das fängt — und es hat gedauert, bis wir wussten, wie man ihn schreibt — feuert 61 echte Requests, verteilt über zwei unterschiedliche Routen, gegen ein auflösbares Token und prüft 429 mit Retry-After-Header. Die Verteilung über zwei Routen ist das tragende Detail: feuert man alle auf eine Route, besteht der Test auch bei Pro-Route-Keying, und man würde nie merken, dass Pooling still kaputt ist.
Eine slowapi-Subtilität ist erwähnenswert: sein Decorator sitzt auf dem Funktionsrumpf, nicht auf Middleware. Wenn Ihre Dependency für ein nicht auflösbares Token einen 404 zurückgibt, bevor der Rate-Limit-Check je läuft, kann eine Angreiferin beliebig CPU verbrennen, indem sie den Token-Raum probt, ohne das Limit zu treffen. Wir haben absichtlich mit einem echten Token getestet — ein Garbage-Token würde zuerst 404 werfen und über den Limiter nichts beweisen.
2. Rotate auf einem bereits abgelaufenen Zugang. Ein Rotate-Button ist die Last-Mile-UX für URL-Tokens — die Kundin hat den Link, der Link ist alt, neuen generieren, fertig. Die einzeilige Implementierung ist token_hash = new_hash; commit. Diese eine Zeile ist ein Bug. Wenn das expires_at der Zeile bereits in der Vergangenheit liegt, ist das frisch rotierte Token bei Ankunft tot: der nächste Request durch die Dependency sieht eine abgelaufene Zeile und liefert 410, und der zweite Link der Kundin ist so nutzlos wie der erste. Der Fix ist, expires_at innerhalb von Rotate auf NULL zu setzen, wenn der aktuelle Wert in der Vergangenheit liegt, und ein zukünftiges Ablaufdatum unangetastet zu lassen. extend_expiry bleibt der einzige Pfad, der bewusst eine neue Frist setzt. Das ist die Art Korrektheitslücke, die Unit-Tests nicht fangen, bis man die zwei Scopes — "rotate ein abgelaufenes Token" und "rotate ein nächste-Woche ablaufendes Token" — als getrennte Fälle schreibt und expires_at in beiden prüft.
3. Check-then-Insert ist nicht atomar. Eine Access-Zeile zu erstellen beginnt mit "gibt es schon einen Zugriff für diesen Auftrag/diese Kandidat:in? wenn ja, 409, sonst insert." Unter jeglicher Nebenläufigkeit — die Admin klickt "Link ausstellen" zweimal, oder ein Webhook und eine UI-Aktion racen — können zwei Requests beide am SELECT vorbeikommen, bevor eines der INSERT gelaufen ist. Der Unique-Constraint fängt den zweiten, aber als rohen IntegrityError, der als 500 an die Oberfläche kommt. Das explizit zu behandeln heisst, den Insert in try / except IntegrityError zu wickeln, ein Rollback zu machen und denselben ServiceError zurückzugeben, den der Pre-Check geliefert hätte — 409 already exists. Der Race selbst ist nicht deterministisch testbar (das ist gerade das Wesen von Check-then-Insert-Races), aber der Handler-Pfad ist es, und der Handler-Pfad ist der Unterschied zwischen "die zweite Admin sieht eine hilfreiche Fehlermeldung" und "die zweite Admin sieht einen Stacktrace." Günstig, und wert, in das Muster aufzunehmen, sobald man merkt, dass man Uniqueness oberhalb eines Unique-Constraints vor-prüft.
4. Rate-Limit-Keying hängt von Ihrer Proxy-Topologie ab. Per-IP-Limits funktionieren nur, wenn man die Client-IP tatsächlich kennt. Hinter einem Reverse Proxy lebt diese IP im X-Forwarded-For, aber nur in den Einträgen, die von vertrauenswürdigen Hops angehängt wurden. Uvicorns ProxyHeadersMiddleware löst sie via --forwarded-allow-ips auf, in unserem Stack auf RFC1918-Bereiche gescoped — sicher unter der Annahme, dass Coolifys Traefik der einzige Hop vor dem Container ist. Ein CDN oder eine WAF davor gestellt, und diese Annahme bricht still: der neue Hop steht nicht auf der Allowlist, Uvicorn hört auf, der Kette zu vertrauen, request.client.host fällt auf die Adresse des nächstgelegenen Proxys zurück, und jeder Portal-Client landet in einem gemeinsamen Bucket. Kein 5xx, keine Logzeile, nur ein Limit, das ein einzelner viel-beschäftigter Nutzer für alle anderen aufbrauchen kann. Wir haben das als Runbook neben dem Code aufgeschrieben statt als Inline-Kommentar, weil der Failure Mode eine Deploy-Zeit-Entscheidung ist, keine Code-Zeit-Entscheidung. Mozillas X-Forwarded-For-Guidance ist die Referenz, zu der wir immer wieder zurückkehren.
Wann Sie zu URL-Token-Portal-Auth greifen sollten
Drei Vorbedingungen machen dieses Muster zu einer guten Wahl. Die Zielgruppe liegt ausserhalb Ihres Identity-Providers. Der Zugriff ist auf ein konkretes Artefakt begrenzt statt auf "alles in der App". Und der Wert eines Ein-Klick-Zugriffs überwiegt den Preis, dass die URL die Credential ist. Eine Kandidat:in, die ihren Assessment-Report ansieht, erfüllt alle drei. Ein Finance-User, der eine Zahlung freigibt, nicht — der URL-als-Credential-Trade ist für diese Form falsch, und ein voller Auth-Flow mit SSO ist sein Gewicht wert.
Zwei kleinere Regeln komponieren gut mit dem Muster. secrets.token_hex(32) als sha256 speichern, in der Disziplin von OWASPs Password-Storage-Cheatsheet auf Tokens angewendet, damit ein Datenbank-Dump keine lebenden Credentials verteilt. Und Rotation und Ablauf als getrennte Operationen halten — sie zu einer Aktion zu kollabieren führt entweder zu unnötigem Rotieren, sobald eine Admin das Datum anpasst, oder zu vergessenem Rotieren am Tag, an dem jemand einen Link geleakt hat.
Der Refactor reimt sich auf das Muster, das wir vor zwei Wochen im Debounced-Autosave-Race-Finalize-Gate beschrieben haben, und auf der Rate-Limit-Seite auf den FastAPI-slowapi-Rate-Limit-Headers-Post — jeder davon ist ein Fall, in dem das Löschen eines beweglichen Teils der Fix ist, aber erst nachdem man die Koordination durchdacht hat, die früher darin versteckt war. Der Code wurde kleiner. Die Anzahl der Dinge, in denen man Recht haben muss, wurde es nicht, sie sind nur umgezogen.
Wenn Sie ein externes Portal ausliefern und in derselben Login-Formular-das-eine-E-Mail-mit-einem-Link-schickt-die-zu-einem-Login-Formular-führt-Schleife feststecken, buchen Sie einen kostenlosen AI Potenzial-Check — oder lesen Sie den Next.js-Worker-Split-mit-pg_notify-Bericht für die Durability-Hälfte desselben "beweglichen Teil löschen"-Themas.
wield · Die Recruiting‑Pipeline, die mit deinem Volumen mitskaliert.
CV‑Pipeline mit KI‑gestützter Dossier‑Generierung und Bewertung. Für Recruiter, die hundert Bewerbungen in einer Stunde sortieren — ohne Qualität zu verlieren.