[UNICODE] 9 Min. Lesezeit

[UNICODE] Base64 für UTF-8 und Unicode

btoa('héllo') scheitert. btoa('日本語') scheitert. Das ist kein Bug — es ist eine API aus den 1990ern, die mit Text aus den 2020ern kollidiert. Hier ist der richtige Weg, Unicode per Base64 zu kodieren, Byte für Byte.

April 2026 | encoding

// DIE FALLE

btoa() im Browser wirkt harmlos. Man übergibt einen String, bekommt Base64 zurück. Dann gibt ein Nutzer eines Tages seinen Namen ein — Renée — und Ihr Code stürzt mit InvalidCharacterError: The string to be encoded contains characters outside of the Latin1 range ab.

Das ist keine Marotte, und es ist nicht allein JavaScripts Schuld. btoa() wurde in einer Zeit spezifiziert, in der "String" und "Bytes" als dasselbe behandelt wurden, solange man unter U+00FF blieb. Unicode hat die Welt erobert, doch der Vertrag von btoa() ist bei Latin-1 eingefroren. Um irgendetwas jenseits westeuropäischer Zeichen per Base64 zu kodieren, müssen Sie zuerst den Umweg über UTF-8 nehmen.

// DIE 30-SEKUNDEN-LÖSUNG

// Browser — encode any Unicode string to Base64
function encodeUtf8Base64(str) {
  const bytes = new TextEncoder().encode(str);           // UTF-8 bytes
  let bin = '';
  for (const b of bytes) bin += String.fromCharCode(b);  // Latin-1 wrapper
  return btoa(bin);                                       // now btoa can handle it
}

// Browser — decode Base64 back to a Unicode string
function decodeUtf8Base64(b64) {
  const bin = atob(b64);
  const bytes = Uint8Array.from(bin, c => c.charCodeAt(0));
  return new TextDecoder().decode(bytes);                // UTF-8 → string
}

// Usage
encodeUtf8Base64('Renée');         // 'UmVuw6ll'
encodeUtf8Base64('日本語');         // '5pel5pys6Kqe'
encodeUtf8Base64('👋 hello');       // '8J+RiyBoZWxsbw=='

decodeUtf8Base64('5pel5pys6Kqe');   // '日本語'

// WARUM DAS FUNKTIONIERT — EIN KURZER UMWEG DURCH UTF-8

JavaScript-Strings sind Sequenzen von UTF-16-Code-Units. Das Zeichen ist U+65E5, eine einzelne 16-Bit-Code-Unit. Das Emoji 👋 (U+1F44B) liegt außerhalb der Basic Multilingual Plane und wird durch ein Surrogate-Paar aus zwei 16-Bit-Code-Units dargestellt.

btoa() benötigt Bytes, keine Code-Units. Und nicht irgendwelche Bytes — es verlangt Latin-1-Bytes im Bereich 0–255. UTF-16-Code-Units überschreiten routinemäßig 255 (etwa = 0x65E5 = 26085), weshalb btoa() scheitert.

UTF-8 ist eine 8-Bit-saubere Kodierung des vollständigen Unicode-Zeichensatzes. Jedes Unicode-Zeichen wird zu 1–4 Bytes, und jedes Byte liegt garantiert im Bereich 0–255. Wenn wir unseren String zuerst in UTF-8-Bytes umwandeln und dann btoa() vormachen, diese Bytes seien ein Latin-1-String (ein Zeichen pro Byte), passt alles zusammen: btoa() sieht Bytes, die es verarbeiten kann, und der Empfänger dekodiert dieselben Bytes wieder zu UTF-8.

// WAS TextEncoder UNTER DER HAUBE MACHT

TextEncoder ist der standardbasierte Weg, einen JavaScript-String in UTF-8-Bytes zu verwandeln. Er ist seit ca. 2017 in jedem Browser verfügbar und in Node.js 11+ ein Global.

Ein kurzer Blick auf die erzeugten Byte-Sequenzen:

new TextEncoder().encode('A');
// Uint8Array [ 0x41 ]                         (1 byte)

new TextEncoder().encode('é');
// Uint8Array [ 0xC3, 0xA9 ]                   (2 bytes)

new TextEncoder().encode('日');
// Uint8Array [ 0xE6, 0x97, 0xA5 ]             (3 bytes)

new TextEncoder().encode('👋');
// Uint8Array [ 0xF0, 0x9F, 0x91, 0x8B ]       (4 bytes)

new TextEncoder().encode('Renée');
// Uint8Array [ 0x52, 0x65, 0x6E, 0xC3, 0xA9, 0x65 ]   (6 bytes for 5 chars)

// NODE.JS: Buffer IST STANDARDMÄSSIG UTF-8-FÄHIG

