← Zurück zum Blog
jwtauthsecuritybrowsersessions23. Juni 2026 · 12 Min. Lesezeit

Schau in deinen Browser: welche Tokens dort wirklich liegen

LOCAL STORAGE · EXPIRED / LIVE · HttpOnly: SEALED
Editorial-Illustration: eine Schublade voller kleiner Token-Karten (mit 'eyJ…' beschriftet), die meisten verblasst (abgelaufen), wenige kupfern leuchtend (live), darüber eine Lupe; rechts ein versiegeltes, verschlossenes Fach mit 'HttpOnly / SEALED'; Beschriftung 'LOCAL STORAGE'.Editorial-Illustration: eine Schublade voller kleiner Token-Karten (mit 'eyJ…' beschriftet), die meisten verblasst (abgelaufen), wenige kupfern leuchtend (live), darüber eine Lupe; rechts ein versiegeltes, verschlossenes Fach mit 'HttpOnly / SEALED'; Beschriftung 'LOCAL STORAGE'.

Im ersten Teil ging es um die Theorie: Ein JWT ist ein Format, kein Login-System, und sein größtes Problem als Browser-Session ist, dass man es nicht widerrufen kann. Es gilt, bis es abläuft, egal was in der Zwischenzeit passiert. Daraus folgt eine unbequeme Eigenschaft, die in der Praxis gern untergeht: Neu einloggen prägt neue Tokens, es killt die alten nicht. Die alte Kopie bleibt gültig und liegt irgendwo weiter herum, bis ihr Ablaufdatum erreicht ist.

Das klingt nach Theorie, bis man sich anschaut, was damit in der echten Welt passiert. Sicherheitsforscher von Group-IB fanden gespeicherte ChatGPT-Zugangsdaten auf über 100.000 Geräten, eingesammelt aus den Logs von Infostealer-Malware, ganz überwiegend durch eine Stealer-Familie namens Raccoon. Wichtig an dieser Geschichte ist, was sie nicht war: kein Einbruch bei OpenAI, kein Datenleck auf deren Servern, keine umgangene Firewall. Es war Schad-Software, die schlicht die Browser der betroffenen Leute auslas und mitnahm, was dort an Tokens und gespeicherten Logins lag. Die Server blieben unberührt, das schwächste Glied saß auf den Geräten der Nutzer, und niemand musste dafür eine fremde Infrastruktur knacken.

Das verschiebt die Frage. Es geht nicht darum, ob ein Anbieter sicher genug ist. Solange das Token im Browser des Nutzers liegt, hilft die beste Server-Sicherheit nichts gegen etwas, das auf dem Gerät selbst sitzt. Die ehrliche Frage ist also eine andere: Was liegt eigentlich in meinem eigenen Browser, und wer kommt da ran? Also habe ich nachgesehen. Was folgt, ist ein Streifzug durch die Stellen, an denen ein Browser Tokens aufhebt, und durch die eine Erkenntnis, die alles Weitere trägt: Ein gefundenes JWT ist fast nie das, wonach es aussieht.

Schau selbst nach

