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?
- Unfurl-Bots machen nur GET-Requests. Ein GET-basierter Consume würde das Secret an Slack & Co. verlieren.
- Browser Prefetch kann GET-Requests spekulativ auslösen. Ein POST wird nie prefetched.
- 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 Scriptsframe-ancestors 'none'— die Seite kann nicht in einen iframe eingebettet werdenCache-Control: no-store— kein Caching des Ciphertexts in Browser oder ProxiesStrict-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.