Base64-Dekodierung in PHP: Ein vollständiger Leitfaden
Taucht sie in einem Support-Ticket, einem API-Log, einer Konfigurationsdatei oder mitten in einer URL auf: eine lange Kette aus Buchstaben, Ziffern, dem gelegentlichen + oder / und vielleicht einem = oder zwei am Ende. Man erkennt sie im Handumdrehen. Base64 ist ein Format von Binärdaten nach Text: Es schreibt jeweils drei Bytes roher Daten in vier Zeichen um, die aus einem Alphabet von 64 Zeichen stammen, und ein paar =-Zeichen runden den Schwanz ab, wenn die Byteanzahl kein Vielfaches von drei ist. Dekodieren ist die schrumpfende Richtung dieses Tauschs: Vier Zeichen gehen hinein, drei Bytes kommen heraus. Die Startseite dieser Seite führt das Format Schritt für Schritt vor, daher gibt dieser Artikel seine Energie dorthin, wo sie hingehört: auf die PHP-Seite des Jobs.
Erst die Schlagzeilen. PHP liefert einen Base64-Decoder seit PHP 4 im Kern mit. base64_decode() braucht keine Erweiterung, kein Composer-Paket und keine Konfiguration, und es läuft überall, wo PHP läuft. Die weniger gute Nachricht: Die Standardlaune schluckt beschädigte Eingaben munter hin und reicht einem ohne ein Wort Müll zurück. Die gute Nachricht wird besser: Ein einziges Flag ($strict) macht aus der Funktion einen richtigen Türsteher, und sobald man weiß, wie man die Laune auswählt, die Eingabe auf Echtheit prüft und die Bytes wieder in Bedeutung übersetzt, hört Base64 auf, eine Quelle rätselhafter Bugs zu sein, und wird zu einer Routine, die man automatisieren kann.
Ein kurzer Hinweis zur Größe: Dekodieren schrumpft Daten um etwa ein Viertel (drei Bytes raus für alle vier Zeichen rein), die Ausgabe belegt also immer weniger Speicher als die Eingabe. Dass ein Dekodieren explodiert, muss man nie fürchten. Und nun lernen wir das Werkzeug kennen.
Die Funktion, die den Job macht
Hier ist die vollständige Signatur, genau so, wie sie das moderne PHP meldet:
base64_decode(string $string, bool $strict = false): string|false
Drei Wörter in dieser Zeile erledigen die ganze Arbeit. $string hat kein Größenlimit: Ein Megabyte wird in gut unter einer Millisekunde dekodiert, also steht nichts im Wege, eine komplette Datei in einem einzigen Aufruf zu dekodieren. Der Rückgabewerttyp benennt den ganzen Vertrag: entweder ein String aus dekodierten Bytes oder false. Es gibt keine Ausnahmen, keine Fehlercodes, keinen zweiten Kanal. false ist das einzige Signal, das man bekommt, also gehört das Prüfen davon zum Job. Und ein Satz aus dem Manual verdient es, auswendig gelernt zu werden: die zurückgegebenen Daten können binär sein. Im Moment, in dem das Ergebnis ein PNG, ein ZIP oder einen Hash enthält, ist es in keinem lockeren Sinne ein "Text-String", und PHP lässt einen trotzdem gern so tun, als wäre es einer. Diese Flexibilität ist Superkraft und Falle zugleich, und die folgenden Abschnitte halten sie im Zaum.
Ein schneller Rundflug über die Versionsstempel, denn geerbter Code hat die Angewohnheit, Dinge vorauszusetzen. Die Funktion ist seit PHP 4 im Kern. Ihr $strict-Parameter kam in PHP 5.2.0, im November 2006. Seit PHP 8.0 trägt die Signatur echte native Typen (das string und bool oben, plus der Rückgabewert string|false), damit wissen IDEs und statische Analyser endlich, dass die Funktion scheitern kann. Seit PHP 8.1 löst das Übergeben von null eine Deprecation-Hinweisung aus; wenn man mit der Eingabe "nichts" meint, schreibt man explizit '':
$decoded = base64_decode('');
var_dump($decoded); // string(0) ""
Strict-Modus oder stille Aufräumarbeit
Das $strict-Flag ist ein Schalter zwischen zwei sehr verschiedenen Persönlichkeiten. Aus (der Standard) ist der Decoder ein freundlicher Vergesslicher: Jedes Zeichen außerhalb des Base64-Alphabets wird stillschweigend verworfen, der Rest wird dekodiert, und niemand wird informiert. Das Manual sagt es offen: Andernfalls werden ungültige Zeichen stillschweigend verworfen. An ist der Decoder ein Türsteher: Das erste Zeichen, das er nicht erkennt, beschert dem gesamten Payload ein false.
Hier ist der Schadensbericht. Jede Zeile unten ist echtes Verhalten von base64_decode() unter PHP 8.x:
| Eingabe | Nachgiebig (Standard) | Strict |
|---|---|---|
Zm9vYmFy, sauber |
"foobar" |
"foobar" |
Zm9v\r\nYmFy, CRLF mitten in der Zeichenkette |
"foobar" |
"foobar" |
" Zm9vYmFy ", Leerzeichen an beiden Enden |
"foobar" |
"foobar" |
Zm9v\x0bYmFy, vertikaler Tab |
"foobar" |
false |
Zm9v\x00YmFy, eingebettetes NUL-Byte |
"foobar" |
false |
V@hpcy, eingeschlichenes @ |
3 Bytes Müll | false |
Zm9vY, fünf Zeichen |
"foo", letztes Zeichen verworfen |
false |
Z, ein einzelner Buchstabe |
"", ein leerer String |
false |
=Zm9, Padding ganz vorne |
"fo" |
false |
Zm9vYmFy==, Pads nach einer vollen Gruppe |
"foobar" |
false |
Zm9vYmFy==A, Daten nach den Pads |
"foobar" |
false |
Zm9vYmF, sieben Zeichen, keine Pads |
"fooba" |
"fooba" |
Drei Zeilen verdienen einen zweiten Blick. Die V@hpcy-Zeile zeigt, warum der nachgiebige Modus überall dann gefährlich ist, wenn die Eingabe nicht vertrauenswürdig ist: Das eingeschlichene @ stoppt das Dekodieren nicht, es verschwindet nur, und die drei Bytes, die herauskommen, bedeuten nichts. Die Zeile mit dem einzelnen Z zeigt, dass ein leeres Ergebnis fast nichts beweist; ein Payload mit nur einem Zeichen "dekodiert" in einen leeren String, ohne zu scheitern. Die Zm9vYmFy==A-Zeile zeigt, wie gern der Decoder Daten ignoriert, die nach dem Padding auftauchen, und genau so kann ein abgeschnittener oder manipulierter Payload völlig unschuldig aussehen.
Was lässt der Strict-Modus noch durch? Genau vier Leerraum-Zeichen: Leerzeichen, Tabulator, Wagenrücklauf und Zeilenvorschub, an jeder Position, auch direkt neben den =-Zeichen. Das ist beabsichtigt. MIME-umwickelte E-Mail-Payloads tragen CRLF-Zeilenumbrüche innerhalb des kodierten Streams, und der Strict-Modus kaut sie ohne jede Vorausverarbeitung herunter (der E-Mail-Abschnitt unten erklärt, warum). Alles andere, was kein Alphabetzeichen ist, von NUL-Bytes bis zu vertikalen Tabs, verdient ein false.
Es gibt eine echte Nachgiebigkeit, die man kennen sollte, auch wenn das keine typisch PHP-Eigenschaft ist: PHP füllt fehlendes Padding stillschweigend nach. Der sieben Zeichen lange Payload Zm9vYmF (gar keine Pads) dekodiert zu "fooba", genau wie sein gepaddeder Cousin Zm9vYmF=, in beiden Launen. RFC 4648 verlangt im allgemeinen Fall Padding, also ist das Akzeptieren eines ungepaddeten Schwanzes eine bewusste Lockerung, und sie ist nicht spezifisch für PHP: Das RawStdEncoding von Go und der Decoder von Java akzeptieren dieselbe ungepaddete Eingabe. Wenn Ihre PHP-Seite und ein Partnersystem sich bei einem Edge-Case-Payload nicht einigen, ist ein fehlendes Pad meist der Ort, an dem man suchen sollte.
Der Standard ist mit der strict Laune einverstanden. RFC 4648, Abschnitt 3.3, besagt es: Implementierungen müssen kodierte Daten, die Zeichen außerhalb des Alphabets enthalten, verwerfen, es sei denn, die umgebende Spezifikation sagt etwas anderes (MIME ist der klassische "sagt etwas anders"-Fall). Derselbe Abschnitt erklärt auch, warum: Zeichen außerhalb des Alphabets lassen sich als verdeckter Kanal ausnutzen, um Information in Zeichen zu verstecken, die der Decoder ohnehin verwirft, und genau das wurde schon genutzt, um Decoder-Bugs auszulösen. Wenn die Eingabe aus der Außenwelt kommt, ist der Strict-Modus keine Stilfrage. Er ist das, was der Standard verlangt.
Nachweisen, dass ein Payload Base64 ist
Ein Decoder, der leise scheitern kann, verdient eine Validierungs-Pipeline vor sich her. Drei Ebenen, die jede fängt, was die anderen übersehen.
Ebene eins ist eine Formprüfung mit einem regulären Ausdruck: nur Alphabetzeichen, und höchstens zwei Pads ganz am Ende.
$shapeLooksPlausible = preg_match('/^[A-Za-z0-9+\/]*={0,2}$/', $payload) === 1;
Der reguläre Ausdruck fängt offensichtlichen Müll (versehentliche Leerzeichen, @-Zeichen, ein Pad mitten in der Zeichenkette), bevor irgendetwas anderes läuft. Er ist aber kein Validator: Er sieht nicht, dass Zm9vYmFy= neun Zeichen mit einem Pad sind, was der Strict-Modus ebenfalls ablehnt. Genau deshalb existiert Ebene zwei. Das strikte Dekodieren ist der einzige Check, der die Semantik von Base64 versteht, und deshalb hat es das letzte Wort.
Ebene drei ist die, die jeder vergisst: false explizit behandeln, denn es ist das einzige Signal, das man bekommt.
function decode_payload(string $payload): string
{
$clean = str_replace(["\r", "\n"], '', $payload);
$decoded = base64_decode($clean, true);
if ($decoded === false) {
throw new InvalidArgumentException('Not a valid Base64 payload.');
}
return $decoded;
}
Das str_replace() ganz am Anfang ist ein optionaler Komfort: Der Strict-Modus toleriert CRLF ohnehin, aber das Entfernen hält jede spätere Längenrechnung sauber, denn die Zeichenanzahl eines sauberen Payloads ist immer ein Vielfaches von vier. (Eines mehr als ein Vielfaches von vier, wie fünf oder neun, ist in Base64 unmöglich, und der Strict-Modus wird es ablehnen.) Beachten Sie, dass die Funktion nie von sich aus wirft; der Check ist der, den man selbst schreibt.
URL-sicheres Base64
In der Wildbahn trifft man auf ein zweites Alphabet, und genau das beißt. Standard-Base64 verwendet + und /, zwei Zeichen, die in URLs Ärger machen: Ein + in einem Query-String wird noch vor PHP als Leerzeichen interpretiert, und / ist ein Pfadtrenner. RFC 4648, Abschnitt 5, definiert die Lösung: das für URLs und Dateinamen sichere Alphabet, in dem + zu - wird, / zu _, und das =-Padding am Ende meist weggelassen wird, um Zeichen zu sparen. Der RFC stellt unmissverständlich klar, dass dies "nicht als das Gleiche wie die base64-Kodierung betrachtet werden sollte", und der Name, den man am häufigsten hört, ist base64url. JSON Web Tokens, OAuth-State-Parameter, API-Session-IDs und die URLs von Video-Seiten leben alle in diesem Dialekt.
Die Decoder-Seite ist in zwei Schritten erledigt: Das Alphabet zurücktauschen und dann fehlendes Padding wiederherstellen. Hier ist der Helfer, den man am Ende überall wiederverwenden wird:
function base64url_decode(string $data): string|false
{
$standard = strtr($data, '-_', '+/');
$missing = strlen($standard) % 4;
if ($missing !== 0) {
$standard .= str_repeat('=', 4 - $missing);
}
return base64_decode($standard, true);
}
var_dump(base64url_decode('aGk_PnRoZXJl')); // string(9) "hi?>there"
Das moderne PHP ist hier auf Ihrer Seite: Es füllt fehlendes Padding nach, also ist die explizite Wiederherstellung doppelt gemoppelt (und sie hält den Code auch für ältere PHP-Versionen portabel). Die Gefahrenrichtung ist einseitig. Gibt man URL-sicheren Text in den Standard-Decoder im nachgiebigen Modus, so sind die Zeichen - und _ einfach nicht im Standard-Alphabet enthalten und werden daher verworfen. Die Ausgabe kommt kürzer heraus, als sie sollte, ohne Fehler, ohne Hinweis, ohne irgendetwas. Führen Sie den strtr()-Tausch immer zuerst aus, oder besser: Gehen Sie immer über den Helfer.
Ein ehrlicher Hinweis: Enthält ein URL-sicherer Payload zufällig weder - noch _, dann sind die beiden Alphabete für genau diese Daten byte-identisch, und es spielt keine Rolle, welchen Decoder man verwendet hat. Die Gefahr erscheint nur, wenn diese Zeichen vorhanden sind, denn das ist der einzige Ort, an dem sich die Alphabete unterscheiden.
Text, Bytes und Zeichensätze
Base64 hat keine Ahnung, was Ihre Bytes bedeuten, und der PHP-Decoder erbt diese Blindheit. Der Codec ist zeichensatz-blind: Er gibt dieselben 8-Bit-Werte zurück, die hinein gingen, egal ob es UTF-8-Text, Windows-1252-Text, ein JPEG oder ein Hash ist. PHP selbst ist derselben Meinung: Ein String ist eine Folge von Bytes, nichts weiter. Sobald man das Ergebnis anzeigen oder mit anderem Text vergleichen will, muss jemand zwei Fragen beantworten: Ist das überhaupt Text, und wenn ja, in welchem Zeichensatz?
Der praktische Test hat zwei Eimer. Binärdaten verkünden sich fast immer mit NUL- und niedrigen Steuerbytes, und Text, der kein gültiges UTF-8 ist, ist der zweite Eimer. Die mbstring-Erweiterung (nicht standardmäßig aktiviert) liefert den strengen UTF-8-Check:
function looks_binary(string $bytes): bool
{
if ($bytes === '') {
return false;
}
if (strpbrk($bytes, "\x00\x01\x02\x03\x04") !== false) {
return true;
}
return !mb_check_encoding($bytes, 'UTF-8');
}
var_dump(looks_binary("\x89PNG\r\n\x1a\n...png body")); // bool(true)
var_dump(looks_binary("héllo wörld, 日本語")); // bool(false)
Wenn der Payload Text in einem veralteten Zeichensatz ist, wandeln Sie ihn um, bevor er Ihr HTML berührt. Windows-1252 ist die gebräuchlichste Legacy-Kodierung für Web- und Desktop-Daten, und der Unterschied zwischen ihr und einfachem ISO-8859-1 entscheidet darüber, ob das Byte 0x93 ein geschwungenes Anführungszeichen oder ein unsichtbares Steuerzeichen ist:
// "café" in Windows-1252: Das é ist ein einzelnes Byte, 0xE9
$legacy = base64_decode('Y2Fm6Q==', true);
$utf8 = mb_convert_encoding($legacy, 'UTF-8', 'Windows-1252');
var_dump($utf8); // string(5) "café": Das é ist jetzt zwei UTF-8-Bytes
Eine Warnung vor dem berühmten mb_detect_encoding(): Das PHP-Manual selbst sagt, dass automatische Erkennung "nie vollständig zuverlässig sein kann", und vergleicht sie damit, eine Nachricht ohne den Schlüssel zu entschlüsseln. Speist man es mit einem Windows-1252-"café", kann es Windows-1252 sagen; speist man es mit einem PNG-Kopf, kann es fröhlich wieder Windows-1252 sagen, denn die Zeichensatz-Familie ISO-8859 ist für jeden möglichen Byte-Wert definiert und kann daher mit allem übereinstimmen. Behandeln Sie Erkennung als letzten Ausweg, vertrauen Sie einem deklarierten Zeichensatz (ein Header, eine Konfigurationszeile, eine Datenbank-Kollation), wann immer einer existiert, und setzen Sie den Rest standardmäßig auf UTF-8 oder binär.
Wenn der Payload eine Datei ist
Der häufigste Datei-Job ist die Umkehrung dessen, was irgendeine Export-Routine getan hat: Eine .b64-Textdatei trifft ein, und man braucht die Originaldatei zurück. Mit strengem Dekodieren und einem false-Check ist das schon produktionsreif:
$encoded = file_get_contents('/var/www/uploads/blob.b64');
$decoded = base64_decode($encoded, true);
if ($decoded === false) {
http_response_code(400);
exit('That upload is not valid Base64.');
}
PHP-Strings sind einfach nur Bytes, also kümmert sich nichts auf diesem Weg darum, ob der Payload eine Textdatei, ein ZIP-Archiv oder ein Video ist. Die Größenrechnung spielt einem in die Karten: Die dekodierte Ausgabe ist drei Viertel so lang wie die kodierte Eingabe, also verschlechtert Dekodieren den Speicher nie.
Eine gute Gewohnheit ist, die Bytes sich selbst ankündigen zu lassen, bevor man irgendeinem Etikett vertraut. Die finfo-Klasse (die fileinfo-Erweiterung, in Standard-PHP-Builds enthalten) sagt einem, was die Daten tatsächlich sind:
$mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($decoded);
var_dump($mime); // string(9) "image/png"
$extensions = ['image/png' => 'png', 'application/pdf' => 'pdf', 'application/zip' => 'zip'];
$ext = $extensions[$mime] ?? 'bin';
$target = '/var/www/uploads/file-' . bin2hex(random_bytes(4)) . '.' . $ext;
file_put_contents($target, $decoded);
Dieser letzte Schritt ist wichtiger, als er aussieht. Ein Payload, der behauptet, ein Bild zu sein, aber in etwas anderes dekodiert, ist genau die Art von Sache, die eine zweite Meinung auffängt. Und wenn man die wiederhergestellte Datei später an einen Browser ausliefert, sollte das Content-Type, das man sendet, aus demselben finfo-Check stammen, nicht aus dem Dateinamen.
Data-URIs, das Format aus der Zwischenablage
Ein beliebtes Ankunftsszenario: Jemand fügt ein Bild in ein Formular ein, und das Frontend reicht einen vollständigen Data-URI: data:image/png;base64,iVBORw0KGgo.... RFC 2397 definiert die Form: data:, ein optionaler Medientyp, ein optionales ;base64-Flag, ein Komma und dann die Daten. Ist das Flag vorhanden, ist der Payload Base64; fehlt es, ist der Payload percent-kodierter Klartext, seltener, aber legal. Wird der Medientyp weggelassen, ist der Standard text/plain;charset=US-ASCII. Warum hier überhaupt Base64? Weil ein URI keine rohen Bytes oder Kommas sicher enthalten kann, und Base64 liefert genau ein Alphabet, das keine Maskierung braucht.
function split_data_uri(string $uri): ?array
{
if (!str_starts_with($uri, 'data:') || !str_contains($uri, ',')) {
return null;
}
$meta = substr($uri, 5, strpos($uri, ',') - 5);
$payload = substr($uri, strpos($uri, ',') + 1);
$isBase64 = str_ends_with($meta, ';base64');
$mime = $isBase64 ? substr($meta, 0, -7) : $meta;
if ($mime === '') {
$mime = 'text/plain;charset=US-ASCII';
}
return [$mime, $isBase64, $payload];
}
$uri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ';
[$mime, $isBase64, $payload] = split_data_uri($uri);
var_dump($mime); // string(9) "image/png"
Zwei Fallen wohnen in diesem Format. Das fehlende ;base64-Flag ist die erste: Ein legales Data-URI ohne Flag trägt einen percent-kodierten Payload, und wenn man ihn durch base64_decode() schickt, kommt Müll heraus. Die zweite ist der behauptete Medientyp: Er ist ein Hinweis vom Sender, keine Tatsache. Der finfo-Check aus dem Datei-Abschnitt ist die Tatsache. Und denken Sie an den eigenen Rat des RFC, dass Data-URIs nur für kurze Werte nützlich sind; ein Bild von mehreren Megabytes innerhalb einer URL stinkt, ist aber kein Muster.
JWTs: Tokens, in die man reinschauen kann
Der berühmteste Base64-Payload im Web ist das JSON Web Token, und auch der am wenigsten beängstigende, sobald man die Form kennt. Laut RFC 7519 besteht ein kompakter JWT aus drei durch Punkte getrennten URL-sicheren Base64-Teilen: einem Header, einem Payload und einer Signatur, jeweils ohne Padding und ohne Zeilenumbrüche kodiert (RFC 7515 stellt ausdrücklich klar, dass keine zusätzlichen Zeichen einschleichen dürfen). Header und Payload sind schlichtes JSON, deshalb kann jeder sie lesen, und deshalb sollte jeder den nächsten Absatz verstanden haben, bevor er ein Token anfasst.
Das Lesen der ersten beiden Teile ist mit dem Helfer von oben fünf Zeilen Arbeit, und es ist ein großartiger Weg, einem Token das Mysterium zu nehmen:
$token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.8GljXWCrvkTYln_WtTVhyWSzflOC1iGL8jBDUHQmaEE';
[$headerPart, $payloadPart] = explode('.', $token);
$header = json_decode(base64url_decode($headerPart), true);
$payload = json_decode(base64url_decode($payloadPart), true);
var_dump($header);
// array(2) { ["alg"] => string(5) "HS256" ["typ"] => string(3) "JWT" }
var_dump($payload);
// array(3) { ["sub"] => string(10) "1234567890" ["name"] => string(8) "John Doe" ["iat"] => int(1516239022) }
Und nun der Teil, der zählt: Der dritte Teil ist eine Signatur, und die beiden Teile, die man gerade dekodiert hat, sind weder geheim noch authentifiziert. Jeder mit einer Paket-Verfolgung kann sie lesen, und jeder mit einem Texteditor kann sie umschreiben. Dem Payload zu vertrauen, bevor man die Signatur überprüft hat, ist der klassische JWT-Bug. In der Produktion baut man diesen Check nicht selbst. Die Antwort der Community ist das Paket firebase/php-jwt, aktuell in v7: Es ist konform mit RFC 7519 und erfordert PHP 8.0 oder neuer. Installieren Sie es mit Composer:
composer require firebase/php-jwt
Dann verifiziert die API zuerst und reicht den Payload nur dann weiter, wenn die Signatur stimmt:
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
$secret = 'correct-horse-battery-staple-long-enough-secret';
try {
$claims = JWT::decode($token, new Key($secret, 'HS256'));
var_dump($claims->sub); // eine Eigenschaft, und erst nachdem die Signatur geprüft war
} catch (UnexpectedValueException $e) {
// fehlerhaftes Token, ungültige Signatur oder abgelaufene Claims
}
Ein Versionshinweis: v7 der Bibliothek erzwingt minimale Schlüssellängen für die HMAC-Algorithmen, daher wird ein HS256-Secret, das kürzer als 32 Bytes ist, mit einer DomainException abgelehnt, bevor die Signatur überhaupt überprüft wird. Halten Sie Ihre Secrets lang; die Bibliothek lässt einen das nicht vergessen.
Achten Sie auf die Reihenfolge in dieser API: JWT::decode() wirft bei ungültiger Signatur, abgelaufenem Token oder fehlendem Algorithmus eine Ausnahme, statt Müll zurückzugeben, also ist ein Payload, den man zurückbekommt, einer, dem man vertrauen kann. Die selbst zusammengebaute Version oben ist zum Verständnis und zum Reinschauen in Tokens, die nicht für einen gedacht waren; die Bibliothek ist zum Vertrauen.
HTTP-Basic-Auth, der älteste Header
Der älteste Authentifizierungs-Header im Web fährt immer noch auf Base64. Laut RFC 7617 sendet eine HTTP-Basic-Anfrage Authorization: Basic gefolgt von der Base64-Kodierung von username:password. Der RFC stellt ausdrücklich klar, dass dies Kodierung und kein Schutz ist: Jeder mit einer Paket-Verfolgung kann beide Hälften mit einer einzigen Taste dekodieren. Ihre Aufgabe auf der Decoder-Seite: den Header parsen, streng dekodieren und mit einer zeitlich sicheren Funktion vergleichen.
function basic_credentials(string $header): ?array
{
if (!str_starts_with($header, 'Basic ')) {
return null;
}
$decoded = base64_decode(substr($header, 6), true);
if ($decoded === false || !str_contains($decoded, ':')) {
return null;
}
[$user, $password] = explode(':', $decoded, 2);
return [$user, $password];
}
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
$creds = basic_credentials($header);
if ($creds !== null
&& hash_equals('alice', $creds[0])
&& hash_equals('secret123', $creds[1])
) {
// authentifiziert
}
Zwei Details halten dies sicher. Die Begrenzung von 2 in explode() ist wichtig, weil ein Passwort legalerweise Doppelpunkte enthalten darf, und der Vergleich sollte hash_equals() sein, niemals ==, damit ein Angreifer sich nicht per Zeitmessung durch Ihre Benutzerliste arbeitet. Und liefern Sie dies nur über HTTPS aus; auf einer unverschlüsselten Verbindung ist die Base64-Ebene nur Augenwischerei.
E-Mail, wo es losging
Base64 wurde für ein bestimmtes Problem geboren: Der Mail-Transport trug nur 7-Bit-ASCII, und doch wollten die Leute Binärdaten senden. Der MIME-Standard (RFC 2045, Abschnitt 6.8) machte Base64 zu einer der binären Übertragungskodierungen und fügte zwei Hausregeln hinzu. Erstens: Kodierte Zeilen dürfen nicht mehr als 76 Zeichen lang sein. Zweitens: Die Dekodier-Software muss jedes Zeichen außerhalb des Alphabets ignorieren, Zeilenumbrüche eingeschlossen. Genau diese zweite Regel ist der Grund, warum der PHP-Decoder, in welcher Laune auch immer, einen CRLF-umwickelten Payload ohne jede Vorausverarbeitung Ihrerseits herunterkaukt. (Das ist auch der Ursprung der \r\n-Toleranz, die Sie oben in der Strict-Modus-Tabelle gesehen haben.)
$png = "\x89PNG\r\n\x1a\n" . random_bytes(256);
$wrapped = chunk_split(base64_encode($png), 76, "\r\n");
// später, auf der Empfängerseite, keine Aufräumarbeit nötig:
$decoded = base64_decode($wrapped, true);
var_dump($decoded === $png); // bool(true): Jedes Byte hat die Rundreise überstanden
Zwei praktische Hinweise. Erstens, das Umwickeln hat Gewicht: Mit einem CRLF alle 76 Zeichen trifft ein 100-KB-Anhang als grob 137 KB Text ein (der übliche Vier-Drittel-Faktor, plus der Zeilenumbruchs-Overhead). Zweitens, für reale Mails mit Headern, mehreren Teilen und quoted-printable-Brüdern seziert die optionale mailparse-Erweiterung komplette RFC-822-Nachrichten Teil für Teil; für einen einzelnen bekannten Anhang genügt strenges Dekodieren.
PEM-Rüstung: Schlüssel und Zertifikate
Zertifikate und Schlüssel reisen in PEM-Rüstung: eine BEGIN-Bezeichnung, ein Block aus Base64 in 64-Zeichen-Zeilen und eine END-Bezeichnung. Die Zeilenlänge von 64 Zeichen ist eine Konvention, die aus der ursprünglichen Privacy-Enhanced-Mail-Spezifikation (RFC 1421) stammt, und OpenSSL-Tools erwarten sie, also spielt sie eine Rolle, wenn man die Rüstung neu anlegt. Beim Dekodieren spielt sie überhaupt keine Rolle: Der Decoder ignoriert die Zeilenumbrüche einfach.
$pem = file_get_contents('/etc/ssl/my-key.pem');
preg_match('/-----BEGIN ([A-Z ]+)-----\s*(.*?)\s*-----END \1-----/s', $pem, $m);
$label = $m[1];
$der = base64_decode(preg_replace('/\s+/', '', $m[2]), true);
if ($der === false) {
// also doch kein Base64
}
var_dump($label); // string(11) "PRIVATE KEY"
Die dekodierten Bytes sind DER, eine kompakte binäre Serialisierung, und damit arbeiten die openssl_*-Funktionen letztlich. Die Rückverweisung \1 im regulären Ausdruck ist der stille Held: Sie garantiert, dass die END-Bezeichnung zur BEGIN-Bezeichnung passt, und so vermeidet man, dass man das END eines Zertifikats an das BEGIN eines Schlüssels näht, wenn eine Datei mehrere Blöcke enthält.
Streams und große Payloads
Dekodieren ist die Richtung, die einem hilft: Die Ausgabe ist drei Viertel so groß wie die Eingabe, also ist Speicherdruck durch Base64 selten. Aber wenn eine mehrere Hundert Megabyte große .b64-Datei auf der Festplatte landet, hat man zwei Werkzeuge, um den Fußabdruck flach zu halten.
Das erste ist das chunk-basierte Dekodieren. Teilen Sie die bereinigte Eingabe in Stücke, deren Länge ein Vielfaches von vier Zeichen ist, dekodieren Sie jedes Stück streng und verbinden Sie sie. Jedes Chunk ist ein in sich geschlossener, gültiger Payload, also geht an den Grenzen nichts verloren, und eine beschädigte Datei scheitert schnell, mit einem Offset, das man berichten kann.
$clean = str_replace(["\r", "\n"], '', file_get_contents('/var/www/uploads/huge.b64'));
$decoded = '';
$chunkSize = 4 * 50000; // ein Vielfaches von vier Zeichen, etwa 150 KB Ausgabe pro Aufruf
for ($offset = 0; $offset < strlen($clean); $offset += $chunkSize) {
$part = base64_decode(substr($clean, $offset, $chunkSize), true);
if ($part === false) {
exit('Corrupted payload near offset ' . $offset);
}
$decoded .= $part;
}
Ein Megabyte Base64 wird auf moderner Hardware in gut unter einer Millisekunde dekodiert, also kostet diese Schleife beinahe nichts; wählen Sie sie wegen ihrer Validierungs- und Berichts-Eigenschaften, nicht wegen der Geschwindigkeit.
Das zweite Werkzeug ist ein Bürger der Streaming-Welt: der convert.base64-decode-Stream-Filter. Er funktioniert auf jedem PHP-Stream, also kann man direkt aus einem Dateizeiger, php://input oder einem Speicher-Stream dekodieren, ohne den gesamten kodierten Text jemals in einer einzigen Variable zu halten. Wie die nachgiebige Funktion springt er einfach über jedes Zeichen außerhalb des Base64-Alphabets hinweg:
$in = fopen('/var/www/uploads/huge.b64', 'rb');
$out = fopen('/var/www/uploads/huge.bin', 'wb');
stream_filter_append($in, 'convert.base64-decode', STREAM_FILTER_READ);
stream_copy_to_stream($in, $out);
fclose($in);
fclose($out);
Welches Werkzeug wählt man? Den Filter, wenn die Daten durch einen Stream fließen und man PHP die Rohrleitung überlassen will; die Chunk-Schleife, wenn man Validierung pro Chunk, Fortschrittsberichte oder das Offset der Beschädigung braucht.
Datenbanken, Konfigurationsdateien und Umgebungsvariablen
Base64 ist ein Text-Container, deshalb taucht es an Orten auf, die man nicht erwarten würde. In Datenbanken kann ein binärer Blob (eine Datei, ein Icon, eine serialisierte Struktur) als Base64 in einer TEXT-Spalte leben und jedes Tool überleben, das Text annimmt. Rechnen Sie damit, dass der gespeicherte Wert etwa 33 Prozent größer ist als das Original, und dimensionieren Sie Ihre Spalten entsprechend. In Konfigurationsdateien und Umgebungsvariablen ist Base64 der Trick, um Werte zu schmuggeln, die sonst das Format sprengen würden: eine Datenbank-DSN mit Semikolons, ein Passwort mit Anführungszeichen, ein Wert mit Zeilenumbruch.
// .env oder Konfiguration, geschrieben von der Ops-Person:
// DB_DSN_B64 = cGc6aG9zdD1kYjtwYXNzd29yZD1xdSJvdGU=
$dsn = base64_decode(getenv('DB_DSN_B64') ?: '', true);
if ($dsn === false) {
exit('DB_DSN_B64 is not valid Base64.');
}
// $dsn ist jetzt: pg:host=db;password=qu"ote
Die gleiche Vorsicht gilt hier doppelt. Erstens: Dies ist Format-Sicherheit, keine Geheimhaltung: Im Moment, in dem ein Entwickler die Konfigurationsdatei liest, kann er den Wert in einem einzigen Aufruf dekodieren. Speichern Sie niemals ein Secret als Base64 und nennen Sie es verschlüsselt. Zweitens: Validieren Sie beim Start: Ein beschädigter oder halb hineingeklebter Umgebungswert ist ein false aus dem strikten Aufruf, und ein einzeiliger Check verwandelt einen kryptischen Laufzeitfehler in eine Startmeldung, auf die man reagieren kann.
Von der Kommandozeile
Nicht alle Dekodierung passiert innerhalb einer Web-Anfrage. CLI-Skripte, Cron-Jobs und One-Liner dekodieren Base64 ständig, und die Kommandozeile ist der Ort, an dem die Funktion auf php://stdin trifft:
php -r 'fwrite(STDOUT, base64_decode(file_get_contents("php://stdin"), true));' < payload.b64 > restored.bin
Die Shell hat bereits ihr eigenes Base64-Utility (coreutils base64 -d), und es ist gut für schnelle Arbeiten geeignet; der PHP-One-Liner ist für den Fall, in dem der nächste Schritt PHP-Logik ist: Schreiben in eine Datenbank, Aufruf einer API, Durchführung einer Validierung. Zwei shell-spezifische Stolpersteine. Die Ausgabe eines Dekodierens sind rohe Bytes, also senden Sie sie an eine Datei oder an ein Kommando, das Bytes versteht, nicht an ein Terminal, das sie beschädigen würde. Und lassen Sie das Strict-Flag im One-Liner an, denn ein abgeschnittenes Einfügen in ein Terminal verdient ein false, nicht drei Bytes Müll.
Stolperfallen mit PHP-Akzent
Eine kurze Tour durch die Fallen, die spezifisch für PHP sind, an einem Ort gesammelt:
- Der nachgiebige Standard ist die Größte.
base64_decode('V@hpcy')gibt ohne jede Warnung drei Bytes Müll zurück, also braucht jeder Decoder unvertrauenswürdiger Eingaben das Strict-Flag und einenfalse-Check. - Ein einzelnes Zeichen dekodiert im nachgiebigen Modus zu einem leeren String, und genauso tut es eine Zeichenkette aus nur Leerzeichen. Ein leeres Ergebnis beweist fast nichts; nur
falsebedeutet Fehlschlag, und man bekommt es nur im Strict-Modus. - Das
+in einem Query-String ist bereits ein Leerzeichen, bevor PHP es sieht. Schickt ein Client?token=abc+defohne percent-Kodierung, gibt PHPabc defheraus (das ist das Verhalten der Formular-Kodierung, gemeinsam beiparse_str()undurldecode()), und kein noch so großer Dekodierungs-Trick bringt das Plus zurück. URL-sicheres Base64 (überhaupt kein Plus) ist die Lösung für Tokens in URLs. - Fehlendes Padding wird stillschweigend nachgefüllt. Sieben Zeichen dekodieren wie acht; das ist bequem, aber es bedeutet, dass ein Payload, der um ein oder zwei Pads abgeschnitten wurde, trotzdem ohne Murren dekodiert werden kann, also beweist ein sauberes Dekodieren nie ganz, dass der Payload als Ganzes angekommen ist (die raw-Encoder von Go und Java sind ebenso nachsichtig).
- Der Geist von
mbstring.func_overload. Die lange deprecated Einstellung, diestrlen()und Co. umschrieb, um Zeichen statt Bytes zu zählen (in PHP 8.0 entfernt), hat in der Vergangenheit die Base64-Byte-Mathematik auf UTF-8-Strings kaputt gemacht. Legacy-Code, den man erbt, kann immer noch Kommentare und Workarounds dafür tragen. Löschen Sie sie. - Dekodierte Bytes sind kein UTF-8-String.
preg_match()mit dem/u-Flag odermb_substr()auf dekodierten Binärdaten auszuführen, ist eine sofortige Quelle für "fehlformatierte Eingabe"-Fehler. Erst schnüffeln, dann entscheiden. - Das Übergeben von
nullist seit PHP 8.1 deprecated. Wenn eine Variable null sein kann, koaleszieren Sie sie vor dem Aufruf auf''. $_GETund Co. werden mit Formular-Regeln dekodiert, nicht mit URL-Regeln. Wenn ein Wert percent-kodiert angekommen ist, istrawurldecode()die sicherere Umkehrung, denn es lässt+in Ruhe.
Eine kurze Geschichte von base64_decode
Base64 selbst ist älter als die meiste des modernen Webs (der es regierende Standard, RFC 4648, stammt aus dem Jahr 2006 und kodifizierte die MIME-Kodierung von 1996, die wiederum aus der PEM-Rüstung der frühen 1990er Jahre abstammt). Die PHP-Geschichte ist ein kleines Changelog von eigener Art.
PHP 4 lieferte base64_decode() als Kernfunktion ohne Optionen und ohne Strict-Modus aus; die nachgiebige Laune war die einzige Laune, und es gab keine Möglichkeit, den Decoder dazu zu bitten, zu klagen. PHP 5.2.0, im November 2006, fügte das $strict-Flag hinzu, und der Changelog-Eintrag lohnt die Lektüre: Es wurde hinzugefügt, um die RFC-3548-Konformität durchzusetzen, die Vorgängerin des heutigen RFC 4648. Dieses eine Flag erwies sich als die nützlichste Ergänzung in der Geschichte der Funktion.
Dann kamen die Debugging-Jahre. PHP 5.3 behebt eine Reihe von Strict-Modus-Bugs über zwei Punkt-Releases: Bug #52327 (leading padding wird im Strict-Modus unsachgemäß behandelt, behoben in 5.3.4) und Bug #55273 (Leerraum nach Padding wird im Strict-Modus abgelehnt, behoben in 5.3.9). (Eine Integer-Overflow-Reparatur aus dem Jahr 2016 ist ebenfalls unter dem Namen dieser Funktion gemeldet: Bug #72836, offiziell betitelt mit "Integer-Overflow in base64_decode verursacht Heap-Korruption", behoben in 5.6.25, aber der eigene Reproduktionscode und die gepatchte Funktion des Bug-Reports zeigen, dass der eigentliche Overflow in der Längenberechnung von base64_encode() lag, nicht im Decoder; der Titel ist eine irreführende Bezeichnung, die vom ursprünglichen Report geerbt wurde.) Jede Reparatur straffte das Verhalten, das Sie in der Tabelle oben sehen. PHP 8.0 gab beiden Base64-Funktionen native Parameter- und Rückgabewerttypen, die Signatur, die Sie am Anfang dieses Artikels gesehen haben, und dieselbe Release-Zeile entfernte mbstring.func_overload, die Einstellung, die jahrelang still und leise die Byte-Mathematik kaputt gemacht hatte. PHP 8.1 erklärte das Übergeben von null an sie zu deprecated. Seitdem ist die Oberfläche eingefroren: ein Parameter, ein Flag, ein Rückgabewerttyp, unverändert.
Ein paar Nerd-Fakten, die Spaß machen
Da dies eine ausführliche Referenz ist, hier einige PHP-spezifische Fakten, die einfach nur Spaß machen:
- Die leere Identität.
base64_encode('')undbase64_decode('')sind beide''. Die Funktionen behandeln Leere in beide Richtungen als erstklassigen Wert, ohne dassfalsedabei ist. - Eine seltsame Adresse. Im PHP-Manual leben beide Base64-Funktionen im Kapitel "URLs" des Buches "Weitere Basis-Erweiterungen". Es gibt kein dediziertes "Kodierung"-Kapitel; dort findet man sie, an der Spitze der Kapitel-Liste, vor
parse_url()und seinen Freunden. - Der Decoder ist ein Homomorphismus. Ein klassischer php.net-Nutzer-Hinweis beobachtet, dass die Funktion ein Homomorphismus zwischen modulo-4- und modulo-3-segmentierten Strings ist, was die formale Art ist zu sagen, dass jede Aufteilung in Vielfache von vier eine gültige Aufteilung ist. Deshalb funktioniert der Abschnitt über chunk-basiertes Dekodieren überhaupt, und deshalb kann eine 1-MB-Datei in 50-KB-Scheiben ohne jeden Verlust dekodiert werden.
- Ein Parameter, ein Flag. In über zwanzig Jahren bekam
base64_decode()genau einen Parameter ($strict), undbase64_encode()keinen einzigen. - Es hat ältere Geschwister. Dieselbe Core-Extension trägt auch
convert_uuencode()undconvert_uudecode()(im Manual unter "String-Funktionen" aufgeführt), die Relikte der Dial-Up-Ära, als uuencode der binäre Transport der Wahl war. Man wird sie fast nie brauchen, aber wenn je eine uralte.uu-Datei im Postfach landet, kann PHP sie öffnen. - Der Strict-Modus hält eine Tür für E-Mail offen. Die vier Leerraum-Zeichen (Leerzeichen, Tabulator, Wagenrücklauf und Zeilenvorschub) segeln absichtlich durch den Strict-Modus hindurch, also braucht ein MIME-umwickelter Anhang keine Vorausverarbeitung. Alles andere, NUL-Bytes eingeschlossen, ist ein
false.
Die andere Richtung
Das ist die Decoder-Seite, und hier lebt der größte Teil des Schmerzes, denn beim Dekodieren trifft man auf die Daten anderer: deren Padding-Entscheidungen, deren Zeilenumbrüche, deren Zeichensätze, deren Tokens. Die andere Richtung, Bytes mit base64_encode() in einen Base64-String zu verwandeln, ist ein ruhigeres Tier: Es scheitert nie, es hat keinen Strict-Modus, und seine eigene Schar von Fallen (Doppel-Kodierung, Umwicklungs-Mismatchs, die Größenrechnung) bekommt ihren eigenen Guide. Base64-Kodierung in PHP, von dieser Seite aus verlinkt, deckt den Encoder in derselben Tiefe ab.
Zuletzt aktualisiert: 2026-09-08
Verwandter Artikel: Base64-Kodierung in PHP: Ein vollständiger Leitfaden