Werk je met Base64-indeling? Dan is deze site perfect voor jou! Gebruik onze handige online tool om je gegevens te coderen of te decoderen.

Base64-decodering in PHP: een complete gids

Het duikt op in een supportticket, in een API-log, in een config-bestand of halverwege een URL: een lange tekenreeks van letters, cijfers, af en toe een + of /, en misschien nog een = of twee aan het eind. Je herkent het in een hartkloppen. Base64 is een binair-naar-tekst formaat: het schrijft elke drie bytes ruwe data om naar vier tekens uit een alfabet van 64 letters, en als het aantal bytes geen veelvoud van drie is, maken een paar =-tekens de staart af. Decoderen is de krimpzijde van die ruil: vier tekens gaan erin, drie bytes komen eruit. De startpagina van deze site loopt het formaat stap voor stap door, dus besteedt dit artikel zijn energie waar die thuishoort: aan de PHP-kant van het werk.

Het belangrijkste nieuws eerst. PHP levert al sinds PHP 4 een Base64-decoder mee in de core. base64_decode() heeft geen extensie, geen Composer-pakket en geen configuratie nodig, en die draait overal waar PHP draait. Het mindere nieuws: de standaardstem slikt beschadigde invoer stilletjes op en reikt je rommel aan zonder een woord. Het goede nieuws wordt beter: één flag ($strict) maakt van de functie een volwaardige poortwachter, en als je eenmaal weet hoe je de stemming kiest, de invoer als echt bewijst en de bytes weer omzet in betekenis, stopt Base64 met het wekken van mysterie-bugs en wordt het een routine die je kunt automatiseren.

Een snelle opmerking over de grootte: decoderen krimpt data met ongeveer een kwart (drie bytes uit voor elke vier tekens in), dus de uitvoer neemt altijd minder geheugen in dan de invoer. Je hoeft je nooit zorgen te maken dat een decodering het geheugen laat ontploffen. Nu gaan we de tool leren kennen.

De functie die het werk doet

Hier is de volledige signatuur, exact zoals moderne PHP die rapporteert:

base64_decode(string $string, bool $strict = false): string|false

Drie woorden in die regel doen al het werk. $string kent geen groottebeperking: een megabyte decodeert in ruim minder dan een milliseconde, dus niets houdt je tegen om een heel bestand in één aanroep te decoderen. Het teruggeeftype legt de hele afspraak vast: of een tekenreeks met gedecodeerde bytes, of false. Geen excepties, geen foutcodes, geen tweede kanaal. false is het enige signaal dat je krijgt, dus het controleren ervan hoort bij het werk. En één zin uit de handleiding verdient geleerd te worden: de teruggegeven data kan binair zijn. Het moment dat het resultaat een PNG, een ZIP of een hash bevat, is het in geen ruime zin meer een 'teksttekenreeks', en PHP laat je het graag zó behandelen. Die flexibiliteit is een superkracht en een valkuil, en de secties hieronder houden het in toom.

Een snelle tocht door de versietags, want overgenomen code maakt er een gewoonte van dingen aan te nemen. De functie zit al sinds PHP 4 in de core. De $strict-parameter kwam in PHP 5.2.0, in november 2006. Sinds PHP 8.0 draagt de signatuur echte native types (het string en bool dat je hierboven ziet, plus het teruggeeftype string|false), dus weten IDE's en statische analyzers eindelijk dat de functie kan falen. Sinds PHP 8.1 geeft het doorgeven van null een verouderingswaarschuwing; als je 'niets' bedoelt, schrijf dan expliciet '':

$decoded = base64_decode('');
var_dump($decoded); // string(0) ""

Strict-modus of stille opruiming

De $strict-flag is een schakelaar tussen twee heel verschillende personaliteiten. Uit (de standaard) is de decoder een vriendelijke vergeetmein: elk teken buiten het Base64-alfabet wordt stilletjes weggeworpen, de rest wordt gedecodeerd en niemand krijgt er iets van mee. De handleiding zegt het bloot: anders worden ongeldige tekens stilletjes verworpen. Aan is de decoder een poortwachter: het eerste teken dat hij niet herkent levert het hele payload een false op.