Nodes Klasse Buffer erledigt die UTF-8-Umwandlung intern. Sie benötigen nie TextEncoder; die Übergabe eines Strings an Buffer.from() kodiert ihn als UTF-8, sofern Sie nicht ausdrücklich eine andere Kodierung wählen. Deshalb ist serverseitiges Base64 in Node ein Einzeiler:

// Encode
Buffer.from('日本語').toString('base64');
// '5pel5pys6Kqe'

// Decode
Buffer.from('5pel5pys6Kqe', 'base64').toString();
// '日本語'

// Explicit UTF-8 (the default, but clearer)
Buffer.from('Renée', 'utf8').toString('base64');
// 'UmVuw6ll'

// UNICODE-BASE64 IN JEDER SPRACHE

// Python
import base64
base64.b64encode('日本語'.encode('utf-8'))
# b'5pel5pys6Kqe'
base64.b64decode('5pel5pys6Kqe').decode('utf-8')
# '日本語'

// PHP
base64_encode('日本語');          // '5pel5pys6Kqe'
base64_decode('5pel5pys6Kqe');     // '日本語'
// (PHP strings are byte strings, so UTF-8 is handled implicitly
// if your source file is saved as UTF-8.)

// Java
import java.util.Base64;
import java.nio.charset.StandardCharsets;
Base64.getEncoder().encodeToString("日本語".getBytes(StandardCharsets.UTF_8));
// '5pel5pys6Kqe'
new String(Base64.getDecoder().decode("5pel5pys6Kqe"), StandardCharsets.UTF_8);
// '日本語'

// Go
import "encoding/base64"
base64.StdEncoding.EncodeToString([]byte("日本語"))
// '5pel5pys6Kqe'
b, _ := base64.StdEncoding.DecodeString("5pel5pys6Kqe")
string(b)
// '日本語'

// Ruby (source file must be UTF-8)
require 'base64'
Base64.strict_encode64('日本語')     # '5pel5pys6Kqe'
Base64.decode64('5pel5pys6Kqe')      # '日本語'

// Rust
use base64::{Engine, engine::general_purpose::STANDARD};
STANDARD.encode("日本語");            // "5pel5pys6Kqe"
STANDARD.decode("5pel5pys6Kqe")
  .map(|b| String::from_utf8(b).unwrap())?;  // "日本語"

// Shell (bash/zsh with GNU coreutils)
echo -n '日本語' | base64                   # '5pel5pys6Kqe'
echo -n '5pel5pys6Kqe' | base64 -d          # '日本語'

// HÄUFIGE UNICODE-STOLPERFALLEN

  • > Mojibake beim Dekodieren — Sie haben direkt mit atob() dekodiert und der Empfänger sieht Müll. Fix: mit TextDecoder umhüllen.
  • > Surrogate-Paar-Fehler — Ihr Code zerlegt Strings zeichenweise und trifft die Mitte eines 👋-Surrogate-Paars. Fix: TextEncoder auf den gesamten String anwenden, nicht Zeichen für Zeichen.
  • > BOM-Probleme — Ihr Quelltext beginnt mit einem UTF-8-BOM (EF BB BF). Es wird mit allem anderen kodiert und der Empfänger sieht ein verirrtes \uFEFF. BOMs vor dem Kodieren entfernen.
  • > Falsche Charset-Metadaten — Sie packen Base64 aus UTF-8-Bytes in ein Feld mit dem Label charset=iso-8859-1. Der Decoder interpretiert die Bytes gemäß dem Label. Richten Sie die Metadaten stets an der tatsächlichen Kodierung aus.
  • > URL-sicheres Base64 verwechselt — Sie haben base64url für die Speicherung genutzt, aber vergessen, vor dem Dekodieren das Standardalphabet wiederherzustellen. Fix: mit replace(/-/g, '+').replace(/_/g, '/') normieren und auf ein Vielfaches von 4 padden.
  • > Locale-abhängige Kodierung — in Python 2 war '日本語'.encode() standardmäßig ASCII und stürzte ab. In jeder modernen Laufzeit stets explizit utf-8 angeben.

// ROUND-TRIP-TEST — DER ENDGÜLTIGE BEWEIS

Die beste Möglichkeit zu prüfen, ob Ihre Base64-Pipeline Unicode korrekt verarbeitet, ist ein Round-Trip-Test. Kodieren Sie einen bekannten String, dekodieren Sie ihn und vergleichen Sie Byte für Byte mit dem Original.

// Vitest / Jest / any test runner
const samples = [
  'hello',
  'héllo',
  '日本語',
  '한국어',
  '👋 Renée — 日本語 — مرحبا',
  '\n\t\0 edge cases',
  '𐀀 ancient greek (U+10000)',
];

