Die Vier-Minuten-Approval
Im vorigen Beitrag haben wir geschrieben: „Die Kryptographie hat die Arbeit getan, die SSO + 2FA früher tat.” Mehrere Ingenieure haben gefragt, was das tatsächlich im Code bedeutet. Dieser Beitrag ist die Antwort.
Der Ausgangspunkt war eine messbare Reibung in MSP-Kunden-Workflows. Die KI schlägt einen abrechenbaren ConnectWise Time Entry vor. Eine Managerin muss abnehmen, bevor er gepostet wird. Der Legacy-Pfad war:
- Slack-Benachrichtigung: „Approval erforderlich”
- Link klicken → SSO-Redirect → Passwort → 2FA → Approval-Seite
- Eintrag lesen → Approve klicken
Median-Zeit bis zur ersten Aktion: 4 Minuten. Mal 30 Einträge/Tag × 4 Techniker in einem typischen 10-Sitz-MSP, das sind 8 Stunden pro Tag Manager-Aufmerksamkeit, um zu tun, was funktional ein Haufen Daumen-hoch ist.
Die vier Minuten waren nicht, weil jemand langsam war. Sie waren, weil der Pfad zur Approval eine vollständige Identitäts-Zeremonie für etwas verlangte, das informationstheoretisch ein einzelnes Bit (approve/reject) plus optionalen Begründungstext war.
Wir haben das hierdurch ersetzt: die E-Mail der Managerin enthält Approve- und Reject-Buttons. Ein Klick. Die Aktion läuft, das Audit-Log zeichnet auf, der Time Entry wird zu ConnectWise gepostet. Kein Login. Kein 2FA.
Es besteht das SOC-2-Fragebogen. So:
Die vier Eigenschaften, die es auditor-äquivalent machen
Der Link in jedem Button ist keine URL mit ?approval_id=42. Es ist ein JWT (JSON Web Token), signiert mit dem Privatschlüssel unseres Servers, mit vier Eigenschaften:
- Signiert. Die Authentizität des Tokens ist kryptographisch verifizierbar. Wir vertrauen nicht der URL-Leiste; wir vertrauen der Signatur.
- Zeitbegrenzt. Jeder Token läuft ab (
exp-Claim), typischerweise 72 Stunden nach Ausstellung. Danach tut der Klick nichts mehr. - Einmalig nutzbar. Ein Token kann genau einmal eingelöst werden. Der zweite Klick auf denselben Approve-Button trifft einen Idempotency-Cache und liefert „bereits genehmigt von Ihnen am [Zeitstempel]” zurück.
- Idempotent. Was das Netzwerk auch macht — Doppelklicks, Retries, Browser-Prefetch — die zugrundeliegende Aktion läuft höchstens einmal. Gleicher Effekt jedes Mal.
Die erste Eigenschaft erlaubt es uns, SSO zu überspringen. Die anderen drei erlauben es uns, die „Aber was wenn…”-Fragen des Audit-Teams zu überspringen.
Das Wire-Format
Der Token-Payload (nach jwt.decode, aber vor Signaturverifikation) sieht so aus:
{
"iss": "jiegou.ai",
"aud": "approval.action",
"iat": 1714502400,
"exp": 1714761600,
"jti": "01HZ8B3X9NQK5W2T7P0V4M6Y8R",
"approval_id": "appr_2bF7kQpL9xR3",
"approver_email": "alex@example-msp.com",
"action": "approve",
"account_id": "acc_4nM8tYz2",
"scope": "time_entry"
}
Drei Dinge zu diesem Payload:
- Der Token bindet die Aktion an einen spezifischen Approver. Selbst wenn die E-Mail weitergeleitet wird, kann der Empfänger sie nicht als sich selbst einlösen — das Audit-Log zeichnet weiterhin
alex@example-msp.comals Approver auf, nicht den Weiterleitenden. (Mehr zur Forwarded-E-Mail-Bedrohung unten.) - Die
jtiist eine ULID — pro Token einzigartig, in unserem Redemption-Cache als Idempotency-Key verwendet. - Das
scope-Feld sagt dem Server, an welchen Redemption-Handler dispatcht wird.time_entrygeht einen anderen Code-Pfad alscontent_publishoderoutbound_message. Gleiches JWT-Pattern, andere Scope-Handler.
Validierungs-Reihenfolge ist wichtig
Wenn der Klick den Server erreicht, validieren wir in genau dieser Reihenfolge. Wenn ein Schritt fehlschlägt, halten wir an:
- Signatur verifizieren. Das ist das Erste — vor dem Parsen, vor dem Vertrauen irgendeines Felds. Wenn die Signatur ungültig ist, bekommt die Anfrage ein generisches
400 Bad Requestohne Details. Wir leaken nicht, welches Feld fehlgeschlagen ist. issundaudprüfen. Falscher Issuer oder Audience → ablehnen. Das fängt Tokens ab, die von anderen Diensten geprägt oder über Systeme hinweg replayt wurden.expprüfen. Abgelaufen → eine „Dieser Approval-Link ist abgelaufen, bitte einen neuen anfordern”-Seite rendern.jtigegen den Redemption-Cache prüfen. Bereits eingelöst → eine „bereits genehmigt von [Approver] am [Zeit]“-Idempotency-Antwort rendern.- Den Approval-Datensatz nachschlagen. Wenn die
approval_idnicht existiert oder bereits über einen anderen Pfad aufgelöst wurde (z. B. in der Konsole genehmigt), „Diese Approval wurde bereits aufgelöst” rendern. approver_emailmit der Liste erlaubter Approver der Approval abgleichen. Wennalex@...nicht auf der Approver-Liste der Policy für diese Approval steht, mit403ablehnen.- Die Aktion ausführen. Atomar: den Redemption-Datensatz schreiben (
jti→ eingelöst-am), denapproval_event-Audit-Eintrag emittieren, den Post-Approval-Workflow dispatchen (z. B. Time Entry zu ConnectWise posten). - Erfolg rendern. Eine Bestätigungsseite zeigen; keine Tokens neu ausstellen; nichts in der URL-Leiste speichern, was neu geteilt werden könnte.
Die schlüssel-nicht-offensichtliche Wahl ist Schritt 1. Verifiziere vor Parsen. Manche Implementierungen machen zuerst jwt.decode, um die kid (Key ID) für Signaturverifikation zu extrahieren, aber das bedeutet, du hast bereits unsignierten Daten vertraut. Verwende eine JWT-Library, die in einem Aufruf verifiziert und fehlerhafte Tokens ablehnt, bevor sie die Claims exposiert.
Das Threat-Model — vier Dinge, was sie den Angreifer kosten
Wir haben uns mit den vier plausibelsten Angriffen hingesetzt und gefragt: wie geht unser Design mit jedem um?
1. Die E-Mail wird an jemanden außerhalb der Firma weitergeleitet
Was passiert: Jemand anderes als alex@... klickt auf den Button.
Kosten für Angreifer: null — sie haben den Token im Klartext.
Mitigation: Der Token bindet die Aktion an alex@...’s Identität im Audit-Log. Der Weiterleitende kann sie nicht als sich selbst einlösen. Die Aktion wird laufen, aber sie läuft als Alex. Die nächste Governance-Review zeigt „Alex hat das von IP 203.0.113.7 genehmigt (was nicht Alex’ Büro ist)” — und Alex erfährt es sofort beim nächsten Login.
Das ist das, was wir kryptographisch nicht vollständig verhindern. Wir verhindern es operativ: Anomaly-Detection auf Approver-IP/UA, wöchentliche Governance-Zusammenfassung, und eine 3-Sekunden-Bestätigungsseite, die sagt „Sie sind dabei, als alex@example-msp.com zu genehmigen — fortfahren?” Forward-Betrug ist mit E-Mail-Links erkennbar, nicht verhinderbar. Wir dokumentieren das im Kunden-SOC-2-Fragebogen klar.
2. Der Token leakt in irgendeinem Server-Log
Was passiert: Ein Token taucht in unserem nginx-Access-Log auf, oder in einem 3rd-Party-Webhook-Log, oder einem verlorenen console.log.
Kosten für Angreifer: Hängt von der Frische ab.
Mitigation: drei Schichten.
- Kurze
exp. 72-Stunden-Ablauf bedeutet, ein geleakter Token ist innerhalb von 3 Tagen tot. Wir stellen keine 30-Tage-Tokens aus. - Einmalig-Nutzung-Redemption. Wenn der legitime Benutzer zuerst klickt, ist der Token tot. Der Redemption-Cache-Lookup passiert vor jedem Side-Effect.
- Logging-Hygiene. Aktion-Link-URLs werden in unseren Access-Logs und SIEM-Ingestion pattern-gematcht und redigiert. Tokens kommen nie in dauerhaften Storage außerhalb des JWTs selbst.
3. Replay — derselbe Token zweimal geklickt
Was passiert: Network-Retries, Doppelklicks, Browser-Prefetch oder ein böswilliger Replay.
Kosten für Angreifer: null (legitime Replays sind häufig).
Mitigation: der Redemption-Cache-Check in Schritt 4 der Validierung ist load-bearing. Der Cache ist Redis, gekeyed auf jti, mit TTL länger als exp. Die erste Redemption setzt den Key atomar (SETNX); jeder nachfolgende Klick liest den existierenden Eintrag und gibt die idempotente „bereits genehmigt”-Antwort zurück, einschließlich wer genehmigt hat und wann.
Idempotenz ist nicht nur eine UX-Annehmlichkeit — sie ist die Eigenschaft, die das System unter Retry sicher hält, ohne State zu leaken.
4. Unser Signing-Key wird kompromittiert
Was passiert: Das HMAC-Secret oder der RSA-Privatschlüssel leakt.
Kosten für Angreifer: Sie können beliebige Tokens für jede Approval prägen.
Mitigation: vier Schichten.
- Key-Isolation. Der Signing-Key lebt im KMS, nicht im Application-Memory. Application-Server rufen das KMS zum Signieren und Verifizieren auf; sie besitzen nie das rohe Schlüsselmaterial.
- Key-Rotation. Schlüssel rotieren nach veröffentlichtem Plan; alte Schlüssel werden nur in einem Grace-Window gleich der längsten ausgestellten
expzur Verifikation akzeptiert. - Per-Approver-scoped Tokens. Selbst mit Key-Compromise produziert ein Angreifer, der einen Token für
alex@...prägt und klickt, einen Audit-Eintrag unter Alex’ Identität, was (per Threat 1) erkennbar ist. - Compromise-Response. Wenn wir Key-Compromise erkennen, widerrufen wir den Schlüssel und invalidieren jeden ausstehenden Token. Kunden sehen „Dieser Approval-Link wurde aus Sicherheitsgründen ungültig gemacht; bitte verwenden Sie die Konsole zur Auflösung”, bis Neu-Ausstellung.
Das ist die Bedrohung, die am schwersten vollständig zu mitigieren ist, und deshalb hat das System einen Fallback: die In-Console-Approval-Queue funktioniert immer. E-Mail-Button-Approvals sind eine Ergänzung, kein Ersatz.
Der ehrliche Tradeoff vs. SSO + 2FA
E-Mail-Button-Approvals sind nicht streng äquivalent zu SSO + 2FA. Der Tradeoff:
| Eigenschaft | SSO + 2FA | Signierter Aktion-Link |
|---|---|---|
| Authentifiziert Identität | Ja (Faktor + Faktor) | Nein — authentifiziert eine einzelne Entscheidung, gebunden an eine Identität |
| Resistent gegen E-Mail-Forwarding | Ja (weitergeleiteter Benutzer kann 2FA nicht passieren) | Erkennbar, aber nicht verhinderbar |
| Resistent gegen Key-Compromise | Unabhängig von unserer Infra (federierter IdP) | Hängt von unserer KMS-Hygiene ab |
| Friction pro Approval | 4 Minuten | unter 5 Sekunden |
| Audit-Trail-Äquivalent | Ja | Ja (gleiche approval_event-Form) |
Wir sind explizit mit Kunden, welchen Tradeoff sie wählen. Die empfohlene Konfiguration:
- Geringes Risiko, hohes Volumen-Approvals (Time Entries, Content-Drafts, Calendar-Holds): standardmäßig E-Mail-Button
- Hohes-Risiko-Approvals (Finanztransaktionen über einer Schwelle, extern sichtbare Commitments, compliance-affecting Actions): Konsole + SSO erforderlich; E-Mail-Button wird zu „Reminder” allein
- Per-Account-Override: jeder Kunde kann die Schwelle setzen, welche Aktionen E-Mail-Button vs. Konsole-allein bekommen
Die Schwelle ist ein Config-Wert. Die Kryptographie ist gleich.
Audit-Log-Äquivalenz
Jede signierte-Aktion-Link-Redemption emittiert dieselbe approval_event-Form wie eine Konsole-Approval. Das Entry-Point-Feld liest email_button; der Rest ist identisch:
{
"event_type": "approval.resolved",
"approval_id": "appr_2bF7kQpL9xR3",
"approver_email": "alex@example-msp.com",
"decision": "approve",
"decision_at": "2026-04-29T14:32:18Z",
"entry_point": "email_button",
"client_ip": "203.0.113.42",
"user_agent": "Mozilla/5.0 …",
"jti": "01HZ8B3X9NQK5W2T7P0V4M6Y8R"
}
Das entry_point ist das Einzige, was der Auditor wissen muss, dass es aus E-Mail kam. Alles andere — wer, wann, was, IP — passt zu Konsole-Approval-Evidenz. Unser SOC-2-Evidence-Export-Format behandelt sie identisch. Der Auditor liest ein Schema.
Fünf andere Workflows, die zu diesem Pattern passen
Sobald das JWT-Redemption-Cache-Pattern existierte, fanden wir, dass es zu vielen Single-Decision-Workflows passt:
- Calendar-Hold-Bestätigungen — die KI schlägt vor „Mittwoch 14 Uhr 30 Minuten für Q3-Review-Vorbereitung blocken”; der Operator bestätigt oder verschiebt
- Content-Publish-Hold-and-Release — Drafts, die Auto-Checks bestehen, aber für Sender-Bestätigung markiert sind
- Outbound-Message-Holds — die KI draftet eine Kunden-Antwort; der Operator gibt sie aus einer gehaltenen Queue frei
- Sensitive-Integration-Unlocks — die KI fordert Erlaubnis an, ein spezifisches Tool zu verwenden, das hinter einem Per-Use-Approval-Gate steht
- Einmalige-Consent-Flows — kundenseitige Einwilligung für Aktionen, die deren Daten betreffen
Jedes davon erforderte zuvor einen Konsolen-Besuch. Jedes hat jetzt ein E-Mail-Button-Äquivalent. Die Kryptographie ist die Credential. Der Operator-Tag schrumpft entsprechend.
Was wir einem anderen Team beim Bauen davon sagen würden
Wenn du etwas Ähnliches baust, drei Dinge zum Lehnen-In und drei zum Vermeiden:
Lehnen-In:
- JWT-Signaturverifikation vor jedem Field-Parsing. Verwende eine Library, die das in einem Aufruf macht.
- Redemption-Cache-Idempotenz gekeyed auf
jti. Das ist, was retry-safe macht. - Gleiche Audit-Event-Form unabhängig vom Entry-Point. Auditoren sollten nicht interessieren, von welchem Pfad der Klick kam.
Vermeiden:
- Lange
exp, um „nett zu sein”. Ein 30-Tage-Approval-Link ist eine 30-Tage-Angriffsfläche. 72 Stunden sind reichlich für menschliche Approver. - Den Token in einem URL-Fragment speichern, in der Hoffnung, Browser loggen ihn nicht. Sie tun. Behandle URLs als logs-eventually.
- Stille Fehler-Modi. Wenn Verifikation fehlschlägt, render eine klare Error-Seite. Der legitime Approver hat möglicherweise einen alten Token, weil er die E-Mail eine Woche ignoriert hat.
Der Slogan aus dem vorigen Beitrag war: Trust ist nicht schwächer geworden, sie hat sich auf eine Schicht mit weniger Friction relocated. Signierte Aktion-Links sind das wörtliche Beispiel davon. Trust hat sich von SSO + 2FA + Passwort-Manager + Browser-Session zu gut verwaltetem Signing-Key + kurzer-exp-JWT + idempotentem Redemption-Cache bewegt. Friction ging von 4 Minuten auf unter 5 Sekunden. Das Audit-Trail bekam mehr Felder, nicht weniger.
Kostenlos starten → — oder wenn du einen MSP betreibst und das Vier-Minuten-Approval-Problem oben vertraut klingt, buche eine 30-minütige Walkthrough und wir richten deinen Account am ersten Tag mit E-Mail-Button-Approvals ein.