Hier is het schadeoverzicht. Elke regel hieronder is echt gedrag van base64_decode() op PHP 8.x:

Invoer Soepel (standaard) Strict
Zm9vYmFy, net "foobar" "foobar"
Zm9v\r\nYmFy, CRLF in het midden van de tekenreeks "foobar" "foobar"
" Zm9vYmFy ", spaties aan beide uiteinden "foobar" "foobar"
Zm9v\x0bYmFy, verticale tab "foobar" false
Zm9v\x00YmFy, ingebouwde NUL-byte "foobar" false
V@hpcy, losse @ 3 bytes rommel false
Zm9vY, vijf tekens "foo", laatste teken weggelaten false
Z, een enkele letter "", een lege tekenreeks false
=Zm9, vulling vooraan "fo" false
Zm9vYmFy==, vultekens na een volledige groep "foobar" false
Zm9vYmFy==A, data na de vultekens "foobar" false
Zm9vYmF, zeven tekens, geen vultekens "fooba" "fooba"

Drie regels verdienen een tweede blik. De V@hpcy-regel toont waarom de soepele modus overal gevaarlijk is waar de invoer niet te vertrouwen is: de losse @ stopt de decoding niet; hij verdwijnt gewoon, en de drie bytes die eruit komen betekenen niets. De enkele-Z-regel toont dat een leeg resultaat bijna niets bewijst; een payload van één teken 'decodeert' naar een lege tekenreeks zonder te falen. De Zm9vYmFy==A-regel laat zien dat de decoder data die na de vulling verschijnt, zonder te knipperen negeert - zo kan een afgekapt of aangepaste payload er perfect gezond uitzien.

Wat laat de strict-modus dan nog wel door? Exact vier witruimtetekens: spatie, tab, carriage return en line feed, op elke positie, zelfs direct naast de =-tekens. Dat is met opzet. MIME-omwikkelde e-mailpayloads hebben CRLF-regeleindes in de gecodeerde stroom, en de strict-modus kauwt erdoorheen zonder voorverwerking (de e-mailsectie hieronder legt uit waarom). Alles anders wat geen alfabetteken is, van NUL-bytes tot verticale tabs, levert een false op.

Er is wel één échte soepelheid die je moet kennen, al is het geen PHP-kenmerk: PHP vult ontbrekende vulling voor je aan, stilletjes. De payload van zeven tekens Zm9vYmF (geen vulling) decodeert naar "fooba", net als zijn gevulde neef Zm9vYmF=, in beide stemmingen. RFC 4648 vraagt in het algemeen om vulling, dus een ongevulde staart accepteren is een bewuste versoepeling, en die is niet specifiek voor PHP: de RawStdEncoding van Go en de decoder van Java nemen dezelfde ongevulde invoer aan. Als jouw PHP-kant en een partnersysteem het oneens zijn over een edge-case-payload, is een ontbrekend vulteken meestal de plek om te zoeken.

De standaard is het met de strenge stemming eens. RFC 4648, sectie 3.3, zegt dat implementaties moeten gecodeerde data weigeren die tekens buiten het alfabet bevat, tenzij de omliggende specificatie anders zegt (MIME is het klassieke 'anders-zegt'-geval). Dezelfde sectie legt uit waarom: niet-alfabettekens kunnen misbruikt worden als verborgen kanaal, door informatie te verstoppen in tekens die je decoder wegworpt, en ze zijn daadwerkelijk gebruikt om decoder-bugs uit te lokken. Als je invoer van de buitenwereld komt, is de strict-modus geen keuze van smaak. Het is wat de standaard vraagt.

Uitmaken of een payload Base64 is

Een decoder die stil kan falen verdient een validatiepijplijn ervoor. Drie lagen, en elke vangt wat de andere overslaat.

Laag één is een vormcheck met een reguliere expressie: alleen alfabettekens, en hooguit twee vultekens helemaal aan het eind.

$shapeLooksPlausible = preg_match('/^[A-Za-z0-9+\/]*={0,2}$/', $payload) === 1;

De regex vangt voor de hand liggende rommel (losse spaties, @-tekens, een vulteken in het midden van de tekenreeks) voordat er iets anders draait. Het is geen validator: hij kan niet zien dat Zm9vYmFy= negen tekens heeft met één vulteken, wat de strict-modus ook afwijst. Dat is precies waarom laag twee bestaat. Strikte decoding is de enige check die de semantiek van Base64 begrijpt, dus hij krijgt het laatste woord.