for (const s of samples) {
  const encoded = encodeUtf8Base64(s);
  const decoded = decodeUtf8Base64(encoded);
  expect(decoded).toBe(s);
  expect([...new TextEncoder().encode(s)])
    .toEqual([...new TextEncoder().encode(decoded)]);
}

// BASE64 VON BINÄR VS. BASE64 VON TEXT

Ein feiner, aber wichtiger Unterschied. Wenn Sie eine PNG-Datei Base64-kodieren, ist die Eingabe bereits Bytes — es gibt keine Unicode-Frage zu klären. Einfach die Bytes direkt an den Encoder übergeben.

Wenn Sie einen String Base64-kodieren, ist die Eingabe eine Folge von Unicode-Codepunkten, keine Bytes. Sie müssen sich auf eine Byte-Kodierung festlegen (2026 praktisch immer UTF-8), bevor der Encoder überhaupt etwas sehen kann. Der erzeugte Base64-String ist nur dann gültig, wenn der Empfänger dieselbe Byte-Kodierung rückgängig macht.

Hier liegt die Ursache der meisten Tickets vom Typ "warum ist mein Base64 falsch": zwei Systeme haben denselben logischen String mit unterschiedlichen zugrundeliegenden Byte-Kodierungen Base64-kodiert (eines UTF-8, eines UTF-16 oder eines Latin-1), und der Decoder des Empfängers liefert pflichtbewusst die falschen Bytes zurück.

// SOLL ICH IMMER UTF-8 VERWENDEN?

Ja, außer Sie arbeiten in einem Altsystem, das etwas anderes vorschreibt. UTF-8 ist:

• Die dominante Kodierung im Web (> 97% der Webseiten im Jahr 2026)
• Standard in der Standardbibliothek jeder modernen Programmiersprache
• ASCII-kompatibel für die ersten 128 Codepunkte, sodass Englisch niemals bricht
• Für jedes Unicode-Zeichen wohldefiniert und round-trip-fähig
• Der von der IETF empfohlene Zeichensatz für neue Protokolle (RFC 2277)

Die alten Alternativen (UTF-16, UTF-32, Latin-1, GBK, Shift-JIS) tauchen noch in Alt-Datenbanken, Windows-APIs und manchen Telco-Protokollen auf. Stammen Ihre Daten aus einem solchen System, wandeln Sie an der Schnittstelle in UTF-8 um, Base64-kodieren Sie das UTF-8 und dokumentieren Sie die Kodierungsentscheidung im Payload-Format.

// DER FALLBACK OHNE TextEncoder (ALTE BROWSER)

Müssen Sie einen sehr alten Browser ohne TextEncoder unterstützen (IE11 und älter), ist der klassische Trick, encodeURIComponent als UTF-8-Encoder zu missbrauchen und dann zu einem Byte-String zurückzuentfluchten. Dieses Muster findet sich in jeder StackOverflow-Antwort zu Base64 und Unicode zwischen 2008 und 2020.

// Legacy browser fallback (pre-2017)
function encodeUtf8Base64Legacy(str) {
  return btoa(unescape(encodeURIComponent(str)));
}
function decodeUtf8Base64Legacy(b64) {
  return decodeURIComponent(escape(atob(b64)));
}

encodeUtf8Base64Legacy('日本語');    // '5pel5pys6Kqe'
decodeUtf8Base64Legacy('5pel5pys6Kqe'); // '日本語'

// Note: unescape() and escape() are deprecated.
// Only use this pattern if TextEncoder is genuinely unavailable.
// It also mishandles some edge-case surrogates.

// WAS MAN SICH MERKEN SOLLTE

  • > Base64 kodiert Bytes, keine Zeichen. Wählen Sie zuerst eine Byte-Kodierung — fast immer UTF-8.
  • > Im Browser: TextEncoderString.fromCharCodebtoaeine Richtung.
  • > Im Browser: atobUint8Array.fromTextDecoderdie andere Richtung.
  • > In Node.js: Buffer.from(str).toString('base64') und Buffer.from(b64, 'base64').toString() — beide standardmäßig UTF-8.
  • > In Python / Go / Java: übergeben Sie .encode() / .getBytes() stets explizit 'utf-8'.
  • > Machen Sie vor der Auslieferung stets einen Round-Trip-Test mit echtem Unicode (Emoji, CJK, Arabisch, kombinierende Zeichen).

// VERWANDTES

Was ist Base64 und wie funktioniert es? — die 6-Bit-Gruppierung, die allem hier zugrunde liegt.
Base64 in JavaScript (Browser + Node) — vollständiger laufzeitübergreifender Helfer.
URL-sicher vs. Standard-Base64 — eine orthogonale Entscheidung, die Sie ebenfalls treffen müssen.
Live-Base64-Encoder ausprobieren — dieselbe Unicode-sichere Pipeline, in Ihrem Browser.