Das Schöne daran ist, dass du es selbst sehen kannst, ohne Werkzeug, ohne mir etwas glauben zu müssen. Öffne auf einer beliebigen Seite, auf der du eingeloggt bist, die DevTools mit F12. Im Reiter Application (in Firefox heißt er Storage) findest du links den Punkt Local Storage. Klick ihn an, und du siehst eine Tabelle aus Schlüsseln und Werten, alles, was diese Seite lokal bei dir abgelegt hat. Such nach Werten, die mit eyJ anfangen. Das ist die Base64-Kodierung von {", dem Anfang jedes JWT-Headers, und damit das verlässliche Erkennungszeichen für ein Token.

Wenn du die Konsole offen hast, geht es bequemer. Dieses Snippet listet alle JWTs der aktuellen Seite und dekodiert ihren Payload:

Object.entries(localStorage)
  .filter(([k, v]) => /^eyJ[\w-]+\.[\w-]+\.[\w-]+$/.test(v))
  .forEach(([k, v]) => {
    const [, payload] = v.split('.');
    console.log(k, JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/'))));
  });

Es geht jeden Eintrag durch, behält nur die, die wie ein JWT aussehen, schneidet den mittleren Teil heraus und dekodiert ihn. Heraus kommt lesbares JSON. Genau hier wird greifbar, was Teil 1 mit „signiert, nicht verschlüsselt“ meinte: Du siehst deinen eigenen sub, deine role, dein exp im Klartext, ohne irgendeinen Schlüssel. Ein erfundenes Beispiel, wie so eine Ausgabe aussieht:

{ "sub": "user_42", "role": "member", "exp": 1750000000 }

Niemand musste das entschlüsseln. Die Signatur schützt das Token vor Veränderung, nicht vor Blicken. Wer es in die Finger bekommt, liest den Inhalt so mühelos, wie du gerade deinen eigenen liest. Das ist kein Versäumnis im Design, sondern Absicht: Ein JWT ist dafür gedacht, dass der Empfänger den Payload prüfen kann, ohne nachzufragen. Der Preis dafür ist, dass der Payload offen liegt.

Zwei weitere Reiter lohnen sich. Im Cookies-Tab siehst du die Cookies der Seite gelistet, und manche tragen das Flag HttpOnly. Du siehst, dass sie existieren, du siehst ihren Namen und ihr Ablaufdatum, aber an ihren Wert kommst du nicht heran, auch nicht über die Konsole. Versuch es ruhig: document.cookie gibt dir die normalen Cookies zurück, die mit HttpOnly fehlen einfach. Das ist kein Defekt, das ist der ganze Sinn: JavaScript darf ein HttpOnly-Cookie nicht lesen, eingeschleustes Skript also auch nicht. Genau hier liegt der erste handfeste Unterschied zum localStorage, der jedem Skript offensteht. Im Network-Tab kannst du dir schließlich einen einzelnen Request ansehen und in den Request-Headern nach Authorization: Bearer eyJ suchen. Taucht das auf, schickt dieser Dienst sein JWT bei jedem einzelnen Request wieder mit, und du kannst live zusehen, wie das Token über die Leitung geht. Drei Reiter, drei verschiedene Orte, an denen Tokens leben, und drei verschiedene Grade von Schutz, vom offenen localStorage bis zum für JavaScript unsichtbaren Cookie.

Warum nicht „alles auf einmal“, und ein Scanner

An dieser Stelle drängt sich eine naheliegende Sorge auf: Wenn ein eingeschleustes Skript den localStorage lesen kann, dann liest ein XSS doch einfach alles aus, was im Browser liegt. Das stimmt nicht, und der Grund ist eine der ältesten Regeln des Webs. Die Same-Origin-Policy sperrt jeden localStorage-Zugriff auf genau eine Origin ein. Ein Skript, das auf site-a.example läuft, kommt an den localStorage von site-b.example nicht heran. Deshalb räumt ein Remote-Angreifer mit einer XSS-Lücke nicht den ganzen Browser leer, sondern nur die eine Seite, die er kompromittiert hat. Dass XSS real ist und nicht bloß ein Lehrbuch-Schreckgespenst, zeigt etwa CVE-2025-43714, eine gespeicherte XSS in geteilten ChatGPT-Chats, laut NVD. Aber selbst die blieb auf ihre Origin beschränkt.

„Alles auf einmal“ geht nur über einen anderen Vektor: lokalen Dateizugriff. Die Tokens liegen am Ende als Dateien im Profilordner des Browsers. Wer diese Dateien direkt liest, umgeht die Same-Origin-Policy vollständig, weil er gar keine Browser-API benutzt, gegen die sie greifen könnte. Er liest Bytes von der Platte. Das bist im Normalfall nur du selbst auf deiner eigenen Maschine, oder lokale Malware, die dann allerdings ohnehin schon das System unter Kontrolle hat. Das Gefährliche ist also nicht, dass eine Webseite an die Tokens einer anderen kommt, sondern dass etwas auf deinem System die Browser-Regeln umgeht, indem es unter ihnen durchgeht.

Genau diese Unterscheidung zwischen „über eine Webseite“ und „über das Dateisystem“ ist es auch, die ein Audit-Werkzeug von einem Stealer trennt, obwohl beide dieselben Bytes lesen. Um das am eigenen Profil greifbar zu machen, habe ich mir mit Claude ein kleines Python-Tool gebaut, das die Profilordner direkt als Bytes durchsucht, gefundene JWTs dekodiert und nach Risiko sortiert. Ausdrücklich ein Audit-Werkzeug für das eigene Profil, kein Stealer: Es zeigt mir, was bei mir liegt, es schickt nichts irgendwohin. Der Unterschied zu einem Infostealer liegt nicht in der Technik des Lesens, sondern darin, dass das Gelesene das Gerät nicht verlässt. Der Code liegt offen unter github.com/syswave-dev/jwt-scan.

Der Count lügt

Der erste Lauf des Scanners fand ein paar Tausend eyJ-Fundstellen. Das klingt nach Katastrophe, und genau hier lohnt es sich, nicht in Panik zu verfallen, sondern genauer hinzusehen. Fast alles davon ist Müll: längst abgelaufene Tokens, die nur niemand weggeräumt hat. Dahinter stecken drei Gründe.

  • localStorage läuft nie von selbst ab. Der Browser räumt es nie automatisch auf (laut MDN). Ein Token, das eine App dort ablegt, bleibt liegen, bis die App es selbst überschreibt oder du die Website-Daten löschst. Für den Browser ist es nur ein String. Dass dieser String ein abgelaufenes Token ist, weiß er nicht und prüft es nie.
  • Auf der Platte staut es sich zusätzlich. Chromium hält localStorage in einer LevelDB. Überschriebene oder gelöschte Werte verschwinden dort nicht sofort, sondern erst bei der sogenannten Compaction, und die wird durch Schreibaktivität ausgelöst. Ein Browser-Profil, das man nicht mehr benutzt, kompaktiert nie. Die alten Geister bleiben physisch in den Dateien, und der Byte-Scanner sieht auch die.
  • Wiederöffnen räumt nicht zuverlässig auf. Eine App noch einmal zu öffnen löscht die alten Geister nicht verlässlich, weil eben keine garantierte Compaction stattfindet. Wirklich weg sind die Kopien erst, wenn man die Website-Daten selbst löscht, über die Browser-Einstellungen oder den Application-Tab. Und selbst das entfernt nur die lokale Kopie. Es macht das Token nicht ungültig, denn der Widerruf passiert serverseitig, nicht bei dir auf der Platte. Du kannst deinen Browser blitzblank putzen, und die gestohlene Kopie eines abgegriffenen Tokens funktioniert anderswo weiter. Dazu mehr im nächsten Teil.

Das Format eyJ allein sagt also nichts darüber, ob ein Token noch zählt. Erst die Auswertung trennt Signal von Müll, also ob das Token abgelaufen ist, wo es liegt und von welchem Typ es ist. „Tausende JWTs gefunden“ ist eine Schlagzeile, die nichts bedeutet. Es ist ungefähr so aussagekräftig wie „Tausende Zettel im Papierkorb gefunden“. Die Frage ist nicht, wie viele, sondern welche.

Format ≠ Risiko

Das wird besonders deutlich, wenn man zwei Tokens nebeneinanderlegt, die identisch aussehen und gegenteilige Bedeutung haben. Beide fangen mit eyJ an, beide sind formal ein JWT, und der eine gehört dorthin, wo er liegt, während der andere genau dort das eigentliche Problem ist.

Das eine ist ein Supabase-anon-Key, erkennbar an einem role: anon im Payload. Der gehört absichtlich in den Client-Code, er ist public-by-design. Sein Schutz kommt nicht aus Geheimhaltung, sondern aus Row-Level-Security auf der Datenbank (laut Supabase-Docs). Ein wichtiger Caveat gehört dazu, sonst ist die Aussage falsch: Das gilt nur, solange Row-Level-Security auch tatsächlich auf allen Tabellen aktiv ist. Fehlt sie, liest dieser öffentlich sichtbare Key die ganze Datenbank aus. Der Key im Klartext ist hier also kein Leak, die fehlende Policy dahinter wäre eines.

Daneben liegt ein langlebiges Refresh-Token im localStorage. Das ist der Generalschlüssel, der immer wieder neue Access-Tokens ausstellt, und es liegt im JavaScript-lesbaren Klartext-Store. Das ist das eigentliche Risiko. Beide Strings beginnen mit eyJ, beide bestehen aus drei punktgetrennten Teilen, beide würde derselbe reguläre Ausdruck herausfischen. Trotzdem ist der eine genau dort, wo er hingehört, und der andere genau dort am falschen Platz. Identisches Format, gegenteiliges Risiko. Die Faustregel „JWT gefunden gleich Leak“ ist damit etwa die Hälfte der Zeit schlicht falsch.

Deshalb ist das Risiko-Rating des Tools bewusst eine Expositions-Heuristik, kein Sensibilitäts-Score. Es bewertet nicht, wie heikel der Inhalt eines Tokens ist, das kann ein Scanner gar nicht wissen, sondern wie exponiert sein Speicherort ist. Hoch ist die Einstufung nur bei localStorage, sessionStorage oder IndexedDB, weil das die JS-lesbaren, XSS-erreichbaren Orte sind. Sonst niedrig. Und niedrig heißt hier ausdrücklich „nicht im XSS-Eimer“, nicht „harmlos“. Ein hochsensibles Token an einem schwer erreichbaren Ort bleibt sensibel, das Rating sagt nur etwas über die Reichweite eines eingeschleusten Skripts.

Drei Welten, gleicher Speicher

Wenn man die abgelaufenen Geister und die public-by-design-Keys abzieht, bleiben die echten Funde übrig: live, JS-lesbar, echtes Login-Material. Unter denen lassen sich drei Kategorien unterscheiden, die im selben Speicher liegen und doch völlig verschiedene Dinge bedeuten.

  • Das Anti-Pattern. Ein langlebiges, account-weites Refresh-Token im localStorage bei einem großen Anbieter. Es kommt oft ganz unscheinbar zustande: Manche SPA-SDKs bieten genau das als Option an, bequem verpackt. auth0-spa-js etwa hält Tokens standardmäßig nur im Speicher, was sie beim Schließen des Tabs verschwinden lässt. Setzt man aber cacheLocation: 'localstorage', werden sie persistent im localStorage abgelegt. Laut Auth0-Docs ändert dieser Opt-in bewusst die Sicherheitseigenschaften und setzt die Tokens XSS aus. Der Tausch ist bequem, denn die Session überlebt das Schließen des Tabs. Aber es ist genau der Tausch, der das Risiko erst schafft, und er steht oft in einer Zeile Konfiguration, die niemand mehr hinterfragt.
  • Richtig gemacht. Ein kurzlebiges, gerätegebundenes Firebase-Installations- bzw. App-Check-Token in IndexedDB. Es identifiziert die App-Instanz, ist kein Login-Token, ist kurzlebig und wird automatisch erneuert (laut Firebase-Docs). Es soll genau dort sein, wo es ist.
  • Selbstverschuldet, aber fixbar. Ein kleiner Dienst, den ich selbst betreibe, legt sein Token offen im localStorage ab, und das Token hat nicht einmal ein Ablaufdatum, würde also nie als abgelaufen aussortiert. So etwas passiert eben auch dem, der gerade den Scanner schreibt. Anders als bei den großen Anbietern kann ich diesen Fall aber selbst beheben, es ist mein eigener Code.

Und dann gibt es noch den Fund, der leicht untergeht und vielleicht der wichtigste ist. Neben einem sichtbaren JWT liegt oft ein opakes Refresh-Token, das ein reiner JWT-Scanner gar nicht sieht. Es ist kein JWT, es hat keine Punkt-Struktur, es matcht kein eyJ-Muster, ein Mustersucher läuft achtlos daran vorbei. Aber es liegt im selben unverschlüsselten localStorage-Eintrag, direkt neben den sichtbaren Tokens, oft als ein Feld in demselben JSON-Objekt, das die Bibliothek dort ablegt. Das Tool zeigt die Spitze des Eisbergs, und der eigentliche Generalschlüssel sitzt unscheinbar daneben. Wer nur nach JWTs sucht, übersieht ausgerechnet das, was am meisten wert ist. Das ist nebenbei eine zweite Lehre über Scanner überhaupt: Ein Werkzeug findet, wonach es sucht, und der gefährlichste Fund ist oft der, für den niemand ein Muster geschrieben hat.

Was im Scan fehlt

Am Ende ist das Interessanteste vielleicht das, was der Scan nicht findet. Die ganz großen Web-Logins, Google und Microsoft, tauchen gar nicht auf. Nicht weil diese Dienste keine Tokens hätten, im Gegenteil, sie verwalten einige der wertvollsten Sitzungen, die ein Browser kennt. Sondern weil ihre an einem Ort liegen, an den der Scanner nicht herankommt. Das ergibt ein auf den ersten Blick verkehrtes Bild: Das Gefährliche liegt im Klartext und ist leicht zu finden, während das eigentlich Wertvolle sich gerade dem Werkzeug entzieht, das nach Wertvollem sucht.

Warum das so ist, warum die heiklen Tokens offen herumliegen und die gut geschützten sich dem Byte-Scanner entziehen, und was der eigentliche Fix dahinter ist, jenseits von „verschlüssel es einfach besser“, das ist der nächste Teil.


Dieser Text baut auf Teil 1: „Ein JWT ist ein Format, kein Login-System“ auf. Weiter geht es mit Klartext und Tresor: warum dein localStorage offen liegt und dein Login nicht.