Laag drie is de laag die iedereen vergeet: behandel false expliciet, want dat is het enige signaal dat je krijgt.

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;
}

De str_replace() vooraan is optionele geruststelling: de strict-modus accepteert CRLF al, maar het eruit halen houdt de lengtewiskunde die je later doet schoon, want het aantal tekens van een schone payload is altijd een veelvoud van vier. (Eén meer dan een veelvoud van vier, zoals vijf of negen, is onmogelijk in Base64, en de strict-modus weigert het.) Let op: de functie werpt zelf nooit iets uit; de check is iets wat jij schrijft.

URL-safe Base64

In het veld zul je een tweede alfabet tegenkomen, en dat is er een dat bijt. Standaard Base64 gebruikt + en /, twee tekens die in URLs voor problemen zorgen: een + in een query string wordt als spatie gelezen voordat PHP het ooit ziet, en / is een padseparator. RFC 4648, sectie 5, definieert de oplossing: het URL- en bestandsnaam-veilige alfabet, waar + wordt -, / wordt _, en de vulling met = aan het eind meestal wordt weggehaald om tekens te besparen. De RFC staat er stevig in dat dit 'niet als hetzelfde als de base64-codering mag worden gezien', en de naam die je het meest hoort, is base64url. JSON Web Tokens, OAuth state parameters, API-sessie-IDs en de URLs van videosites leven allemaal in dit dialect.

De decoderkant is twee stappen: wissel het alfabet terug, en herstel daarna de ontbrekende vulling. Hier is de helper die je overal weer zult gebruiken:

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"

Moderne PHP staat hier aan jouw kant: het vult ontbrekende vulling voor je aan, dus het expliciete herstellen is dubbel en dwars (en het houdt je code draagbaar naar oudere PHP-versies). De gevarenrichting is eenrichtingsverkeer. Als je URL-safe tekst voert in de standaard decoder in soepele modus, staan de -- en _-tekens simpelweg niet in het standaardalfabet, dus worden ze weggeworpen. Je uitvoer komt korter uit dan hij zou moeten, zonder fout, zonder waarschuwing, zonder iets. Voer altijd eerst de strtr()-wissel uit, of beter: ga altijd via de helper.

Eén eerlijke kanttekening: als een URL-safe payload toevallig noch - noch _ bevat, zijn de twee alfabetten voor die specifieke data byte-voor-byte identiek, en maakt het niet uit welke decoder je hebt gebruikt. Het gevaar verschijnt pas als die tekens wél aanwezig zijn, want dat is de enige plek waar de alfabetten verschillen.

Tekst, bytes en tekensets

Base64 heeft geen flauw idee wat je bytes betekenen, en de decoder van PHP erft die blindheid. De codec is tekenset-blind: hij geeft precies dezelfde 8-bit waarden terug die erin gingen, of het nu UTF-8-tekst is, Windows-1252-tekst, een JPEG of een hash. PHP zelf is het ermee eens: een tekenreeks is een reeks bytes, niets meer. Het moment dat je het resultaat wilt tonen of vergelijken met andere tekst, moet iemand twee vragen beantwoorden: is dit überhaupt tekst, en zo ja, in welke tekenset?

De praktische test heeft twee categorieën. Binair data maakt zich vrijwel altijd kenbaar met NUL- en lage besturingsbytes, en tekst die geen geldige UTF-8 is, valt in de tweede categorie. De mbstring-extensie (niet standaard ingeschakeld) geeft je de strenge 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)

Als de payload tekst is in een oude tekenset, zet dan om voordat die je HTML raakt. Windows-1252 is de meest gangbare oude codering voor web- en desktopdata, en het verschil tussen die en gewone ISO-8859-1 bepaalt of byte 0x93 een gebogen aanhalingsteken is of een onzichtbaar besturingsteken:

// "café" in Windows-1252: de é is één byte, 0xE9
$legacy = base64_decode('Y2Fm6Q==', true);
$utf8 = mb_convert_encoding($legacy, 'UTF-8', 'Windows-1252');
var_dump($utf8); // string(5) "café": de é is nu twee UTF-8-bytes

