Base64-Dekodierung in JavaScript/Browser: Ein vollständiger Leitfaden
Sie kommt in einem Dutzend verschiedener Verkleidungen daher: ein JWT, versteckt in einem Authorization-Header, ein image/png-Blob in einer JSON-Antwort, ein Sec-WebSocket-Accept-Wert in einem Handshake-Log, ein E-Mail-Anhang, verpackt in MIME, ein Wert, den Ihr Backend artig in einen Query-String gepackt hat. Die Zeichenkette selbst sieht immer gleich aus: eine lange Folge aus Buchstaben und Ziffern, dem gelegentlichen + oder / und vielleicht einem = oder zwei am Ende. Wenn die Startseite dieser Site Ihnen gezeigt hat, was Base64 ist - vier druckbare Zeichen, die für je drei Bytes stehen, mit =-Padding, das die letzte Gruppe abrundet - dann dreht sich dieser Artikel um den Teil, den Sie im Code tatsächlich ausführen: Diese Zeichen wieder in Bytes verwandeln und die Bytes wieder in Bedeutung zurückführen, und zwar mit nichts als dem, was der Browser ohnehin mitliefert.
Zwei kurze Grundregeln, bevor es losgeht. Erstens: Dekodieren ist die schrumpfende Richtung: Für je vier Zeichen, die Sie hereinlesen, kommen drei Bytes heraus, und die Ausgabe belegt daher immer weniger Speicher als die Eingabe. Zweitens: Eine dekodierte Base64-Zeichenkette ist nicht automatisch Text. Es sind Bytes, und Bytes können sich als UTF-8, Windows-1252, ein PNG-Header oder eine kryptografische Signatur entpuppen. Der mit Abstand häufigste Bug in Base64-Code ist, zu vergessen, welches von diesen Sie gerade in der Hand haben, darum sind die folgenden Abschnitte um genau diese Frage herum organisiert.
Die drei Ebenen des Dekodierens
Moderne Browser bieten Ihnen drei native Ebenen, und die gute Nachricht ist: Es wird nie ein Paket gebraucht. Jede beantwortet eine leicht andere Frage, und die richtige Wahl erspart Ihnen jede Menge kopierter Stack-Overflow-Snippets:
| Ebene | Was es frisst | Was es Ihnen liefert | Persönlichkeit | Verfügbarkeit |
|---|---|---|---|---|
atob() |
Standard-Base64-Zeichenkette | einen "Binär-String" (ein Byte pro Zeichen) | sehr nachsichtig: überspringt ASCII-Leerraum, akzeptiert fehlendes Padding | jeder Browser seit den 2000er-Jahren, IE 10+, Node 16+ |
TextDecoder |
Bytes (Uint8Array) |
lesbarer JavaScript-Text | konfigurierbar: Label für den Zeichensatz, fatal-Flag für die Strenge |
Firefox 18, Chrome 38, Safari 10.1 und neuer (nie IE) |
Uint8Array.fromBase64() |
Base64-Zeichenkette plus Optionen | eine echte Uint8Array |
streng mit Reglern: Alphabet und Behandlung des letzten Chunks | Baseline 2025: Chrome 140, Firefox 133, Safari 18.2, Node 25 |
Der Aufbau des ganzen Artikels folgt aus dieser Tabelle. atob() ist das Arbeitstier, dem Sie überall begegnen werden, auch in altem Code. TextDecoder ist die Brücke von Bytes zu Wörtern. Und Uint8Array.fromBase64() ist das 2025er-Upgrade, das den Zwischenschritt komplett überspringt, wenn Sie ohnehin nur Bytes wollten.
atob: Schnell, nachsichtig und sehr alt
Der gesamte Vertrag passt in eine Zeile: atob(encodedData). Sie nimmt eine Base64-kodierte Zeichenkette entgegen und gibt einen "Binär-String" zurück: einen ganz normalen JavaScript-String, in dem jedes Zeichen genau ein dekodiertes Byte hält, einen Codepunkt von 0 bis 255. Dieser Rückgabetyp ist entscheidend, denn er ist nicht das gleiche wie lesbarer Text (dazu mehr unten). Die Funktion selbst ist so schnell, wie es geht, und sie gibt es seit sehr langer Zeit: Chrome 4, Firefox 1, Safari 3 und - das merken sich die meisten - Internet Explorer erst ab Version 10. Darum ist Code, der vor 2012 geschrieben wurde, voll von handgebauten Base64-Tabellen.
Was atob() angenehm macht, ist, wie viel sie verzeiht, bevor sie aufgibt. Der WHATWG-HTML-Standard sagt, den gesamten ASCII-Leerraum zu ignorieren - Leerzeichen, Tab, Zeilenumbruch, Formfeed, Wagenrücklauf - bevor dekodiert wird, und so dekodiert eine MIME-verpackte Zeichenkette mit Zeilenumbrüchen alle 76 Zeichen ganz ohne Aufräumarbeit Ihrerseits. Auch fehlendes Padding wird verziehen. Doch im Moment, in dem sie ein Zeichen außerhalb des Alphabets sieht oder eine Länge, die niemals gültig sein könnte, wirft sie eine DOMException namens InvalidCharacterError. Kein stiller Müll, keine Teilergebnisse.
Hier ist der Schadensbericht, Zeile für Zeile:
| Eingabe | Ergebnis |
|---|---|
"SGVsbG8sIFdvcmxkIQ==" |
"Hello, World!" - der Lehrbuchfall |
"aGVsbG8" (kein Padding) |
"hello" - ein fehlendes = wird verziehen |
"SGVs\nbG8s\nIFdvcmxkIQ==" (umgebrochene Zeilen) |
"Hello, World!" - ASCII-Leerraum wird zuerst übersprungen |
"" (leere Zeichenkette) |
"" - die leere Eingabe ist gültig und roundtrippt sauber |
"A" (ein übrig gebliebenes Zeichen) |
wirft InvalidCharacterError - ein Zeichen kann nichts kodieren |
"Zm9vYmFy!" (ein eingeschlichenes !) |
wirft InvalidCharacterError - außerhalb des Alphabets |
"ZGFua29nYWk-" (URL-sicheres Zeichen eingeschlichen) |
wirft InvalidCharacterError - die beiden Alphabete dürfen nicht gemischt werden |
"Zm9v====" (zu viel Padding) |
wirft InvalidCharacterError - höchstens zwei = am Ende |
Eine praktische Anmerkung: Die Fehlermeldung selbst unterscheidet sich zwischen den Engines (Firefox sagt "String contains an invalid character", Chrome sagt, die Zeichenkette "contains characters outside of the Latin1 range" bei nicht-Latin1-Eingaben oder "is not correctly encoded" bei ungültigem Base64). Fangen Sie daher nach dem Namen der Ausnahme ab, nicht nach dem Text der Meldung.
Von rohen Bytes zu echtem Text
Der Rückgabetyp "Binär-String" verdient ein Innehalten, denn er ist die Quelle des größten Teils der Dekodierungsverwirrung. JavaScript-Strings sind UTF-16, und atob() reicht Ihnen einen String, dessen Zeichen Byte-Werte sind, keine lesbaren Glyphen. Wenn Ihr Payload die UTF-8-Kodierung des Textes "hello 你好" war, liefert der direkte Ausdruck des Ergebnisses Mojibake. Die Lösung ist ein zweistufiges Dekodieren: Base64 zu Bytes, dann Bytes zu Text.
Zuerst der Schritt von Base64 zu Bytes. Dieser kleine Helfer ist das klassische Rezept und lohnt sich für die Hosentasche, denn er ist das tragende Stück in den meisten Beispielen dieses Artikels:
function base64ToBytes (base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
Dann der Schritt von Bytes zu Text, mit TextDecoder. Für UTF-8 (der Standard und die richtige Wahl für JSON, JWT-Payloads und die meisten Webdaten) ist der Aufruf nur eine Zeile:
const bytes = base64ToBytes('aGVsbG8g5L2g5aW9');
const text = new TextDecoder('utf-8').decode(bytes);
console.log(text); // "hello 你好"
Warum überhaupt zwei Schritte? Weil atob() keine Ahnung hat, in welchem Zeichensatz die Bytes erzeugt wurden. Es ist ein reiner Bit-Umwandler. TextDecoder ist die Komponente, die Bytes als Zeichensatz interpretiert, und sie nimmt dafür ein Label entgegen: utf-8, windows-1252, iso-8859-1, utf-16le plus etwa 220 weitere Labels. Daten, die aus einer Anwendung der 1990er-Jahre stammen, sind meist Windows-1252, und ein Konstruktions-Argument genügt:
const decoder = new TextDecoder('windows-1252');
const text = decoder.decode(bytes); // dieselben Bytes, andere Interpretation
Der TextDecoder-Konstruktor nimmt außerdem ein fatal-Flag entgegen, und es lohnt sich, es auf true zu setzen, wann immer der dekodierte Text etwas Wichtiges füttert. Standardmäßig ist der Decoder nachsichtig: Ungültige Byte-Folgen werden still und leise durch das Unicode-Ersatzzeichen U+FFFD ersetzt, und Sie erfahren nie etwas davon. Mit fatal: true wirft derselbe Schaden eine TypeError, anstatt sich zu verstecken:
const strict = new TextDecoder('utf-8', { fatal: true });
try {
strict.decode(corruptedBytes);
} catch (error) {
console.log(error.name); // "TypeError"
}
Das ist einer dieser Schalter, der in der Doku unbedeutend wirkt und in Produktion wie ein Datenverlust-Vorfall aussieht. Wenn Ihre Eingabe vom Benutzer oder aus dem Netz stammt, dekodieren Sie streng und behandeln Sie den Fehler bewusst.
URL-sichere Eingaben brauchen einen Umweg
Eine Base64-Variante verdient einen eigenen Abschnitt, denn sie taucht in freier Wildbahn ständig auf, und atob() kann sie nicht lesen. Es ist das URL- und Dateinamen-sichere Alphabet aus Abschnitt 5 von RFC 4648, meist als base64url bekannt: dieselben 64 Zeichen, nur dass + und / durch - und _ ersetzt werden, und das =-Padding wird oft weggelassen, weil die Datengröße implizit bekannt ist. Der Tausch hat einen konkreten Grund: In einer URL bedeutet + ein Leerzeichen und / beginnt ein Pfadsegment, also müsste das Standard-Alphabet Zeichen für Zeichen Prozent-kodiert werden. Base64url reist sauber durch Query-Strings, Pfadsegmente, Fragmente und Dateinamen.
Der Haken ist, dass die beiden Alphabete nicht austauschbar sind, und atob() spricht nur das Standard-Alphabet. Geben Sie ihr ein - oder _, und Sie bekommen InvalidCharacterError. Sie haben zwei saubere Optionen.
Option eins, die überall funktioniert: Wandeln Sie das Alphabet um und stellen Sie das Padding wieder her, bevor Sie atob() aufrufen:
function fromUrlBase64 (segment) {
let s = segment.replace(/-/g, '+').replace(/_/g, '/');
const missing = (4 - (s.length % 4)) % 4;
return atob(s + '='.repeat(missing));
}
console.log(fromUrlBase64('aGVsbG8')); // "hello"
Der Ausdruck (4 - (s.length % 4)) % 4 ist der ganze Trick: Er berechnet, wie viele =-Zeichen eine korrekt gepaddete Zeichenkette dieser Länge brauchen würde, von null bis zwei.
Option zwei, in Browsern ab 2025: Der neue native Decoder nimmt das Alphabet als Option entgegen, also gar keine String-Chirurgie:
const bytes = Uint8Array.fromBase64('P3-0', { alphabet: 'base64url' });
console.log(Array.from(bytes).join(', ')); // "63, 127, 180"
Zwei Regeln halten Sie aus den Schikanen heraus. Mischen Sie niemals Alphabete innerhalb eines einzelnen Werts - ein Decoder, der sowohl ein + als auch ein - sieht, kann nicht wissen, welche Familie er gerade liest, und das spezifikationskonforme Verhalten ist, zu scheitern. Und einigen Sie sich mit der anderen Seite der Leitung darüber, ob Padding vorhanden ist: Weglassen ist bei base64url erlaubt, also muss ein Empfänger für beide Formen bereit sein. atob() ist es bereits; die nativen Optionen unten geben Ihnen einen Regler dafür.
Der Kurzweg von 2025: Uint8Array.fromBase64
Blicken Sie auf den base64ToBytes-Helfer zurück, und Sie werden bemerken, dass er zwei Dinge tut: Base64 dekodieren und dann die Zeichen in JavaScript einzeln in ein Byte-Array kopieren. Diese Kopierschleife ist der langsame, vermeidbare Teil, genau den eliminiert die neue ECMAScript-Methode. Uint8Array.fromBase64(string, options) geht direkt von der kodierten Zeichenkette ins Byte-Array, und sie ist in Chrome 140, Edge 140, Firefox 133, Safari 18.2, Node 25 und Deno 2.5 enthalten - das erste JavaScript-Plattform-Feature dieser Art, das überhaupt gelandet ist, im Baseline-Programm der Browser-Anbieter als Baseline Newly available markiert.
Das Options-Objekt hat zwei Regler. Der erste ist alphabet: "base64" (der Standard) oder "base64url". Der zweite ist lastChunkHandling, der steuert, was mit der letzten unvollständigen Zeichen-Gruppe passiert:
| Modus | Regel für den letzten Chunk |
|---|---|
"loose" (Standard) |
zwei oder drei Zeichen, oder vier mit Padding; übrige Overflow-Bits werden ignoriert |
"strict" |
genau vier Zeichen (Padding nur, wo die Länge es verlangt), und die Overflow-Bits müssen alle null sein |
"stop-before-partial" |
nur vollständige Vierzeichen-Gruppen werden dekodiert; ein unvollständiger Schwanz wird ungelesen gelassen |
Wie atob() ignoriert die Methode ASCII-Leerraum in der Eingabe, also sind umgebrochene Zeilen unproblematisch. Im Gegensatz zu atob() hat sie aber eine Meinung zu allem anderen: Ein Zeichen außerhalb des gewählten Alphabets oder ein letzter Chunk, der den gewählten Modus verletzt, wirft eine SyntaxError; wird etwas übergeben, das kein String ist, wirft sie eine TypeError. Hier ist der strict-Modus bei der Arbeit und lehnt einen Chunk ab, dem das Padding fehlt:
const ok = Uint8Array.fromBase64('SGVsbG8=', { lastChunkHandling: 'strict' });
try {
Uint8Array.fromBase64('VR', { lastChunkHandling: 'strict' });
} catch (error) {
console.log(error.name); // "SyntaxError"
}
Performance ist der andere Grund, sie zu bevorzugen. Auf einem aktuellen Firefox auf dem Rechner des Autors dekodiert fromBase64 einen 10-Megabyte-Payload in einstelligen Millisekunden, während das klassische atob plus zeichenweiser Byte-Zuordnung etwa zwanzigmal so lange braucht, denn der langsame Teil ist die Schleife auf JavaScript-Ebene, nicht die Base64-Mathematik. Wenn Ihre Daten Bytes sind, überspringen Sie den String komplett.
Für ältere Browser ist die Lage einfach: Behalten Sie den base64ToBytes-Helfer oben bei, oder ziehen Sie ein kleines Polyfill heran (core-js und das es-arraybuffer-base64-Paket aus dem es-shims-Projekt liefern beide eines für fromBase64), wenn Sie überall im neuen Stil schreiben wollen. Die API ist stabil - sie steht jetzt in der ECMAScript-Spezifikation - also wird alles, was Sie dagegen schreiben, nicht deprecated werden.
Ein JWT lesen
Die häufigste "mysteriöse Zeichenkette" in Anwendungs-Logs ist ein JSON Web Token: drei durch Punkte getrennte Segmente, header.payload.signature, wobei die ersten beiden base64url-kodierte JSON-Objekte sind. Eines davon zu dekodieren ist ein Fünfzeilen-Job und eine perfekte Aufwärmübung für alles bis hierhin:
function jwtSegmentToBytes (segment) {
let s = segment.replace(/-/g, '+').replace(/_/g, '/');
s += '='.repeat((4 - (s.length % 4)) % 4);
return base64ToBytes(s);
}
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
const [header64, payload64] = token.split('.');
const payload = JSON.parse(new TextDecoder().decode(jwtSegmentToBytes(payload64)));
console.log(payload.name); // "John Doe"
Jetzt der Teil, den Anfänger überspringen und Produktionssysteme auf die harte Weise lernen: Der Payload wird nicht dadurch verifiziert, dass er dekodierbar ist. Jeder kann ein JWT mit beliebigen Payload schreiben; das Signatur-Segment ist es, das ihn an ein Secret bindet. Ein HS256-Token im Browser zu verifizieren, nutzt die Web Crypto API, die die Signatur als Bytes braucht - ein weiterer Grund, warum sich der Segmente-zu-Bytes-Helfer lohnt:
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode('shared-secret'),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const [h, p, sig64] = token.split('.');
const valid = await crypto.subtle.verify(
'HMAC',
key,
jwtSegmentToBytes(sig64),
encoder.encode(h + '.' + p)
);
console.log(valid); // true nur, wenn die Signatur zum Secret passt
Drei Stolperfallen verdienen eine Benennung. Erstens: Prüfen Sie den Header, bevor Sie verifizieren: Ein Token, das alg: "none" behauptet, bittet Sie, dem Payload ohne Signatur zu vertrauen, und naiver Code hat sich genau darauf schon öfter eingelassen. Zweitens: Beachten Sie die Zeit-Ansprüche - exp, nbf, iat - erst nach der Verifizierung, nicht vorher. Drittens: der klassische Key-Confusion-Angriff: Ein Server, der für RS256 konfiguriert ist, aber auch HS256 akzeptiert, erlaubt es einem Angreifer, Tokens mit dem öffentlichen Schlüssel zu signieren (der ist aus gutem Grund öffentlich), der dabei als HMAC-Secret dient. Kurz gesagt: Frei dekodieren, nichts vertrauen, alles verifizieren.
Data-URLs öffnen
Eine Data-URL bettet eine ganze Datei in eine URL ein: data:, ein optionaler Medientyp, ein optionales ;base64-Flag, ein Komma, dann der Payload. Text-Payloads sind Prozent-kodiert, binäre Payloads sind Base64, und der Browser rendert sie ohne jegliche HTTP-Anfrage - kein fetch, kein Server-Rundweg, nichts zum Cachen. Der Browser behandelt jede Data-URL als eigene, undurchsichtige Origin, was auch der Grund ist, warum sie ein beliebter Schleusweg für schlaue Inhalte sind: Ein data:text/html-Dokument, das in einem iframe geöffnet wird, führt seine Skripte aus, und eine restriktive Content-Security-Policy kann Data-URLs komplett blockieren. Halten Sie Ihre CSP im Hinterkopf, wenn Sie anfangen, solche an benutzerkontrolliertes Markup weiterzugeben.
Eine davon zu dekodieren ist vor allem String-Chirurgie, danach dieselbe Bytes-Pipeline wie zuvor:
const url = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADgQFY/fWoOgAAAABJRU5ErkJggg==';
const comma = url.indexOf(',');
const meta = url.slice(5, comma); // "image/png;base64"
const bytes = base64ToBytes(url.slice(comma + 1));
const blob = new Blob([bytes], { type: 'image/png' });
const objectUrl = URL.createObjectURL(blob);
Das meta-Stück verrät Ihnen den Medientyp (hier image/png, mit dem ;base64-Marker, der bestätigt, dass der Payload Base64 ist). Sobald der Payload ein Blob ist, gilt alles Normale: eine Object-URL für ein <img>, ein Download-Link oder ein POST an einen Server. Der einzige echte Preis des Data-URL-Weges ist die Größe - der Payload ist etwa 33 Prozent größer als die Ursprungsdatei - und ein großes Bild in einer URL kann die String-Limits der Seite an ihre Grenzen bringen, was ein weiteres Argument für Object-URLs ist, wenn die Datei den Browser sowieso nie verlässt.
Dateien dekodieren, die als Text ankommen
Dateien erreichen den Browser auf zwei Wegen. Der moderne Weg sind rohe Bytes: ein fetch, das Sie als ArrayBuffer lesen, oder eine File aus der Dateiauswahl, die Sie mit file.arrayBuffer() lesen. Wenn Sie auf diesem Weg sind, herzlichen Glückwunsch - Base64 ist hier gar nicht im Spiel, und Sie sollten auf diesem Weg bleiben, denn Bytes kosten nichts zu tragen, während Base64 für das Privileg ein Drittel mehr an Bandbreite und Speicher kostet. Der andere Weg betrifft Kanäle, die nur Text transportieren: eine JSON-API, die {"attachment": "data:application/pdf;base64,JVBERi..."} zurückgibt, ein E-Mail-Anhang, ein Konfigurations-String, ein Wert in einer Datenbank-Spalte. Dann ist Base64 das Protokoll, und Ihr Job besteht nur darin, die Bytes herauszuholen:
async function loadRemoteBytes (fileUrl) {
const response = await fetch(fileUrl);
return new Uint8Array(await response.arrayBuffer());
}
const record = JSON.parse(await (await fetch('/api/record/42')).text());
const pdfBytes = base64ToBytes(record.attachment.split(',')[1]);
Drei Anmerkungen zu diesem Snippet. Auf das erste Komma zu splitten genügt, um den Data-URL-Header abzuschälen (der Medientyp kann kein Komma enthalten, also ist das erste immer der Trenner). Und wenn der Wert einfach Base64 ohne Data-URL-Präfix ist, lassen Sie das Splitten einfach weg. Schließlich ist das nackte await dort ein top-level await, und Browser erlauben solche nur innerhalb von Modulen, also braucht das Snippet ein <script type="module">-Tag oder einen async-Wrapper um diese beiden Zeilen. E-Mail-MIME-Teile sind die gleiche Geschichte mit extra Schritten: Der Anhang-Body ist Base64, umgebrochen auf 76 Zeichen pro Zeile, aber da atob() Leerraum überspringt, können Sie ihm den umgebrochenen Text genau so geben, wie er in der rohen Nachricht angekommen ist - kein Zurück-Verpacken nötig. Dieses eine Verhalten spart still und leise jede Menge Regex.
Ein WebSocket-Handshake verifizieren
Eine der charmantesten Anwendungen von Dekodieren im Browser ist das Prüfen des WebSocket-Handshakes selbst. RFC 6455 verlangt, dass der Client einen Sec-WebSocket-Key-Header sendet (16 zufällige Bytes, Base64-kodiert) und der Server mit Sec-WebSocket-Accept antwortet: der SHA-1-Hash des Schlüssels, konkatiniert mit einer festen magischen GUID, Base64-kodiert. Passt der Wert nicht, scheitert der Handshake, und die Verbindung wird nicht upgegradet. Der ganze Sinn dieser Zeremonie ist, dass ein Server, der nur HTTP spricht, sie nicht versehentlich abschließen kann - die magische GUID existiert, um die Berechnung absichtlich überkompliziert aussehen zu lassen. Und da der Browser sowohl das Hashen als auch die Kodierung mitbringt, können Sie die erwartete Antwort selbst berechnen, was das Debuggen von Proxys und Gateways zu einer Einzeilen-Angelegenheit macht:
const MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
async function expectedAccept (clientKey) {
const digest = await crypto.subtle.digest(
'SHA-1',
new TextEncoder().encode(clientKey + MAGIC)
);
return btoa(String.fromCharCode(...new Uint8Array(digest)));
}
const accept = await expectedAccept('dGhlIHNhbXBsZSBub25jZQ==');
console.log(accept); // "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
Die letzte Zeile ist kein Zufall - es ist das exakte Beispiel aus dem RFC, Byte für Byte nachgebaut. Wenn Ihr Gateway mit irgendetwas anderem antwortet, wissen Sie nun genau, welche Seite der Gleichung lügt.
HTTP-Header und Query-Strings
Base64 ist bei HTTP-Headern ein Favorit, denn Header müssen ASCII sein, und der berühmteste Fall ist die Basic-Authentifizierung: Authorization: Basic gefolgt von der Base64-Kodierung von username:password. Einen solchen Header zu lesen (zum Beispiel beim Anzeigen dessen, was eine Anfrage mitführt) ist ein Split und ein Dekodieren:
const header = 'Basic YWxpY2U6c2VjcmV0MTIz';
const [user, ...rest] = atob(header.slice(6)).split(':');
const password = rest.join(':');
console.log(user, password); // "alice secret123"
Das Spread-und-wieder-verbinden-Muster behandelt den umständlichen, aber legalen Fall eines Passworts mit Doppelpunkt, denn der Split-Punkt ist immer der erste nach dem Benutzernamen. Dasselbe Muster gilt überall dort, wo ein Header einen strukturierten Wert schmuggelt: Proxy-Authorization, einige vendor-spezifische Header und der gelegentliche Cookie. In Query-Strings und Deep-Links taucht Base64 auf, wenn eine App Zustand teilen will, ohne einen Server: ein OAuth-state-Wert, ein wiederhergestelltes Suchformular, eine "weitermachen, wo ich aufgehört habe"-Markierung. Dekodieren Sie defensiv - in try/catch wickeln, denn der Wert hat eine Netzwerk-Grenze überschritten, und ihm kann alles passiert sein - und behandeln Sie das Ergebnis als nicht vertrauenswürdige Eingabe, ohne Wenn und Aber.
Und das bringt uns zum Satz, der über jedem Terminal hängen sollte: Base64 ist keine Verschlüsselung. Es ist nicht einmal im eigentlichen Sinne Verschleierung, denn das "Entschlüsseln" ist ein Funktionsaufruf, den jede Sprache der Erde implementiert. Wenn ein Wert geheim bleiben muss, macht es ihn weniger sicher, nicht mehr, wenn man ihn zuerst Base64-kodiert - es erzeugt die Illusion von Privatsphäre und fügt genau einen lächerlich einfachen Schritt für jeden hinzu, der das Original will.
Zustand in der URL und im Storage
Dieselbe Logik erstreckt sich auf alles, was einen Seiten-Reload oder einen Teilen-Link überleben muss. Die üblichen Verdächtigen: localStorage- und sessionStorage-Werte, die strukturierte oder binäre Daten tragen, das Hash-Fragment einer URL für den Routing-Zustand einer Single-Page-App, und Konfigurations-Blobs, die Build-Tools in Seiten einbetten. Die Storage-Geschichte verdient ein konkretes Beispiel, denn die Leseseite gehört zur Schreibseite, die Sie sich merken werden wollen:
const raw = localStorage.getItem('profile');
const profile = JSON.parse(new TextDecoder().decode(base64ToBytes(raw)));
Drei Dinge zu beachten. Erstens: Budgets: Browser geben jeder Origin etwa 5 Megabyte an localStorage, und Ihre gespeicherte Base64-Zeichenkette frisst etwa 33 Prozent mehr als die Ursprungsdaten, also wird eine 3,5-Megabyte-Datei still und leise zu 4,6 Megabyte an Speicher - und der String lebt im Speicher als UTF-16, was den Fußabdruck verdoppelt, solange die Seite offen ist. Zweitens: Konsistenz: Kodieren und dekodieren Sie mit demselben Zeichensatz auf beiden Seiten, sonst speichern Sie tadellose Bytes und lesen Mojibake. Drittens: Teilen-Links: Wenn der Zustand in der URL reist, verwenden Sie das URL-sichere Alphabet, damit der Wert Copy-Paste übersteht, und halten Sie ihn kurz, denn URL-Längen über ein paar tausend Zeichen machen älteren Clients und Logging-Tools nervös.
Wenn die Daten in Stücken ankommen
Manchmal kommt das Base64 nicht als eine Zeichenkette an: Eine WebSocket-Nachrichtengrenze hackt es in zwei Hälften, ein Server-Sent-Event-Stream tropft es herein, ein chunked Upload reicht es je ein paar Kilobyte. Sie können atob() nicht auf ein Fragment aufrufen, denn Base64-Gruppen sind 3-Byte-Einheiten, ausgedrückt in Blöcken von vier Zeichen, und ein Schnitt mitten in einer Gruppe hinterlässt einen hängenden Bruchteil. Der altmodische Fix war, Zeichen zu puffern, bis ein Vielfaches von vier vorlag, und den Puffer in Scheiben zu dekodieren. Die API von 2025 macht das sauber: Uint8Array.prototype.setFromBase64(string, options) schreibt dekodierte Bytes in ein bestehendes Array und gibt ein Objekt mit zwei Zahlen zurück, read (wie viele Zeichen es verbraucht hat) und written (wie viele Bytes es produziert hat). Mit lastChunkHandling: "stop-before-partial" dekodiert es nur vollständige Gruppen und lässt den unvollständigen Schwanz ungelesen, genau das Verhalten, das ein Stream-Decoder möchte:
const parts = [];
let carry = '';
for (const piece of incomingPieces) {
let pending = carry + piece;
for (;;) {
const room = new Uint8Array(8);
const result = room.setFromBase64(pending, {
lastChunkHandling: 'stop-before-partial'
});
parts.push(room.subarray(0, result.written));
pending = pending.slice(result.read);
if (result.read === 0) {
carry = pending;
break;
}
}
}
const size = parts.reduce((sum, part) => sum + part.length, 0);
const bytes = new Uint8Array(size);
let at = 0;
for (const part of parts) {
bytes.set(part, at);
at += part.length;
}
const text = new TextDecoder().decode(bytes);
Lesen Sie die innere Schleife langsam, denn sie ist das ganze Muster: Füttern Sie den mitgeführten Rest plus das neue Stück herein, lassen Sie den Decoder so viele vollständige Gruppen verbrauchen, wie hineinpassen, merken Sie sich, wie viel übrig blieb, indem Sie result.read Zeichen abschneiden, und wenn nichts Vollständiges mehr übrig ist (result.read === 0), legen Sie den Rest als neuen carry beiseite und warten Sie auf das nächste Stück. Das Uint8Array(8) ist nur ein Kratzer-Puffer - eine Gruppe von vier Zeichen produziert höchstens drei Bytes, also ist acht großzügig. Am Ende hält carry alles, was der Stream nie zu Ende brachte, und das ist entweder Ihr Fehler-Signal oder Ihr "Verbindung sauber beendet"-Check.
Wenn man Base64 besser nicht dekodiert
Ein guter Leitfaden lehrt einen, wann man das Werkzeug ablegt. Wenn Sie beide Enden des Kanals kontrollieren, greifen Sie stattdessen zu rohen Bytes: fetch mit response.arrayBuffer() für Downloads, file.arrayBuffer() für Dateiauswahl-Dateien, ArrayBuffer-Payloads in WebSockets und multipart FormData für Uploads. Keines davon berührt Base64, und Sie erhalten die Daten mit voller Geschwindigkeit, ohne Größensteuer und ohne String-im-Speicher-Fußabdruck. Base64 verdient seinen Lohn genau dann, wenn der Kanal nur Text transportiert: JSON-Bodies, Query-Strings, E-Mail, Storage, Legacy-APIs und alles, dessen Vertrag sagt "ASCII oder nichts". Sobald ein Byte genügen würde, zahlt eine Base64-Zeichenkette einen 33-Prozent-Aufschlag für das Privileg, druckbar zu sein, und der Aufschlag wird in Bandbreite, Speicher und CPU kassiert - drei Rechnungen, die Sie alle vermeiden können.
Häufige Stolperfallen beim Dekodieren
Nach all den glatten Pfaden hier die Liste der Wege, auf denen es beißt, in etwa der Reihenfolge, in der Sie ihnen begegnen werden:
- Das Ergebnis von
atob()als Text behandeln. Es ist ein Binär-String. DurchTextDecoderwird er zu Text; direkt ausgedruckt wird er zu Mojibake. Diese einzige Verwechslung verursacht die meisten "Base64 funktioniert nicht"-Meldungen. - Erwarten, dass Unicode einfach funktioniert. Die Bytes von "你好" dekodieren sich problemlos, aber sie sind Bytes, bis ein Decoder Ihnen sagt, dass sie UTF-8 sind. Auf beiden Seiten mit demselben Zeichensatz kodieren und dekodieren.
- base64url in
atob()füttern. Schon ein einzelnes-oder_wirft eine Ausnahme. Zuerst das Alphabet umwandeln, oderfromBase64mit der richtigen Option verwenden. - Glauben, jeder lange String sei Base64. Eine gültige, gepaddete Base64-Zeichenkette hat eine Länge, die ein Vielfaches von vier ist (ungepaddetes base64url darf auf 2 oder 3 enden), und verwendet höchstens ein Alphabet. Eine Länge von eins modulo vier ist ein sofortiger Ausfall - prüfen Sie das, bevor Sie einen try/catch dafür verschwenden.
- Padding vertrauen, das Sie nicht vereinbart haben. Manche Systeme streichen
=, manche behalten es, und manche setzen es mitten in eine umgebrochene Zeichenkette, wo es nicht hingehört. Verabsprachen Sie es mit dem Sender, und entscheiden Sie dann, ob Sie nachsichtig (atob) oder streng (fromBase64) sein wollen. - Stille Beschädigung durch einen nachsichtigen Decoder. Ein Standard-
TextDecoderersetzt ungültige Bytes durch U+FFFD und sagt nichts. Setzen Siefatal: true, wenn die Daten wichtig sind. - Annehmen, Base64 schütze etwas. Tut es nicht. Es ist ein Serialisierungsformat, ein Funktionsaufruf entfernt von Klartext, und "wir Base64-kodieren es, damit Nutzer es nicht lesen können" ist eine Sicherheits-Einstellung, keine Kontrolle.
- Speicher vergessen. Ein dekodierter Binär-String von einem Megabyte belegt zwei Megabyte als UTF-16-String, während eine
Uint8Arrayderselben Daten eines belegt. Bei großen Payloads direkt zufromBase64gehen. - Mit jedem Render neu dekodieren. Ein paar Megabyte zu dekodieren ist schnell, aber nicht gratis - und es ist nichts, was man einmal pro Frame tun sollte. Einmal dekodieren, die Bytes cachen, aus dem Cache rendern.
Performance-Notizen
Die Kurzversion: Die nativen Decoder sind schnell, und der langsame Teil alten Codes ist normalerweise das JavaScript drumherum, nicht das Base64 selbst. Bei den Größen, die zählen, sieht das Bild gleich aus: Ein 10-Megabyte-Payload dekodiert mit Uint8Array.fromBase64 in einstelligen Millisekunden; atob allein ist ein paar Mal langsamer, und die klassische Folgeschleife, die Zeichen in ein Byte-Array überträgt, braucht bei derselben Eingabe etwa zwanzig Mal so lange wie fromBase64, denn sie fährt dabei etwa dreizehn Millionen Property-Writes auf dem Haupt-Thread. Praktische Konsequenzen: fromBase64 bevorzugen, wo Ihr Publikum es hat; den atob-Helfer behalten, wo es nicht da ist; ein Byte-Array niemals durch String-Konkatenation in einer Schleife bauen; und wenn Sie einen riesigen Payload verarbeiten müssen, überlegen Sie, die dekodierte Uint8Array einem Web Worker zu übergeben - die Bytes übertragen sich ohne Kopieren, und der Haupt-Thread bleibt frei, um die UI bei 60 Frames pro Sekunde zu halten. Und denken Sie an die Richtung der Rechnung: Dekodieren schrumpft, also belegt ein dekodierter Buffer immer weniger Speicher als der String, aus dem er stammt. Beim Dekodieren stößt man nie an die Speichergrenze; man platzt nur aus dem Speicher, wenn man String und Bytes länger umherträgt, als man muss.
Eine kurze Geschichte des Dekodierens in Browsern
Base64 ist älter als der Großteil des modernen Webs, aber die Dekoder der Browser haben eine Geschichte, die sich zu kennen lohnt, denn sie erklärt, warum das Ökosystem voller Relikte ist. atob und sein Geschwister btoa sind älter als die Spezifikation, die sie heute abdeckt: Der WHATWG-HTML-Standard definierte sie erst im Februar 2011, als ihr langjähriges Browser-Verhalten rückwärts in den Standard eingearbeitet wurde. Die Engines hatten sie ohnehin früh ausgeliefert: Firefox ab Version 1 im Jahr 2004, Safari 3, Chrome 4. Internet Explorer übersprang sie komplett bis IE 10 im Jahr 2012, darum ist JavaScript von vor 2012 ein Museum handgebauten Base64 - Nachschlagetabellen, String.fromCharCode-Gymnastik und die berüchtigte unescape(encodeURIComponent())-Beschwörung für Unicode, ein Paar Funktionen, die in der Sprache deprecated wurden und in Browsern ein Jahrzehnt lang aus bloßer Trägheit überlebten. Dann kam die Zeichensatz-Ebene: TextEncoder und TextDecoder aus dem Encoding-Standard trafen zwischen 2013 und 2017 ein (Firefox 18, Chrome 38, Safari 10.1, und nie in irgendeinem IE) und gaben der Plattform endlich eine prinzipienbasierte Art, Bytes in Wörter zu verwandeln. Node.js, das bis Version 16 im Jahr 2021 niemals atob oder btoa als Globals hatte, verbrachte seine frühen Jahre mit Buffer und einem Paar kleiner npm-Shims. Und dann schloss sich der Kreis: Firefox 133 (November 2024) und Safari 18.2 (Dezember 2024) lieferten zuerst Uint8Array.fromBase64, toBase64 und Freunde aus, und die zweite Hälfte von 2025 schloss die Reihe ab, als Chrome 140 (September) und Node 25 (Mitte Oktober) landeten und das Baseline-Programm sie als Newly available markierte, das erste Mal, dass die Sprache selbst - nicht die Web-Plattform - Base64 eingebaut bekam. Ein Jahrzehnte altes Format wurde gerade zu einer Standard-Bibliotheksfunktion der Sprache, und das nächste Jahrzehnt an Code darf aufhören, Helfer umherzukopieren.
Spannende Fakten
- Der schnellste "ist das überhaupt Base64?"-Test, der existiert, ist
string.length % 4 === 0. Jede gültige, gepaddete Base64-Zeichenkette besteht ihn; alles andere ist ein Fremder. atob('')gibt''zurück. Die leere Zeichenkette ist die einzige Eingabe ohne Bytes, und sie roundtrippt sauber durch die ganze Pipeline - niemals braucht es einen Sonderfall.- Die magische WebSocket-GUID,
258EAFA5-E914-47DA-95CA-C5AB0DC85B11, ist ein fester Wert, in den RFC eingebrannt, gewählt so, dass ein simpler HTTP-Server den Handshake nie versehentlich abschließen kann. Sie ist die berühmteste Konstante im Protokoll-Engineering, die niemand je erzeugt. - Chrome und Firefox werfen für denselben Fehler dieselbe Ausnahme, aber mit verschiedenen Meldungen. Fangen Sie nach
error.nameab, nicht nach dem Meldungs-String, sonst bekommt Ihr Fehlerhandling einen Browser-Akzent. - Ein Binär-String von einem Megabyte wiegt im Speicher zwei Megabyte, denn JavaScript-Strings sind UTF-16: Jedes dekodierte Byte schleppt ein Byte ungenutzten Spielraum hinterher. Die
Uint8Arraykennt keine solche Steuer. - "Data URI" ist ein ausrangierter Name. Das WHATWG hat ihn im Zuge der großen URI-zu-URL-Harmonisierung zu "data URL" umbenannt, darum treffen Sie beide Schreibweisen in Spezifikationen, Posts und Paketnamen.
- RFC 4648 bringt eine Tabelle von Testvektoren mit - "f", "fo", "foo", "foob", "fooba", "foobar" und Freunde, jeweils mit ihrer bekannten Kodierung -, gegen die Decoder-Autoren seit zwanzig Jahren prüfen. Wenn Ihr Decoder diese Zeilen besteht, ist er mit ziemlicher Sicherheit korrekt.
- Die am häufigsten produzierte Base64-Zeichenkette in der Geschichte der Informatik ist mit ziemlicher Sicherheit
aGVsbG8=, die Kodierung von "hello". Jedes "Loslegen"-Tutorial, jede Test-Suite und jede Stack-Overflow-Antwort auf dem Planeten gibt ihre Stimme ab.
Zum Abschluss
So passt die ganze Kunst des Dekodierens im Browser auf eine Seite: atob() für das schnelle, nachsichtige, universelle Dekodieren; TextDecoder, um die Bytes in die Wörter zu verwandeln, die Sie tatsächlich wollen, mit fatal: true, wenn die Daten wichtig sind; und Uint8Array.fromBase64 für den modernen, strengen, schnellen Weg, der den String komplett überspringt. Dazwischen haben die Varianten Namen und Regeln: base64url für alles, was in einer URL reist, Padding, das da sein darf oder auch nicht, Leerraum, den der alte Decoder still und leise frisst. Und darunter all dem zwei Haltungen: Bytes sind nicht der Text, und der Text ist nicht das Geheimnis. Dekodieren Sie mit Absicht, verifizieren Sie, bevor Sie vertrauen, und wenn der Kanal es erlaubt, überspringen Sie Base64 und nehmen Sie die Bytes.
Die andere Hälfte der Reise - Ihre Bytes und Ihren Text nehmen und in den druckbaren String verwandeln, mit dem all dies begann - ist im Begleitguide zur Base64-Kodierung in JavaScript im Detail abgedeckt, verlinkt unten.
Zuletzt aktualisiert: 2026-09-08
Verwandter Artikel: Base64-Kodierung in JavaScript/Browser: Ein vollständiger Leitfaden