Passwörter per Slack. API-Keys per E-Mail. SSH-Schlüssel als Teams-Nachricht. Wir alle wissen, dass das keine gute Idee ist — und machen es trotzdem. Weil es schnell geht. Weil die Alternative umständlich ist.

One-Time-Secret-Services lösen dieses Problem: Ein Geheimnis wird in einen Link verpackt, der genau einmal geöffnet werden kann. Danach ist es weg. Aber die meisten dieser Services haben ein fundamentales Problem: Der Server sieht den Klartext.

Das Threat Model

Bevor man Code schreibt, lohnt sich die Frage: Gegen wen schützen wir eigentlich?

Der Unfurl-Bot als Angreifer

Slack, Teams und Discord fetchen URLs automatisch, um eine Vorschau anzuzeigen. Ein naiver One-Time-Secret-Service, der das Geheimnis bei GET ausliefert, verliert es an den Bot, bevor der Empfänger es sieht.

Lösung: Die Reveal-Seite (GET /s/:id) zeigt nur einen Button. Das Secret wird erst bei POST /api/secrets/:id/consume ausgeliefert. Bots machen keine POST-Requests.

Der kompromittierte Server

Wenn der Server den Klartext kennt, ist ein Datenbankzugriff durch einen Angreifer gleichbedeutend mit dem Zugriff auf alle Secrets. Verschlüsselung auf dem Server hilft nicht — der Server hat ja den Schlüssel.

Lösung: Ende-zu-Ende-Verschlüsselung . Der Client verschlüsselt, der Server speichert nur Ciphertext, der Browser des Empfängers entschlüsselt. Der Server sieht nie den Schlüssel.

Race Conditions beim Consume

Zwei Requests treffen gleichzeitig ein. Ohne Atomarität bekommen beide den Ciphertext — das “One-Time” in One-Time Secret ist gebrochen.

Lösung: Atomares UPDATE ... RETURNING in SQLite. Nur der erste Request, der consumed_at von NULL auf einen Timestamp setzt, bekommt die Daten zurück.

Das E2E-Design

Schlüsseltransport über URL-Fragmente

Das URL-Fragment (alles nach #) wird laut HTTP-Spezifikation nie an den Server gesendet. Browser verarbeiten es rein clientseitig. Das macht es zum idealen Kanal für den Verschlüsselungsschlüssel:

https://once.mathis-adler.dev/s/dG9rZW4xMjM0NTY3OA#SchlüsselHierBase64url
                                  ├── ID (Server sieht) ─┤├── Key (nur Browser) ──┤

Der Server erhält die ID, um den Ciphertext zu finden. Den Schlüssel zum Entschlüsseln sieht er nie.

Der Crypto-Kern

Die Verschlüsselung nutzt AES-256-GCM — einen authentifizierten Verschlüsselungsmodus, der gleichzeitig Vertraulichkeit und Integrität garantiert:

  • 256-Bit Schlüssel — zufällig generiert, 2^256 mögliche Kombinationen
  • 96-Bit IV (Initialization Vector) — einmalig pro Verschlüsselung
  • 128-Bit Authentication Tag — stellt sicher, dass der Ciphertext nicht manipuliert wurde

Der verschlüsselte Output wird als IV (12 Bytes) || Ciphertext || Auth-Tag (16 Bytes) zusammengepackt und als Base64url kodiert. Die CLI verschlüsselt mit Node.js crypto, der Browser entschlüsselt mit der WebCrypto API .

POST statt GET

Warum ist der Consume-Endpoint ein POST und kein GET?

  1. Unfurl-Bots machen nur GET-Requests. Ein GET-basierter Consume würde das Secret an Slack & Co. verlieren.
  2. Browser Prefetch kann GET-Requests spekulativ auslösen. Ein POST wird nie prefetched.
  3. Semantik: Das Konsumieren eines Secrets ist eine zustandsverändernde Operation. POST ist das korrekte HTTP-Verb dafür.

Header-Hygiene

Selbst mit E2E-Verschlüsselung kann ein XSS-Angriff auf der Reveal-Seite den Schlüssel aus window.location.hash exfiltrieren. Die Content Security Policy verhindert das:

Content-Security-Policy: default-src 'self'; script-src 'self'; frame-ancestors 'none'
  • script-src 'self' — kein Inline-JavaScript, keine externen Scripts
  • frame-ancestors 'none' — die Seite kann nicht in einen iframe eingebettet werden
  • Cache-Control: no-store — kein Caching des Ciphertexts in Browser oder Proxies
  • Strict-Transport-Security — erzwingt HTTPS für 2 Jahre

Jeder einzelne Header schließt einen spezifischen Angriffsvektor. Keiner ist optional.

Fazit

Ein One-Time-Secret-Service, bei dem der Server den Klartext kennt, ist besser als Passwörter per Slack — aber er verlagert das Vertrauensproblem nur. Ende-zu-Ende-Verschlüsselung mit URL-Fragmenten als Schlüsseltransport löst dieses Problem elegant: Der Server wird zum reinen Ciphertext-Speicher, und selbst eine vollständige Kompromittierung offenbart keine Geheimnisse.

Die wichtigsten Designentscheidungen haben nichts mit Kryptografie zu tun: POST statt GET gegen Unfurl-Bots, atomares Consume gegen Race Conditions, strikte CSP gegen XSS. Sicherheit entsteht nicht durch eine einzelne Maßnahme, sondern durch die Summe durchdachter Entscheidungen.

Glossar

Ende-zu-Ende-Verschlüsselung
Ein Kommunikationsprinzip, bei dem nur Sender und Empfänger die Nachricht lesen können. Der Transportweg — einschließlich des Servers — sieht nur verschlüsselte Daten und hat keinen Zugriff auf den Schlüssel.
AES-256-GCM
Ein authentifizierter Verschlüsselungsmodus, der AES mit 256-Bit-Schlüssel und Galois/Counter Mode kombiniert. Bietet gleichzeitig Vertraulichkeit und Integritätsschutz — manipulierte Ciphertexte werden bei der Entschlüsselung erkannt.
URL-Fragment
Der Teil einer URL nach dem #-Zeichen. Wird laut HTTP-Spezifikation nie an den Server gesendet und nur clientseitig vom Browser verarbeitet. Eignet sich dadurch als Kanal für Daten, die der Server nicht sehen soll.
Base64url
Eine URL-sichere Variante der Base64-Kodierung, die + durch - und / durch _ ersetzt. Kann ohne URL-Encoding direkt in URLs verwendet werden.
WebCrypto API
Eine Browser-API für kryptografische Operationen wie Verschlüsselung, Hashing und Schlüsselgenerierung. Läuft nativ im Browser ohne externe Libraries und unterstützt unter anderem AES-GCM, RSA und ECDSA.
Atomare Operation
Eine Operation, die entweder vollständig oder gar nicht ausgeführt wird — ohne Zwischenzustände. In Datenbanken verhindert Atomarität, dass parallele Requests inkonsistente Ergebnisse erzeugen.
Content Security Policy
Ein HTTP-Header, der dem Browser vorschreibt, welche Ressourcen eine Seite laden darf. script-src 'self' erlaubt nur Scripts von der eigenen Domain und blockiert damit eingeschleusten Code (XSS).