Een waarschuwing over de beroemde mb_detect_encoding(): de PHP-handleiding zelf zegt dat automatische detectie 'nooit volledig betrouwbaar kan zijn' en vergelijkt het met het ontcijferen van een bericht zonder de sleutel. Geef hem een Windows-1252-'café' en hij mag Windows-1252 zeggen; geef hem een PNG-header en hij kan gerust weer Windows-1252 zeggen, want de familie van ISO-8859-tekensets is gedefinieerd voor elke mogelijke byte-waarde en kan daardoor alles matchen. Behandel detectie als laatste redmiddel, vertrouw een aangegeven tekenset (een header, een config-regel, een database-collatie) waar die maar bestaat, en standaardiseer de rest op UTF-8 of binair.

Wanneer de payload een bestand is

De meest voorkomende bestandstaak is het omgekeerde van wat een exportroutine heeft gedaan: er komt een .b64-tekstbestand binnen, en je hebt het oorspronkelijke bestand terug nodig. Met strikte decoding en een false-check is dit al productieklaar:

$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-tekenreeksen zijn gewoon bytes, dus niets in dit pad geeft om of de payload een tekstbestand is, een ZIP-archief of een video. De grootte-wiskunde werkt in jouw voordeel: de gedecodeerde uitvoer is drie kwart van de lengte van de gecodeerde invoer, dus decoderen maakt geheugen nooit erger.

Een goede gewoonte is om de bytes zichzelf te laten aankondigen voordat je een label vertrouwt. De finfo-klasse (de fileinfo-extensie, opgenomen in standaard PHP-builds) vertelt je wat de data eigenlijk is:

$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);

Die laatste stap is belangrijker dan hij lijkt. Een payload die beweert een afbeelding te zijn maar naar iets anders decodeert, is precies het soort ding dat een tweede mening opvangt. En als je het herstelde bestand later naar een browser stuurt, moet de Content-Type die je verstuurt uit dezelfde finfo-check komen, niet uit de bestandsnaam.

Data-URIs, het formaat van het klembord

Een favoriete binnenkomer: iemand plakt een afbeelding in een formulier, en de front end reikt je een complete data-URI aan: data:image/png;base64,iVBORw0KGgo.... RFC 2397 definieert de vorm: data:, een optionele media type, een optionele ;base64-flag, een komma, en dan de data. Als de flag aanwezig is, is de payload Base64; als die ontbreekt, is de payload percent-gecodeerde platte tekst, zeldzamer maar legaal. Als de media type weggelaten wordt, is de standaard text/plain;charset=US-ASCII. Waarom hier überhaupt Base64? Omdat een URI geen ruwe bytes of komma's veilig kan bevatten, en Base64 je één alfabet geeft dat geen escapering nodig heeft.

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"

In dit formaat leven twee valkuilen. De ontbrekende ;base64-flag is de eerste: een legale data-URI zonder de flag draagt een percent-gecodeerde payload, en als je die door base64_decode() haalt, krijg je rommel. De tweede is de beweerde media type: dat is een hint van de verzender, geen feit. De finfo-check uit de bestandsectie is jouw feit. En onthoud het eigen advies van de RFC: data-URIs zijn pas nuttig voor korte waarden; een afbeelding van meerdere megabytes in een URL is een slecht teken, geen patroon.

JWTs: tokens waar je in kunt kijken

De beroemdste Base64-payload op het web is de JSON Web Token, en de minst engste zodra je de vorm kent. Volgens RFC 7519 is een compacte JWT drie URL-safe Base64-delen gescheiden door punten: een header, een payload en een signatuur, elk gecodeerd zonder vulling en zonder regeleindes (RFC 7515 is expliciet: er mogen geen extra tekens inslopen). De header en de payload zijn platte JSON, daarom kan iedereen ze lezen, en daarom moet iedereen de volgende alinea begrepen hebben voordat hij een token aanraakt.

Het lezen van de eerste twee delen is vijf regels werk met de helper van hierboven, en een uitstekende manier om een token te ontmystificeren:

$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) }

En nu het deel dat ertoe doet: het derde deel is een signatuur, en de twee delen die je zojuist decodeerde zijn niet geheim en niet geverifieerd. Iedereen met een packetcapture kan ze lezen, en iedereen met een teksteditor kan ze herschrijven. De payload vertrouwen vóórdat je de signatuur verifieert, is de klassieke JWT-bug. Voor productie: knutsel die check niet zelf in elkaar. Het antwoord van de community is het firebase/php-jwt-pakket, momenteel op v7, conform RFC 7519 en vereist PHP 8.0 of nieuwer. Installeer het met Composer:

composer require firebase/php-jwt

Dan verifieert de API eerst en reikt je de payload pas aan als de signatuur klopt:

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); // een eigenschap, en pas nadat de signatuur klopte
} catch (UnexpectedValueException $e) {
  // kapot token, slechte signatuur of verlopen claims
}

Eén versie-opmerking: v7 van de library handhaaft minimale sleutellengtes voor de HMAC-algoritmes, dus een HS256-secret van minder dan 32 bytes wordt afgewezen met een DomainException, vóórdat de signatuur geverifieerd is. Houd je secrets lang; de library laat je dat niet vergeten.

Kijk naar de volgorde in die API: JWT::decode() gooit uit bij een slechte signatuur, een verlopen token of een ontbrekend algoritme, in plaats van rommel terug te geven, dus een payload die je terugkrijgt, is er een die je kunt vertrouwen. De zelfgemaakte versie van hierboven is voor het begrijpen, en om te kijken naar tokens die niet voor jou bestemd waren; de library is voor het vertrouwen.

HTTP Basic Auth, de oudste header

De oudste authenticatieheader op het web rijdt nog steeds op Base64. Volgens RFC 7617 stuurt een HTTP Basic-verzoek Authorization: Basic gevolgd door de Base64-codering van username:password. De RFC is er expliciet over dat dit codering is, geen bescherming: iedereen met een packetcapture kan beide helften met één toetsaanslag decoderen. Jouw werk aan de decoderkant is de header parsen, strikt decoderen en vergelijken met een timing-veilige functie.

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])
) {
  // geauthenticeerd
}

Twee details houden dit veilig. De limiet van 2 in explode() is belangrijk, want een wachtwoord mag legaal kolommen bevatten, en de vergelijking moet hash_equals() zijn, nooit ==, zodat een aanvaller niet via tijdmetingen zijn weg door je gebruikerslijst loopt. En serveer dit alleen via HTTPS; op een gewone verbinding is de Base64-laag alleen maar schijn.

E-mail, waar het allemaal begon

Base64 werd geboren voor een specifiek probleem: het e-mailtransport droeg alleen 7-bit ASCII, maar mensen wilden binaire data versturen. De MIME-standaard (RFC 2045, sectie 6.8) maakte Base64 een van de binaire transfercoderingen en voegde twee huisregels toe. Allereerst mogen gecodeerde regels niet langer dan 76 tekens zijn. Ten tweede moet decoderende software elk teken buiten het alfabet negeren, regeleindes inbegrepen. Die tweede regel is precies waarom de decoder van PHP, in beide stemmingen, door een CRLF-omwikkelde payload heenkauwt zonder enige voorverwerking van jou. (Dit is ook de herkomst van de \r\n-tolerantie die je in de strict-modustabel hierboven zag.)

$png = "\x89PNG\r\n\x1a\n" . random_bytes(256);
$wrapped = chunk_split(base64_encode($png), 76, "\r\n");
// later, aan de ontvangende kant, geen opruiming nodig:
$decoded = base64_decode($wrapped, true);
var_dump($decoded === $png); // bool(true): elk byte heeft de rondreis overleefd

Twee praktische opmerkingen. Eerst voegt de omwikkeling gewicht toe: met een CRLF elke 76 tekens komt een bijlage van 100 KB aan als ongeveer 137 KB tekst (de bekende factor 4/3, plus de overhead van de regeleindes). Ten tweede: voor echte e-mail met headers, meerdere delen en quoted-printable-verwanten, ontleedt de optionele mailparse-extensie volledige RFC 822-berichten deel voor deel; voor één bekende bijlage heb je strikte decoding genoeg.

PEM-omhulling: sleutels en certificaten

Certificaten en sleutels reizen in PEM-omhulling: een BEGIN-label, een blok Base64 in regels van 64 tekens, en een END-label. De regellengte van 64 tekens is een conventie geërfd van de oorspronkelijke Privacy Enhanced Mail-specificatie (RFC 1421), en OpenSSL-tools verwachten die, dus het telt als je opnieuw omhult. Bij het decoderen maakt het helemaal niets uit: de decoder negeert de regeleindes gewoon.

$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) {
  // toch geen Base64
}
var_dump($label); // string(11) "PRIVATE KEY"

De gedecodeerde bytes zijn DER, een compacte binaire serialisatie, en daarmee werken de openssl_*-functies uiteindelijk. De terugverwijzing \1 in de regex is de stille held: die garandeert dat het END-label op het BEGIN-label past, en zo voorkom je dat je een END van een certificaat aan een BEGIN van een sleutel naait wanneer een bestand meerdere blokken bevat.

Streams en grote payloads

Decoderen is de richting die je helpt: de uitvoer is drie kwart van de grootte van de invoer, dus geheugendruk door Base64 is zeldzaam. Toch, als een .b64-bestand van enkele honderden megabytes op de schijf landt, heb je twee tools om de geheugenvoetafdruk vlak te houden.

De eerste is decoding in chunks. Splits de schoongemaakte invoer in stukken waarvan de lengte een veelvoud van vier tekens is, decodeer elk stuk in de strict-modus, en voeg ze samen. Elke chunk is een op zich geldige payload, dus er gaat niets verloren aan de randen, en een beschadigd bestand faalt snel met een offset die je kunt rapporteren.

$clean = str_replace(["\r", "\n"], '', file_get_contents('/var/www/uploads/huge.b64'));
$decoded = '';
$chunkSize = 4 * 50000; // een veelvoud van vier tekens, ongeveer 150 KB uitvoer per aanroep
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;
}

Een megabyte Base64 decodeert in ruim minder dan een milliseconde op moderne hardware, dus deze loop kost bijna niets; kies hem omwille van de validatie- en rapportage-eigenschappen, niet omwille van de snelheid.

De tweede tool is een inwoner van de streamingwereld: de convert.base64-decode-streamfilter. Die werkt op elke PHP-stream, dus je kunt direct decoderen uit een file pointer, php://input of een geheugenstream, zonder ooit de hele gecodeerde tekst in één variabele te houden. Net als de soepele functie slaat hij simpelweg elk teken buiten het Base64-alfabet over:

$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);

Welke tool kies je? De filter wanneer de data door een stroom loopt en je PHP het binnenwerk wilt laten regelen; de chunk-loop wanneer je per-chunk-validatie, voortgangsrapportage of de offset van de beschadiging nodig hebt.

Databases, config-bestanden en omgevingsvariabelen

Base64 is een tekstcontainer, en daarom duikt hij op op plekken waar je het niet verwacht. In databases kan een binaire blob (een bestand, een icoon, een geserialiseerde structuur) in een TEXT-kolom als Base64 leven en elke tool overleven die tekst aannemt. Verwacht dat de opgeslagen waarde ongeveer 33 procent groter is dan het origineel, en dimensioneer je kolommen daarop. In config-bestanden en omgevingsvariabelen is Base64 de truc om waarden te smokkelen die anders het formaat zouden breken: een database-DSN met puntkomma's, een wachtwoord met aanhalingstekens, een waarde met een regeleinde.

// .env of config, geschreven door de ops-medewerker:
//   DB_DSN_B64 = cGc6aG9zdD1kYjtwYXNzd29yZD1xdSJvdGU=
$dsn = base64_decode(getenv('DB_DSN_B64') ?: '', true);
if ($dsn === false) {
  exit('DB_DSN_B64 is not valid Base64.');
}
// $dsn is nu: pg:host=db;password=qu"ote

Dezelfde voorzichtigheid geldt hier dubbel. Eerst: dit is formatveiligheid, geen geheimhouding: het moment dat een ontwikkelaar het config-bestand leest, kan hij de waarde in één aanroep decoderen. Sla een secret nooit op als Base64 en noem het daarna geëncrypteerd. Ten tweede: valideer bij het opstarten: een beschadigde of half geplakte env-waarde is een false van de strikte aanroep, en een check van één regel verandert een cryptische runtime-fout in een opstartbericht waarop je kunt handelen.

Vanaf de command line

Niet alle decoding gebeurt binnen een webverzoek. CLI-scripts, cronjobs en one-liners decoderen Base64 de hele tijd, en de command line is waar de functie op php://stdin stuit:

php -r 'fwrite(STDOUT, base64_decode(file_get_contents("php://stdin"), true));' < payload.b64 > restored.bin

De shell heeft al zijn eigen Base64-utility (coreutils base64 -d), en die is prima voor snelle klusjes; de PHP one-liner is voor wanneer de volgende stap PHP-logica is: schrijven naar een database, een API aanroepen, een validatie draaien. Twee shell-specifieke valkuilen. De uitvoer van een decode is ruwe bytes, dus stuur die naar een bestand of naar een commando dat bytes begrijpt, niet naar een terminal die ze zou vervormen. En houd de strict-flag aan in de one-liner, want een afgeknakte plak in een terminal verdient een false, niet drie bytes rommel.

Valkuilen met een PHP-accent

Een snelle rondleiding door de vallen die specifiek zijn voor PHP, op één plek verzameld:

  • De soepele standaard is de grote. base64_decode('V@hpcy') geeft drie bytes rommel zonder waarschuwing, dus elke decoder van niet-te-vertrouwen invoer heeft de strict-flag en een false-check nodig.
  • Eén enkel teken decodeert in soepele modus naar een lege tekenreeks, en hetzelfde doet een tekenreeks van alleen maar spaties. Een leeg resultaat bewijst bijna niets; alleen false betekent falen, en dat krijg je alleen in strict-modus.
  • De + in een query string is al een spatie voordat PHP hem ziet. Als een client ?token=abc+def stuurt zonder percent-codering, reikt PHP je abc def aan (dat is form-codering-gedrag, gedeeld door parse_str() en urldecode()), en geen hoeveelheid decoding-toverij brengt de plus terug. URL-safe Base64 (geen plus) is de oplossing voor tokens in URLs.
  • Ontbrekende vulling wordt voor je ingevuld, stilletjes. Zeven tekens decoderen als acht; dat is handig, maar het betekent ook dat een payload die door een of twee vultekens is afgeknakt, toch nog zonder murren decodeert, dus een schone decode bewijst nooit helemaal dat de payload heel aankwam (de raw-encoders van Go en Java zijn even zo nageeflijk).
  • Het spook van mbstring.func_overload. De lang verouderde instelling die strlen() en co herschreef om tekens te tellen (verwijderd in PHP 8.0) brak vroeger de Base64-byte-wiskunde op UTF-8-tekenreeksen. Legacy code die je overneemt kan nog steeds comments en workarounds voor die instelling bevatten. Verwijder ze.
  • Gedecodeerde bytes zijn geen UTF-8-tekenreeks. preg_match() met de /u-flag of mb_substr() op gedecodeerd binair draaien is direct een bron van 'malformed input'-fouten. Snuffel eerst, beslis daarna.
  • null doorgeven is verouderd sinds PHP 8.1. Als een variabele null kan zijn, coalesceer die dan naar '' vóór de aanroep.
  • $_GET en co worden gedecodeerd met form-regels, niet met URL-regels. Als een waarde percent-gecodeerd aankwam, is rawurldecode() de veiligere omkering, want die laat + onaangetast.

Een korte geschiedenis van base64_decode

Base64 zelf is ouder dan het grootste deel van het moderne web (de standaard die het bestuurt, RFC 4648, dateert uit 2006, en die codificeerde de MIME-codering uit 1996, die op zijn beurt afstamt van de PEM-omhulling van het begin van de jaren 90). Het PHP-verhaal is een changelog van zijn eigen kleine wereld.

PHP 4 bracht base64_decode() uit als kernfunctie zonder opties en zonder strict-modus; de soepele stemming was de enige stemming, en er was geen manier om de decoder te vragen te klagen. PHP 5.2.0, in november 2006, voegde de $strict-flag toe, en de changelog-regel is het lezen waard: die werd toegevoegd om RFC 3548-naleving af te dwingen, de voorloper van de huidige RFC 4648. Die ene flag bleek de nuttigste toevoeging in het leven van de functie te zijn.

Daarna kwamen de debug-jaren. PHP 5.3 fixeerde een reeks strict-modus-bugs over twee point releases: bug #52327 (vooraanstaande vulling onjuist behandeld in strict-modus, gefixt in 5.3.4) en bug #55273 (witruimte na de vulling afgewezen in strict-modus, gefixt in 5.3.9). (Een integer-overflow-fix uit 2016 is ook onder de naam van deze functie geregistreerd: bug #72836, officieel getiteld 'integer overflow in base64_decode caused heap corruption' en gefixt in 5.6.25, maar de eigen reproductiecode van het bugrapport en de gefixte functie tonen dat de echte overflow zat in de lengteberekening van base64_encode(), niet in de decoder; de titel is een onjuiste benaming geërfd van het oorspronkelijke rapport.) Elke fix schiepte het gedrag aan dat je in de tabel hierboven ziet. PHP 8.0 gaf beide Base64-functies native parameter- en teruggeeftypes, de signatuur die je bovenaan dit artikel zag, en dezelfde release-lijn verwijderde mbstring.func_overload, de instelling die jarenlang stilletjes de byte-wiskunde had gebroken. PHP 8.1 maakte het doorgeven van null aan beide functies verouderd. Sindsdien is het oppervlak bevroren: één parameter, één flag, één teruggeeftype, onveranderd.

Een paar nerdpretjes

Omdat dit een referentie in de lange vorm is, hier wat PHP-specifieke feiten die gewoon leuk zijn:

  • De lege identiteit. Zowel base64_encode('') als base64_decode('') is ''. De functies behandelen leegte als een eerste-klassige waarde in beide richtingen, zonder dat false van de partij is.
  • Een vreemd adres. In de PHP-handleiding wonen beide Base64-functies in het 'URLs'-hoofdstuk van het 'Overige basisextensies'-boek. Er is geen apart 'encoding'-hoofdstuk; daar vind je ze, bovenaan de lijst van dat hoofdstuk, vóór parse_url() en co.
  • De decoder is een homomorfisme. Een klassieke php.net-gebruikersnotitie observeert dat de functie een homomorfisme is tussen tekenreeksen die in modulo-4- en modulo-3-segmenten zijn gesplitst, wat de formele manier is om te zeggen dat elke split in veelvouden van vier een geldige split is. Daarom werkt de sectie over decoding in chunks in het geheel, en daarom kan een bestand van 1 MB worden gedecodeerd in stukken van 50 KB zonder verlies.
  • Eén parameter, één flag. In meer dan twintig jaar kreeg base64_decode() exact één parameter ($strict), en base64_encode() er geen.
  • Er zijn oudere broers. Dezelfde kernextensie draagt ook convert_uuencode() en convert_uudecode() (onder String Functions in de handleiding vermeld), relikwieën uit het dial-up-tijdperk waarin uuencode de binaire transport was die iedereen koos. Je zult ze amper nodig hebben, maar als ooit een antiekie .uu-bestand in je inbox landt, kan PHP het openen.
  • Strict-modus houdt een deur open voor e-mail. De vier witruimtetekens (spatie, tab, carriage return en line feed) varen met opzet door de strict-modus heen, dus een MIME-omwikkelde bijlage heeft geen voorverwerking nodig. Alles anders, NUL-bytes incluis, is een false.

De andere richting

Dat is de decoderkant, en daar woont het meeste pijn, want decoderen is waar je de data van anderen ontmoet: hun vullingskeuzes, hun regeleindes, hun tekensets, hun tokens. De andere richting, bytes omzetten in een Base64-tekenreeks met base64_encode(), is een rustiger dier: het faalt nooit, het heeft geen strict-modus, en zijn eigen stel vallen (dubbele codering, omwikkel-mismatches, de groottefactuur) krijgt zijn eigen gids. Base64-codering in PHP, gelinkt vanaf deze pagina, behandelt de encoder in dezelfde diepte.

Laatst bijgewerkt: 2026-10-06

Gerelateerd artikel: Base64-codering in PHP: een complete gids