<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: zbinze</title>
    <description>The latest articles on DEV Community by zbinze (@toolstead).</description>
    <link>https://dev.to/toolstead</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4205621%2F0d3fb5b3-ff3c-4dd0-8687-d04a7becf9d7.png</url>
      <title>DEV Community: zbinze</title>
      <link>https://dev.to/toolstead</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/toolstead"/>
    <language>en</language>
    <item>
      <title>URL Encoding vs Base64: When to Use Which</title>
      <dc:creator>zbinze</dc:creator>
      <pubDate>Sat, 14 Nov 2026 06:07:50 +0000</pubDate>
      <link>https://dev.to/toolstead/url-encoding-vs-base64-when-to-use-which-1na4</link>
      <guid>https://dev.to/toolstead/url-encoding-vs-base64-when-to-use-which-1na4</guid>
      <description>&lt;p&gt;Both percent-encoding and Base64 turn input into a restricted set of ASCII characters, both are reversible, and both are routinely described as "making a string safe". They are also answers to completely different questions, which is why choosing between them by feel produces data that survives the round trip in testing and falls apart in a log file three weeks later.&lt;/p&gt;

&lt;p&gt;The distinction is one sentence long: &lt;strong&gt;percent-encoding protects characters that have syntactic meaning inside a URL, and Base64 re-encodes bytes that are not text at all.&lt;/strong&gt; Get that backwards and nothing throws an exception — the value simply arrives different, or half of it arrives.&lt;/p&gt;

&lt;h1&gt;
  
  
  The rule, stated once
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;If what you have is &lt;strong&gt;text&lt;/strong&gt; that must sit inside a URL component (a search term, a filename, a redirect target), &lt;strong&gt;percent-encode it&lt;/strong&gt; for that component.&lt;/li&gt;
&lt;li&gt;If what you have is &lt;strong&gt;bytes&lt;/strong&gt; (an image, a hash, an encryption key, a protobuf), &lt;strong&gt;Base64 them&lt;/strong&gt; — and then percent-encode the Base64 if it has to travel in a URL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second clause is the one people skip, and it is the source of the classic bug covered below. The two transforms compose; they do not compete.&lt;/p&gt;

&lt;h1&gt;
  
  
  What each one actually guarantees
&lt;/h1&gt;

&lt;p&gt;Percent-encoding is defined &lt;em&gt;per component of the URL grammar&lt;/em&gt;. It says: replace the bytes that are not allowed here with &lt;code&gt;%&lt;/code&gt; followed by two hex digits. Its output is still text with the same meaning — a human can read an encoded path and see what it was. Its cost is unbounded in the worst case: an ASCII character costs one &lt;code&gt;%XX&lt;/code&gt; triplet if it needs escaping (three characters), and a character outside ASCII costs one triplet per UTF-8 byte, so a single CJK character is nine characters.&lt;/p&gt;

&lt;p&gt;Base64 is defined over &lt;strong&gt;bytes&lt;/strong&gt;, with no knowledge of URLs. Its output uses 64 characters — &lt;code&gt;A&lt;/code&gt;–&lt;code&gt;Z&lt;/code&gt;, &lt;code&gt;a&lt;/code&gt;–&lt;code&gt;z&lt;/code&gt;, &lt;code&gt;0&lt;/code&gt;–&lt;code&gt;9&lt;/code&gt;, &lt;code&gt;+&lt;/code&gt;, &lt;code&gt;/&lt;/code&gt; — plus &lt;code&gt;=&lt;/code&gt; padding, and it expands by exactly one third. All of those characters are legal in a URL &lt;em&gt;as characters&lt;/em&gt;, which is exactly why Base64 breaks there: &lt;code&gt;+&lt;/code&gt; means something else, &lt;code&gt;/&lt;/code&gt; means something else, and &lt;code&gt;=&lt;/code&gt; is used by other parts of the URL syntax. Legal, and load-bearing, are not the same property.&lt;/p&gt;

&lt;h1&gt;
  
  
  The failure everyone hits: Base64 in a query string
&lt;/h1&gt;

&lt;p&gt;Take an image encoded as standard Base64 and dropped into a query parameter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://example.test/img?data=iVBORw0KGgoAAAANSUhEUg...+/8AAAAASUVORK5CYII=
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every character there is legal in a URL, so the browser sends it and the server receives it. Then the server parses the query string with &lt;code&gt;application/x-www-form-urlencoded&lt;/code&gt; rules — which is what most frameworks do by default — and in those rules &lt;code&gt;+&lt;/code&gt; means a &lt;strong&gt;space&lt;/strong&gt;. The Base64 that arrives is not the Base64 that was sent. If it decodes at all, it decodes to different bytes; more often it fails at a length that is not a multiple of four, at a totally different character position from the one that was altered.&lt;/p&gt;

&lt;p&gt;There are two correct fixes, and they are not interchangeable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Percent-encode the Base64&lt;/strong&gt; before appending it, and decode in the reverse order on the way back. &lt;code&gt;+&lt;/code&gt; becomes &lt;code&gt;%2B&lt;/code&gt;, which form parsing leaves alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the URL-safe alphabet&lt;/strong&gt; — &lt;code&gt;-&lt;/code&gt; and &lt;code&gt;_&lt;/code&gt; instead of &lt;code&gt;+&lt;/code&gt; and &lt;code&gt;/&lt;/code&gt; — which removes the ambiguity at the source rather than escaping around it. That alphabet, and where it comes from, is covered in &lt;a href="https://toolstead.net/blog/url-safe-base64-vs-standard/" rel="noopener noreferrer"&gt;URL-safe Base64 vs standard Base64&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The order of operations is worth writing down, because it is easy to get one step wrong and hard to see: &lt;code&gt;bytes → Base64 → percent-encode → query string&lt;/code&gt;, and in reverse &lt;code&gt;query string → percent-decode → Base64-decode → bytes&lt;/code&gt;. A percent-decode after a Base64-decode is a bug that only shows up on inputs containing &lt;code&gt;%&lt;/code&gt;.&lt;/p&gt;

&lt;h1&gt;
  
  
  encodeURI vs encodeURIComponent
&lt;/h1&gt;

&lt;p&gt;JavaScript offers both, and the difference is exactly the "per component" rule.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'a&amp;amp;b=c d'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// 'a%26b%3Dc%20d'&lt;/span&gt;
&lt;span class="nf"&gt;encodeURI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://x.test/?a&amp;amp;b=c d'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 'https://x.test/?a&amp;amp;b=c%20d'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;encodeURI&lt;/code&gt; is designed to take a &lt;strong&gt;whole URL you already assembled&lt;/strong&gt;. It therefore leaves &lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;=&lt;/code&gt;, &lt;code&gt;?&lt;/code&gt;, &lt;code&gt;/&lt;/code&gt; and &lt;code&gt;:&lt;/code&gt; alone — they are the URL's structure, and encoding them would destroy it. Used on a value that is then pasted into a query parameter, it leaves the &lt;code&gt;&amp;amp;&lt;/code&gt; alone, and the &lt;code&gt;&amp;amp;&lt;/code&gt; splits your value into a second parameter. That is a straightforward injection: user text becomes URL syntax.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;encodeURIComponent&lt;/code&gt; is designed for &lt;strong&gt;one piece of a URL&lt;/strong&gt;, so it encodes everything structural. It is the function to reach for by default, and one of the few places a rule of thumb is genuinely correct: if you are not certain, encode harder — an over-encoded unreserved character is harmless, an under-encoded &lt;code&gt;&amp;amp;&lt;/code&gt; is a parameter injection.&lt;/p&gt;

&lt;p&gt;Note also what &lt;code&gt;encodeURIComponent&lt;/code&gt; does not encode: &lt;code&gt;! ' ( ) * - . _ ~&lt;/code&gt; and alphanumerics are left as-is, because RFC 3986 marks them as unreserved. That is correct, and it is why two different producers can emit different-looking but equivalent strings — a fact that matters later, when a signature or cache key is computed over one of them.&lt;/p&gt;

&lt;h1&gt;
  
  
  Plus and space, the rule behind the folklore
&lt;/h1&gt;

&lt;p&gt;&lt;code&gt;+&lt;/code&gt; meaning space is not a URL rule. It is a rule of &lt;code&gt;application/x-www-form-urlencoded&lt;/code&gt;, the format used by HTML forms and, by convention, by query strings on many servers. Consequences worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In a &lt;strong&gt;query string&lt;/strong&gt;, &lt;code&gt;+&lt;/code&gt; is usually read as a space. In a &lt;strong&gt;path&lt;/strong&gt;, it is a literal plus. So &lt;code&gt;?q=C%2B%2B&lt;/code&gt; and &lt;code&gt;?q=C++&lt;/code&gt; are different on a standards-compliant parser, and &lt;code&gt;%2B&lt;/code&gt; is the only reliable way to send a literal plus in a value.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;encodeURIComponent&lt;/code&gt; produces &lt;code&gt;%20&lt;/code&gt; for a space, not &lt;code&gt;+&lt;/code&gt;. &lt;code&gt;new URLSearchParams({ q: 'a b' }).toString()&lt;/code&gt; produces &lt;code&gt;q=a+b&lt;/code&gt; — same meaning, different spelling. Both decode correctly under a form parser; neither decodes correctly under a strict URL parser that treats &lt;code&gt;+&lt;/code&gt; literally. Which one you get is decided by the library, not by you, which is why round-tripping a value through two libraries is a reliable way to grow an extra space.&lt;/li&gt;
&lt;li&gt;When a value is &lt;em&gt;already&lt;/em&gt; encoded and you encode it again, spaces and &lt;code&gt;+&lt;/code&gt; do not survive a naive double transform. That is the topic of &lt;a href="https://toolstead.net/blog/percent-encoding-reserved-characters/" rel="noopener noreferrer"&gt;percent-encoding: the reserved characters that break URLs&lt;/a&gt;, which goes through the character-level rules this section summarises.&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  When neither belongs in a URL
&lt;/h1&gt;

&lt;p&gt;Both transform encodings cost something, and neither is a solution to "the URL is too long". Percent-encoding can triple the length of ASCII input and worse for non-ASCII; Base64 adds a flat 33% and then gets percent-encoded, so a binary payload in a URL can end up nearly twice its size in characters before anything else is counted — see &lt;a href="https://toolstead.net/blog/base64-overhead-33-percent/" rel="noopener noreferrer"&gt;why Base64 costs 33%&lt;/a&gt; for the arithmetic.&lt;/p&gt;

&lt;p&gt;Practical limits are the reason this matters: older browsers capped URLs near 2,083 characters, many servers and proxies cap the request line at 8 KB, and every URL ends up in logs, analytics and referrer headers, where a megabyte of encoded data is not something anyone wants to retain. If the payload is more than a token or a short parameter, it belongs in the request body, not in the query string. If it is binary and it must be in a URL — a signed thumbnail, an inline data URI — that is a design decision with a cost, and Base64 plus percent-encoding is that cost. If you want to see where that cost lands, &lt;a href="https://toolstead.net/base64-to-image/" rel="noopener noreferrer"&gt;decode the data URI back into an image&lt;/a&gt; — the decoded byte count is printed next to the payload you pasted.&lt;/p&gt;

&lt;h1&gt;
  
  
  The decision list
&lt;/h1&gt;

&lt;ol&gt;
&lt;li&gt;User text going into a URL component → &lt;code&gt;encodeURIComponent&lt;/code&gt;, or the equivalent in your language.&lt;/li&gt;
&lt;li&gt;An assembled URL that needs non-ASCII or spaces escaped → &lt;code&gt;encodeURI&lt;/code&gt;, knowing it leaves structure intact.&lt;/li&gt;
&lt;li&gt;Binary going through a text channel → Base64; if the destination is a URL, use the URL-safe alphabet or percent-encode the result.&lt;/li&gt;
&lt;li&gt;Text going into a form field or a body → percent-encode only if the field is defined as URL-encoded; otherwise send it as-is.&lt;/li&gt;
&lt;li&gt;Anything large → the body, not the URL.&lt;/li&gt;
&lt;li&gt;Base64 inside a JSON string inside a URL — the case that catches everyone twice — encode the Base64 first, then the JSON, then the URL component, and decode in exact reverse. &lt;a href="https://toolstead.net/url-encoder/" rel="noopener noreferrer"&gt;The URL encoder and decoder&lt;/a&gt; shows the character-by-character result of each step, which is the fastest way to see which layer is eating your &lt;code&gt;+&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>http</category>
      <category>beginners</category>
    </item>
    <item>
      <title>"MIFARE Classic Sector Dump: What the Hex Actually Says"</title>
      <dc:creator>zbinze</dc:creator>
      <pubDate>Fri, 13 Nov 2026 01:47:52 +0000</pubDate>
      <link>https://dev.to/toolstead/mifare-classic-sector-dump-what-the-hex-actually-says-2m4k</link>
      <guid>https://dev.to/toolstead/mifare-classic-sector-dump-what-the-hex-actually-says-2m4k</guid>
      <description>&lt;p&gt;A card dump is the only description of a credential that does not depend on trusting somebody's reader. Every other form — the number printed on the card, the value the panel shows, the frame on the wire — has already been through a conversion that somebody chose. The dump has not. It is the bytes as they sit in the card's memory.&lt;/p&gt;

&lt;p&gt;That also makes it the form people misread most often, because nothing in it is labelled and three of its conventions run against intuition.&lt;/p&gt;

&lt;h1&gt;
  
  
  The 1K layout, in one paragraph
&lt;/h1&gt;

&lt;p&gt;A MIFARE Classic 1K card holds 1024 bytes, organised as &lt;strong&gt;16 sectors of 4 blocks&lt;/strong&gt;, and each block is &lt;strong&gt;16 bytes&lt;/strong&gt;. Blocks are addressed from 0 to 63. In every sector, the last block is special: block 3 of sector 0, block 7 of sector 1, block 11 of sector 2, and so on. Data lives in the other three blocks of each sector; the fourth holds the keys and the permissions for that sector.&lt;/p&gt;

&lt;p&gt;So a full dump is 64 lines of 16 bytes if your tool prints one block per line, or 1024 bytes of continuous hex if it does not. When a dump arrives as one long string with no line breaks, the first thing to do is re-split it at 16 bytes — otherwise every offset you compute from it will be wrong, and wrong in a way that still produces plausible-looking numbers.&lt;/p&gt;

&lt;h1&gt;
  
  
  Sector 0, block 0 is not like the others
&lt;/h1&gt;

&lt;p&gt;The first block of the first sector is the manufacturer block, and it is read-only. Its bytes are laid out roughly like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;bytes 0 to 3 (or 0 to 6, or 0 to 9): the UID&lt;/li&gt;
&lt;li&gt;the byte after the UID: the BCC, a checksum computed as the XOR of the UID bytes&lt;/li&gt;
&lt;li&gt;the next byte: SAK&lt;/li&gt;
&lt;li&gt;the next two bytes: ATQA&lt;/li&gt;
&lt;li&gt;the rest: manufacturer data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two consequences follow. First, &lt;strong&gt;you cannot change a card's UID by writing to it&lt;/strong&gt; — that block refuses writes, which is why "UID changeable" cards are a separate product with a different chip inside, not a setting. Second, a 4-byte UID and a 7-byte UID produce different layouts inside the same block, so a script that hardcodes offsets 0 to 3 will read a 7-byte card's UID as "the first four bytes" and silently drop three.&lt;/p&gt;

&lt;h1&gt;
  
  
  The UID is stored backwards
&lt;/h1&gt;

&lt;p&gt;This is the part that costs people the most time. MIFARE stores the UID &lt;strong&gt;least significant byte first&lt;/strong&gt;, while most readers, panels and printed card numbers show it most significant byte first. A card whose dump begins &lt;code&gt;E1 5A 3C 04&lt;/code&gt; is the card a reader calls &lt;code&gt;043C5AE1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Anyone who has compared two readers already knows this failure mode from the other direction — see &lt;a href="https://toolstead.net/blog/card-number-formats-explained/" rel="noopener noreferrer"&gt;why card number formats differ&lt;/a&gt; for the same problem in decimal and in 125 kHz readers. The dump does not have a convention of its own; it simply preserves the chip's byte order, and every layer above it may or may not reverse that order before showing you a number.&lt;/p&gt;

&lt;p&gt;The BCC byte is a useful check here. XOR the UID bytes together and compare: if the result does not match the byte following the UID, either the UID length is not what you assumed, or the dump is not the block you think it is. It is a one-line check that catches the most common misreading.&lt;/p&gt;

&lt;h1&gt;
  
  
  The trailer block is three fields, not one
&lt;/h1&gt;

&lt;p&gt;The last block of each sector contains, in order: &lt;strong&gt;6 bytes of Key A&lt;/strong&gt;, &lt;strong&gt;4 bytes of access bits&lt;/strong&gt;, &lt;strong&gt;1 general-purpose byte&lt;/strong&gt;, and &lt;strong&gt;6 bytes of Key B&lt;/strong&gt;. That is 17 fields' worth of meaning packed into 16 bytes, and the reason a "key" is never a 16-byte value no matter how often it is described that way.&lt;/p&gt;

&lt;p&gt;Three things surprise people here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keys cannot be read back.&lt;/strong&gt; The card will tell you whether a key is correct, and nothing else. A dump therefore never contains the keys in the sense of revealing them — the tool that produced the dump either already knew them or the sector is showing its factory defaults.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The access bits are stored three times.&lt;/strong&gt; The four bytes hold the permission bits plus two inverted copies, so that a single flipped bit can be detected rather than obeyed. That is why the same configuration shows up as &lt;code&gt;FF 07 80&lt;/code&gt; in almost every tutorial: it is not a magic constant, it is one particular set of permissions written into all three copies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A factory-fresh card is recognisable.&lt;/strong&gt; When a sector has never been configured, its trailer usually still reads &lt;code&gt;FF FF FF FF FF FF FF 07 80 69 FF FF FF FF FF FF&lt;/code&gt;. If you see that in a sector you expected to be in use, the card has not been personalised — or somebody reset it, which for a credential is worse news than a wrong number.&lt;/p&gt;

&lt;h1&gt;
  
  
  The 4K cards break the pattern
&lt;/h1&gt;

&lt;p&gt;A 4K card is not "1K times four". It holds 4096 bytes in &lt;strong&gt;40 sectors&lt;/strong&gt;: sectors 0 to 31 are four blocks each, exactly like the 1K layout, and sectors 32 to 39 are &lt;strong&gt;sixteen blocks each&lt;/strong&gt;. Block numbering continues past 63 instead of stopping there, and the trailer of a 16-block sector is block 15 of that sector, not block 3.&lt;/p&gt;

&lt;p&gt;Any code that assumes "16 sectors" or "the trailer is at index 3" works perfectly on the card it was tested with and produces quietly wrong results on the other one. That is the same failure shape as reading a 34-bit credential with a 26-bit parser: no error, just a different and entirely plausible answer.&lt;/p&gt;

&lt;h1&gt;
  
  
  Three readings to distrust
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A number that looks clean.&lt;/strong&gt; If a UID reads as a round decimal or a familiar pattern, check the byte order before believing it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dump shorter than you expected.&lt;/strong&gt; Truncated dumps are common; a 1K card is 1024 bytes and a 4K card is 4096, so count before analysing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A sector with no trailer.&lt;/strong&gt; If your dump has 15 bytes in a block, the tool dropped one and every offset after it has shifted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To settle the first of those three, &lt;a href="https://toolstead.net/card-number-converter/" rel="noopener noreferrer"&gt;flip the byte order and compare&lt;/a&gt;: paste the UID in whichever reading your dump uses and the page prints both, each labelled with how it was read, so a reversed match is visible instead of inferred.&lt;/p&gt;

&lt;h1&gt;
  
  
  Where this connects to the wire
&lt;/h1&gt;

&lt;p&gt;The dump is the card side; the &lt;a href="https://toolstead.net/blog/wiegand-26-vs-34/" rel="noopener noreferrer"&gt;Wiegand frame&lt;/a&gt; is the wire side, and the two disagree in ways that matter. A panel often stores only the low bytes of a UID, or converts it through a facility-code layout that was designed for a different card generation. When a card "works everywhere except one door", the useful comparison is dump bytes against frame bits — not the numbers printed on two different screens.&lt;/p&gt;

&lt;p&gt;If you also have a file whose type you are inferring from its first bytes, &lt;a href="https://toolstead.net/blog/image-file-signatures/" rel="noopener noreferrer"&gt;reading signatures&lt;/a&gt; covers the same discipline for a different kind of binary: the first bytes are evidence, and the extension is not.&lt;/p&gt;

</description>
      <category>hardware</category>
      <category>security</category>
      <category>programming</category>
      <category>embedded</category>
    </item>
    <item>
      <title>Image File Signatures: PNG, JPEG, WebP, AVIF and More</title>
      <dc:creator>zbinze</dc:creator>
      <pubDate>Wed, 11 Nov 2026 01:43:07 +0000</pubDate>
      <link>https://dev.to/toolstead/image-file-signatures-png-jpeg-webp-avif-and-more-4d9b</link>
      <guid>https://dev.to/toolstead/image-file-signatures-png-jpeg-webp-avif-and-more-4d9b</guid>
      <description>&lt;p&gt;Give a decoder three things — a filename ending in &lt;code&gt;.png&lt;/code&gt;, a header saying &lt;code&gt;image/jpeg&lt;/code&gt;, and a byte stream that starts with &lt;code&gt;FF D8 FF&lt;/code&gt; — and only one of them is worth trusting. The extension is a naming convention that survives any rename. The declared MIME type is a claim written by whoever produced the data, and it is wrong often enough that treating it as fact is how you end up with a file that opens in your browser and breaks in every other tool.&lt;/p&gt;

&lt;p&gt;The byte stream is the only part that cannot lie, because those bytes are what a decoder has to draw.&lt;/p&gt;

&lt;h1&gt;
  
  
  What a signature actually is
&lt;/h1&gt;

&lt;p&gt;Every container format begins with a fixed pattern so that readers can identify it without parsing the whole file. The pattern is at a known offset — usually byte 0, sometimes byte 4 — and it is short: two to twelve bytes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PNG    89 50 4E 47 0D 0A 1A 0A     then an IHDR chunk
JPEG   FF D8 FF                     then a segment marker
GIF    47 49 46 38   ("GIF8")       then 39 or 37 for version
WebP   52 49 46 46 __ __ __ __ 57 45 42 50    ("RIFF" size "WEBP")
BMP    42 4D         ("BM")
TIFF   49 49 2A 00 (little-endian) / 4D 4D 00 2A (big-endian)
ICO    00 00 01 00
AVIF   66 74 79 70 at offset 4, major brand "avif"
HEIC   66 74 79 70 at offset 4, major brand "heic" or "mif1"
JP2    00 00 00 0C 6A 50 20 20 0D 0A 87 0A
PSD    38 42 50 53  ("8BPS")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in that list explain most of the confusion people run into.&lt;/p&gt;

&lt;p&gt;The first is that PNG's signature contains &lt;code&gt;0D 0A&lt;/code&gt; — a CRLF — and &lt;code&gt;1A&lt;/code&gt;, a DOS end-of-file marker. Both are there deliberately: they were chosen in the 1990s so that a PNG file transferred through a text-mode FTP connection would be visibly corrupted rather than silently mangled. It is also why a PNG's first bytes look like text control characters in a hex dump.&lt;/p&gt;

&lt;p&gt;The second is that AVIF, HEIC and HEIF &lt;strong&gt;share the same container&lt;/strong&gt;. They are all ISO base media files, so all of them begin with an &lt;code&gt;ftyp&lt;/code&gt; box; what distinguishes them is the four-character brand at offset 8. A decoder that only checks for &lt;code&gt;ftyp&lt;/code&gt; will happily report "HEIC" for an AVIF file. This is why a correct implementation returns a &lt;em&gt;ranked list&lt;/em&gt; of candidates rather than one answer, and why our &lt;a href="https://toolstead.net/base64-to-image/" rel="noopener noreferrer"&gt;Base64 to Image&lt;/a&gt; tool reports the evidence (&lt;code&gt;ftyp box + major brand "avif"&lt;/code&gt;) instead of just a format name.&lt;/p&gt;

&lt;h1&gt;
  
  
  Reading the bytes in JavaScript
&lt;/h1&gt;

&lt;p&gt;Four lines, no dependencies. Take the first twelve bytes after decoding and compare them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;head&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;ascii&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;len&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;len&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x89&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x4e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x47&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'image/png'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0xff&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0xd8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0xff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'image/jpeg'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x47&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x49&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x46&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x38&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'image/gif'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x52&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x49&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x46&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x46&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;ascii&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'WEBP'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'image/webp'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;ascii&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'ftyp'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;brand&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ascii&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;brand&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'avif'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;brand&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'avis'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'image/avif'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;brand&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'heic'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;brand&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'heix'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;brand&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'mif1'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'image/heic'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what happens after the signature check: &lt;code&gt;head&lt;/code&gt; is only the first twelve bytes, so this identifies the container, not the contents. A signature says "this is a PNG stream", never "this PNG is complete". Those are different questions, and conflating them is behind a large share of "the file looks fine but the image is half grey" reports — JPEG in particular has no total-length field in its header, so a truncated JPEG still looks like a perfectly legal JPEG for the first few kilobytes.&lt;/p&gt;

&lt;h1&gt;
  
  
  The four impostors you will actually meet
&lt;/h1&gt;

&lt;p&gt;When a decoder reports "not an image", the more useful question is &lt;em&gt;what is it&lt;/em&gt;. In practice, four families account for almost everything that arrives where a picture was expected.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Compressed archives.&lt;/strong&gt; &lt;code&gt;1F 8B&lt;/code&gt; is gzip, &lt;code&gt;78 9C&lt;/code&gt; is a zlib stream, &lt;code&gt;50 4B 03 04&lt;/code&gt; is a ZIP entry, &lt;code&gt;50 4B 05 06&lt;/code&gt; is an empty ZIP's end-of-central-directory record. Gzipped JSON that someone Base64-encoded to move it through a text channel lands here, and it is by far the most common cause of this report.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documents.&lt;/strong&gt; &lt;code&gt;25 50 44 46&lt;/code&gt; is &lt;code&gt;%PDF&lt;/code&gt;. A PDF that a browser previews inline is easy to mistake for an image when you only see a thumbnail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Executables.&lt;/strong&gt; &lt;code&gt;7F 45 4C 46&lt;/code&gt; is ELF, &lt;code&gt;00 61 73 6D&lt;/code&gt; is a WebAssembly module, &lt;code&gt;FF FE&lt;/code&gt; or &lt;code&gt;FE FF&lt;/code&gt; at offset 0 indicates UTF-16 text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text that was already encoded.&lt;/strong&gt; A string of &lt;code&gt;0-9a-f&lt;/code&gt; is hexadecimal, not Base64 with unusual characters. Decoding hex bytes as Base64 produces plausible-looking garbage instead of an error, which is why the distinction matters: the fix is to parse it as hex, not to keep re-encoding it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Telling someone "it decoded to 200 KB but the bytes are gzip" is a different, and much more useful, statement than "invalid input".&lt;/p&gt;

&lt;h1&gt;
  
  
  Why a signature check belongs before the &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; element
&lt;/h1&gt;

&lt;p&gt;If you assign decoded bytes to an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; and the format is wrong, the browser fails silently: a broken-image icon, an &lt;code&gt;error&lt;/code&gt; event, and nothing in the console. No exception, no diagnostic. A signature check converts that silence into a sentence, and it costs microseconds because it reads twelve bytes.&lt;/p&gt;

&lt;p&gt;To do it by hand on a file you already have, &lt;a href="https://toolstead.net/file-signature-checker/" rel="noopener noreferrer"&gt;drop the file and read its first bytes&lt;/a&gt; — the page prints the signature, the type the name claims and whether the two agree, and the file never leaves the browser.&lt;/p&gt;

&lt;p&gt;The same check is what makes it safe to accept raw strings with no &lt;code&gt;data:&lt;/code&gt; prefix at all — see &lt;a href="https://toolstead.net/base64-without-prefix/" rel="noopener noreferrer"&gt;the raw string decoder&lt;/a&gt; — because the format can be established from the bytes rather than from a header that may not exist.&lt;/p&gt;

&lt;p&gt;It is also the check that turns a silent failure into a sentence. When a payload decodes but the format is wrong, the useful output is not "invalid image" but "these bytes are gzip"; &lt;a href="https://toolstead.net/blog/base64-image-broken-fix/" rel="noopener noreferrer"&gt;Base64 image not showing&lt;/a&gt; walks through that decision order, and &lt;a href="https://toolstead.net/blog/decode-base64-image-python-node-browser/" rel="noopener noreferrer"&gt;decoding in Python, Node and the browser&lt;/a&gt; covers the runtime differences you hit while getting the bytes in the first place.&lt;/p&gt;

&lt;p&gt;One caveat about offsets: they are byte offsets into the &lt;strong&gt;decoded&lt;/strong&gt; stream, and Base64 decoding has to be finished first. Whitespace, line breaks and padding in the encoded form do not shift anything after decoding, which is exactly why stripping them first is safe. If you try to reason about signatures on the Base64 text itself, you get confused quickly: three bytes become four characters, so a PNG's eight-byte signature occupies about eleven characters in encoded form, and their values are unrelated to &lt;code&gt;89 50 4E 47&lt;/code&gt;. Decode first, then look.&lt;/p&gt;

&lt;h1&gt;
  
  
  Signatures are evidence, not guarantees
&lt;/h1&gt;

&lt;p&gt;None of this proves a file is well-formed. A signature check answers "which decoder should I hand this to", and a decoder's own validation answers the rest. That is the right division of labour: the signature is the cheap, reliable first question, and the format's own integrity rules — chunk lengths and a terminating &lt;code&gt;IEND&lt;/code&gt; for PNG, the marker chain for JPEG — do the expensive work afterwards.&lt;/p&gt;

&lt;p&gt;It also explains why "force this format" is a legitimate escape hatch for the rare file whose signature is wrong but whose contents are fine: real-world data is occasionally mislabelled at the source, and the bytes should be given the last word. Just be aware that you are overriding the only part of the input that was not a claim, so verify the result before shipping it.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>security</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Base64 Overhead: Why Data URIs Make Files 33% Bigger</title>
      <dc:creator>zbinze</dc:creator>
      <pubDate>Tue, 10 Nov 2026 01:48:13 +0000</pubDate>
      <link>https://dev.to/toolstead/base64-overhead-why-data-uris-make-files-33-bigger-406c</link>
      <guid>https://dev.to/toolstead/base64-overhead-why-data-uris-make-files-33-bigger-406c</guid>
      <description>&lt;p&gt;Base64 makes data bigger. Not slightly bigger — a third bigger, every time, with no exceptions and no settings to tune. The number is not a quirk of any particular implementation; it falls directly out of the encoding, and understanding where it comes from makes the inlining decision much easier.&lt;/p&gt;

&lt;h1&gt;
  
  
  Where the third comes from
&lt;/h1&gt;

&lt;p&gt;Base64 exists to represent arbitrary bytes using only characters that survive text transport. The alphabet has 64 symbols, which is 6 bits of information per character. Bytes are 8 bits. So the encoder reads 3 bytes (24 bits) at a time and writes 4 characters (4 × 6 = 24 bits).&lt;/p&gt;

&lt;p&gt;Three bytes in, four characters out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3 bytes → 4 chars        4 / 3 = 1.333...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole story. A 300,000-byte image becomes a 400,000-character string. The overhead is exactly 33.3%, and it applies to the payload only — the &lt;code&gt;data:image/png;base64,&lt;/code&gt; prefix adds a further 22 characters, which matters for tiny assets and is irrelevant for large ones.&lt;/p&gt;

&lt;p&gt;The one escape hatch is padding. If the input length is not a multiple of three, the last group is padded with &lt;code&gt;=&lt;/code&gt; characters that carry no information, so the exact size is &lt;code&gt;4 × ceil(n / 3)&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1 byte&lt;/strong&gt; → 4 characters, &lt;strong&gt;+300%&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3 bytes&lt;/strong&gt; → 4 characters, &lt;strong&gt;+33%&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;100 bytes&lt;/strong&gt; → 136 characters, &lt;strong&gt;+36%&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1,000 bytes&lt;/strong&gt; → 1,336 characters, &lt;strong&gt;+33.6%&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1,000,000 bytes&lt;/strong&gt; → 1,333,336 characters, &lt;strong&gt;+33.3%&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The small-input entries are the argument against inlining tiny assets for size reasons: a 1-byte file becomes 4 characters plus a 22-character prefix, and the prefix dominates.&lt;/p&gt;

&lt;h1&gt;
  
  
  Why gzip does not save you
&lt;/h1&gt;

&lt;p&gt;On text, gzip routinely achieves 60–80% reduction, which is why the 33% figure is sometimes dismissed as irrelevant. That reasoning holds for JSON and HTML and fails completely for images.&lt;/p&gt;

&lt;p&gt;Compression works by finding redundancy. JPEG, PNG, WebP and AVIF have already removed almost all of it — that is what those formats do — so their bytes are close to random from a compressor's point of view. Base64 encodes those incompressible bytes into a restricted 64-symbol alphabet, which adds no redundancy a compressor can exploit. The result is the worst of both worlds: the data is 33% larger and still does not compress.&lt;/p&gt;

&lt;p&gt;You can measure this in one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# a real image, already compressed&lt;/span&gt;
base64 -w0 photo.jpg &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; photo.b64
ls -l photo.jpg photo.b64
gzip -9 -c photo.b64 &lt;span class="o"&gt;|&lt;/span&gt; wc -c    &lt;span class="c"&gt;# barely smaller than photo.b64&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The garbled case is different. If you Base64-encode a &lt;em&gt;text&lt;/em&gt; payload — JSON, XML, a log — gzip will compress the encoded form well, sometimes better than the original, because Base64 of text still contains recognisable structure. So "does gzip fix the 33%" has two answers, and they depend entirely on whether the payload was compressible to begin with.&lt;/p&gt;

&lt;h1&gt;
  
  
  The cost is not only bytes
&lt;/h1&gt;

&lt;p&gt;Inflating a stylesheet or an HTML document has three costs that do not show up in a file-size comparison:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Render blocking.&lt;/strong&gt; A data URI inside a &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; block or a linked CSS file is part of that file. The browser cannot paint until it has parsed it, and it cannot parse it until it has downloaded it. An external image request is not on that critical path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No independent caching.&lt;/strong&gt; Change one byte of a stylesheet that inlines twenty icons and every visitor re-downloads all twenty. As separate files they would each have their own cache entry and their own validator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parsing and memory.&lt;/strong&gt; The bytes must be decoded and held as a string before they become an image. A 400 KB string in a stylesheet is 400 KB of text the parser must walk, and it stays in the CSSOM.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these are fatal. All of them are invisible if you only compare file sizes.&lt;/p&gt;

&lt;p&gt;Two of those three costs come from how the string is embedded rather than from Base64 itself, which is worth separating: &lt;a href="https://toolstead.net/blog/what-is-base64-data-uri/" rel="noopener noreferrer"&gt;what a data URI actually is&lt;/a&gt; walks the grammar and the four ways one fails to render, and most of them are wrapping problems rather than encoding problems.&lt;/p&gt;

&lt;h1&gt;
  
  
  Where the break-even actually sits
&lt;/h1&gt;

&lt;p&gt;There is no universal threshold, but the decision is narrow enough to state as rules of thumb:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Under ~2 KB, inline it.&lt;/strong&gt; The bytes saved by avoiding a request (headers alone are often 300–800 bytes over HTTP/1.1, less over HTTP/2) can exceed the 33%. A single-colour icon or a tiny SVG is the classic case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2–10 KB, it depends on caching.&lt;/strong&gt; A favicon or a logo that never changes can be inlined safely; something that ships weekly should not be, and an asset used on ten pages should be a file so it is downloaded once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over ~10 KB, use a file.&lt;/strong&gt; The overhead is now measured in kilobytes, there is no request to save, and you have given up caching for nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over ~1 MB, never inline it in a document.&lt;/strong&gt; Beyond the size penalty you are now in territory where some tools truncate long attributes and some parsers get slow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For images specifically, there is a better question than "how big is the Base64 string": &lt;em&gt;could this be an SVG?&lt;/em&gt; A vector logo is a few hundred bytes of path data, encodes to under a kilobyte, and is resolution-independent. Inlining that is a clear win, and it is the case where the technique earns its reputation.&lt;/p&gt;

&lt;p&gt;The table above covers round numbers. For a real file, &lt;a href="https://toolstead.net/image-to-base64/" rel="noopener noreferrer"&gt;convert an image and watch the payload grow&lt;/a&gt; — the page reports the exact character count, the byte count and the overhead that falls out of the two, in the Data URI, plain and CSS forms.&lt;/p&gt;

&lt;h1&gt;
  
  
  Measuring it on your own site
&lt;/h1&gt;

&lt;p&gt;Two numbers tell you whether inlining is helping: the transfer size of the document that contains the data URI, and the transfer size of the equivalent page with external assets. If the inlined version is 30% larger, it is 30% larger — the 33% is not recoverable by the network layer.&lt;/p&gt;

&lt;p&gt;The measurement is easy to get wrong in one specific way: comparing the &lt;em&gt;uncompressed&lt;/em&gt; size of the image with the &lt;em&gt;compressed&lt;/em&gt; size of the document. Compare like with like, and remember that the data URI's bytes travel inside a response that gzip can only partially shrink. Our &lt;a href="https://toolstead.net/png-to-base64/" rel="noopener noreferrer"&gt;PNG to Base64&lt;/a&gt; and &lt;a href="https://toolstead.net/jpg-to-base64/" rel="noopener noreferrer"&gt;JPG to Base64&lt;/a&gt; pages report the output length and the overhead as you convert, which is usually enough to see immediately that a 900 KB photograph should not be an inline background image.&lt;/p&gt;

&lt;p&gt;For the cases where you do want the string — a small icon in critical CSS, an email template that cannot reference external files, a single-file HTML export, a JSON fixture in a test — the overhead is a fair price for self-containment, and knowing the exact number keeps it a deliberate choice rather than an accident. If you are deciding between the two ways of holding an image in memory rather than in a document, &lt;a href="https://toolstead.net/blog/data-uri-vs-blob-url/" rel="noopener noreferrer"&gt;data URI vs blob URL&lt;/a&gt; covers the other half of this trade.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>javascript</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Decode a Base64 Image in Python, Node.js and the Browser</title>
      <dc:creator>zbinze</dc:creator>
      <pubDate>Mon, 09 Nov 2026 02:05:32 +0000</pubDate>
      <link>https://dev.to/toolstead/decode-a-base64-image-in-python-nodejs-and-the-browser-3kei</link>
      <guid>https://dev.to/toolstead/decode-a-base64-image-in-python-nodejs-and-the-browser-3kei</guid>
      <description>&lt;p&gt;Base64 is standardised, but decoders are not standardised in how forgiving they are. The same string that Python accepts can be silently mangled by Node, and the same string that Node happily decodes will make &lt;code&gt;atob&lt;/code&gt; throw. Knowing which runtime does what turns a confusing failure into an expected one.&lt;/p&gt;

&lt;h1&gt;
  
  
  Python: strict, explicit, and the best choice for validation
&lt;/h1&gt;

&lt;p&gt;Python's &lt;code&gt;base64&lt;/code&gt; module is the most honest of the three. By default it ignores newlines and other characters it does not recognise in the middle of the string; with &lt;code&gt;validate=True&lt;/code&gt; it rejects anything outside the alphabet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;binascii&lt;/span&gt;

&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;uri&lt;/span&gt;
&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# base64url → standard
&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                        &lt;span class="c1"&gt;# restore dropped padding
&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;validate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;binascii&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SystemExit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;not valid base64: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;out.png&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;wb&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things in that snippet are worth pointing out. The padding repair happens &lt;em&gt;before&lt;/em&gt; validation, so a string that lost its &lt;code&gt;=&lt;/code&gt; in transit is accepted. The alphabet conversion happens before padding repair, because the padding calculation uses the length after substitution — the two are the same length, so the order is safe, but doing it the other way round in a language with different assumptions is a common source of off-by-one bugs. And &lt;code&gt;binascii.Error&lt;/code&gt; is the exception to catch; &lt;code&gt;base64.b64decode&lt;/code&gt; does not raise &lt;code&gt;ValueError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you want the strictest possible check, decode and then verify the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PIL&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;
&lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BytesIO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;# raises on a truncated or structurally broken file
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;verify()&lt;/code&gt; is the closest thing to answering "is this actually a complete image", which is a different question from "did the Base64 decode". A truncated JPEG decodes perfectly and fails here.&lt;/p&gt;

&lt;h1&gt;
  
  
  Node.js: lenient to the point of being dangerous
&lt;/h1&gt;

&lt;p&gt;&lt;code&gt;Buffer.from(s, 'base64')&lt;/code&gt; does not throw on invalid input. It discards characters it does not recognise and returns whatever it could decode, which means a truncated or polluted string produces bytes with no error at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is convenient for forgiving input and terrible for validation, because a string that lost the last 20% of its characters still returns a buffer. If you are decoding data you did not produce, validate the shape before decoding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\r\n\t&lt;/span&gt;&lt;span class="sr"&gt; &lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;truncated: length % 4 === 1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9+&lt;/span&gt;&lt;span class="se"&gt;/&lt;/span&gt;&lt;span class="sr"&gt;=&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;illegal character in payload&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Node also handles base64url natively, which avoids the manual substitution entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64url&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// '-' and '_' understood, padding optional&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For files on disk, the whole thing is a one-liner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;writeFileSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;out.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  The browser: &lt;code&gt;atob&lt;/code&gt;, bytes, and one crucial detail
&lt;/h1&gt;

&lt;p&gt;&lt;code&gt;atob&lt;/code&gt; decodes to a "binary string" — one character per byte — which is not the same thing as bytes. Passing those characters anywhere that expects text corrupts everything above &lt;code&gt;0x7F&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;   &lt;span class="c1"&gt;// real bytes&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Uint8Array.from&lt;/code&gt; step is not optional. &lt;code&gt;new Blob([atob(payload)])&lt;/code&gt; looks equivalent and produces a corrupt file for any image containing a byte above 127 — which is most of them, since compressed data is close to uniformly random. The symptom is an image that "almost" works: correct dimensions, green or grey bands, or a decoder rejecting it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;atob&lt;/code&gt; is also the strictest of the three about whitespace. Per the current specification it throws &lt;code&gt;InvalidCharacterError&lt;/code&gt; on a newline, so a MIME-wrapped string must be stripped first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\r\n\t&lt;/span&gt;&lt;span class="sr"&gt; &lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  Command line, for when there is no script
&lt;/h1&gt;

&lt;p&gt;Two tools do this and they are not identical in strictness:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$B64&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/^.*,//'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; out.bin        &lt;span class="c"&gt;# GNU coreutils, stops on bad input&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | openssl &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; out.bin   &lt;span class="c"&gt;# -A ignores newlines&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;base64 -d&lt;/code&gt; on GNU systems reports &lt;code&gt;invalid input&lt;/code&gt; and exits non-zero on a character outside the alphabet, which makes it a decent validator. macOS uses BSD &lt;code&gt;base64&lt;/code&gt; with &lt;code&gt;-D&lt;/code&gt; instead of &lt;code&gt;-d&lt;/code&gt;. Follow either with a signature check, which is the only way to know what you got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;file out.bin          &lt;span class="c"&gt;# → PNG image data, 1 x 1, 8-bit/color RGBA, non-interlaced&lt;/span&gt;
xxd out.bin | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  A fifth way in the browser: let &lt;code&gt;fetch&lt;/code&gt; do it
&lt;/h1&gt;

&lt;p&gt;There is an approach that skips &lt;code&gt;atob&lt;/code&gt; entirely and avoids every size limit discussed above, because the browser decodes the data URI internally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;     &lt;span class="c1"&gt;// uri is the full data: string&lt;/span&gt;
&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;   &lt;span class="c1"&gt;// if you need the bytes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fetch&lt;/code&gt; understands &lt;code&gt;data:&lt;/code&gt; URLs as a scheme, so this works with no network request. It returns a &lt;code&gt;Blob&lt;/code&gt; directly, which pairs naturally with a blob URL and with &lt;code&gt;arrayBuffer()&lt;/code&gt; for byte inspection, and it does not build an intermediate binary string — so the double-memory problem of &lt;code&gt;atob&lt;/code&gt; plus &lt;code&gt;Uint8Array&lt;/code&gt; does not arise.&lt;/p&gt;

&lt;p&gt;Two caveats. A strict &lt;code&gt;Content-Security-Policy&lt;/code&gt; can block this: the fetch is governed by &lt;code&gt;connect-src&lt;/code&gt;, so a policy that does not allow &lt;code&gt;data:&lt;/code&gt; there will fail the request, and the error message talks about the connection rather than about Base64. And &lt;code&gt;fetch&lt;/code&gt; is asynchronous, which is usually an improvement but does change the shape of surrounding code.&lt;/p&gt;

&lt;p&gt;For a converter that decodes on every keystroke it is the simplest of the five approaches; for a script that needs to validate and repair the input first, doing the normalisation yourself and then calling one strict decoder remains clearer.&lt;/p&gt;

&lt;h1&gt;
  
  
  A behaviour comparison worth remembering
&lt;/h1&gt;

&lt;p&gt;Same input, three runtimes, three outcomes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Contains &lt;code&gt;\n&lt;/code&gt;&lt;/strong&gt; — Python: ignored by default · Node: ignored · browser &lt;code&gt;atob&lt;/code&gt;: &lt;strong&gt;throws&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contains spaces&lt;/strong&gt; — Python: ignored by default · Node: ignored · browser &lt;code&gt;atob&lt;/code&gt;: &lt;strong&gt;throws&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contains &lt;code&gt;%3D&lt;/code&gt;&lt;/strong&gt; — Python: error with &lt;code&gt;validate=True&lt;/code&gt; · Node: &lt;code&gt;%&lt;/code&gt; skipped · browser &lt;code&gt;atob&lt;/code&gt;: &lt;strong&gt;throws&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing padding&lt;/strong&gt; — Python: error · Node: decodes · browser &lt;code&gt;atob&lt;/code&gt;: &lt;strong&gt;throws&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Length % 4 == 1&lt;/strong&gt; — Python: error · Node: decodes (wrong bytes) · browser &lt;code&gt;atob&lt;/code&gt;: throws&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contains &lt;code&gt;-&lt;/code&gt; or &lt;code&gt;_&lt;/code&gt;&lt;/strong&gt; — Python: error (&lt;code&gt;urlsafe&lt;/code&gt; variant needed) · Node: &lt;code&gt;'base64url'&lt;/code&gt; handles it · browser &lt;code&gt;atob&lt;/code&gt;: &lt;strong&gt;throws&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern is that Node never tells you anything and Python tells you if you ask. Neither is wrong for its intended use — Node optimises for tolerant ingestion of real-world data, Python for correctness by default. The practical advice follows directly: &lt;strong&gt;decode with the strictest runtime you have, and do the normalisation yourself.&lt;/strong&gt; If you repair the input explicitly, record what you repaired, and verify the output bytes afterwards, the three runtimes agree — and you find out about a truncated string instead of rendering half an image.&lt;/p&gt;

&lt;p&gt;That last step, verifying the decoded bytes, is the one people skip. Two checks cover most of it: the first bytes should match a known format signature (see &lt;a href="https://toolstead.net/blog/image-file-signatures/" rel="noopener noreferrer"&gt;image file signatures&lt;/a&gt;), and the length should match what the producer reported. If the length is short, the string was truncated in transit — the arithmetic behind that check is in &lt;a href="https://toolstead.net/blog/base64-line-breaks-padding/" rel="noopener noreferrer"&gt;line breaks and padding&lt;/a&gt;. If you would rather not write it, &lt;a href="https://toolstead.net/base64-to-image/" rel="noopener noreferrer"&gt;Base64 to Image&lt;/a&gt; performs the whole sequence — normalise, repair, decode, identify, verify — and reports each step it took, including the ones it decided not to take, which is often the more useful information.&lt;/p&gt;

&lt;p&gt;The reverse direction has its own traps, and they are not symmetric. Encoding accepts bytes without complaint, so a wrong header or a missing prefix only surfaces later, at the consumer. &lt;a href="https://toolstead.net/blog/image-to-base64-javascript/" rel="noopener noreferrer"&gt;Image to Base64 in JavaScript&lt;/a&gt; covers that side, including what each browser appends to the string without being asked.&lt;/p&gt;

</description>
      <category>python</category>
      <category>node</category>
      <category>javascript</category>
      <category>base64</category>
    </item>
    <item>
      <title>Data URI vs Blob URL: Which Should You Use for Images?</title>
      <dc:creator>zbinze</dc:creator>
      <pubDate>Sun, 08 Nov 2026 01:35:33 +0000</pubDate>
      <link>https://dev.to/toolstead/data-uri-vs-blob-url-which-should-you-use-for-images-2m9b</link>
      <guid>https://dev.to/toolstead/data-uri-vs-blob-url-which-should-you-use-for-images-2m9b</guid>
      <description>&lt;p&gt;When you have image bytes in memory and want to display them, there are two ways to get them into an &lt;code&gt;img&lt;/code&gt; element without a server round trip. They are frequently treated as interchangeable, and they are not: one is a portable but expensive text encoding, the other is a cheap reference that only exists while the page does.&lt;/p&gt;

&lt;h1&gt;
  
  
  The short version
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data URI&lt;/strong&gt; — &lt;code&gt;data:image/png;base64,iVBOR...&lt;/code&gt; — the bytes encoded as text, embedded in the URL itself. Self-contained, portable, serialisable, ~33% larger, and it lives in the DOM as a string.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blob URL&lt;/strong&gt; — &lt;code&gt;blob:https://example.com/5f2a...&lt;/code&gt; — a reference to a &lt;code&gt;Blob&lt;/code&gt; the browser holds in memory. No encoding, no size penalty, but it exists only in the document that created it.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// data URI: the bytes are in the string&lt;/span&gt;
&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data:image/png;base64,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;base64Payload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// blob URL: the bytes stay in a Blob, the URL is just a handle&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// later, when the image is gone:&lt;/span&gt;
&lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;revokeObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  What each one costs
&lt;/h1&gt;

&lt;p&gt;The data URI's 33% is structural, not an implementation detail: Base64 packs three bytes into four characters, and image bytes are already compressed, so gzip does not win them back. A 300 KB photograph becomes a 400 KB string, and inside JavaScript that string is stored as UTF-16 — two bytes per character — so it occupies about 800 KB in memory, on top of the original bytes you still hold.&lt;/p&gt;

&lt;p&gt;The blob URL's cost is essentially zero. The browser keeps the bytes once, in the &lt;code&gt;Blob&lt;/code&gt; you already created, and the URL is a short string that points at them. There is no encode step, no decode step, and no second copy.&lt;/p&gt;

&lt;p&gt;That difference decides most cases on its own. If the payload is large or transient — a preview of a file the user just selected, a generated image, a decoded Base64 payload — a blob URL is the right answer.&lt;/p&gt;

&lt;h1&gt;
  
  
  What each one can do
&lt;/h1&gt;

&lt;p&gt;The trade-off runs the other way for portability, and this is where the choice stops being about size.&lt;/p&gt;

&lt;p&gt;A data URI is a &lt;strong&gt;value&lt;/strong&gt;. You can put it in a JSON response, write it into an HTML file, store it in &lt;code&gt;localStorage&lt;/code&gt;, paste it into an email, embed it in CSS, or send it to a colleague in a chat message. It survives serialisation, page reloads and process restarts, because everything needed is in the string.&lt;/p&gt;

&lt;p&gt;A blob URL is a &lt;strong&gt;handle&lt;/strong&gt;. It is scoped to the document that created it: open the same URL in a new tab and it means nothing; save it to a file and it is a dead reference; reload the page and every blob URL the previous document created becomes invalid. It also cannot be used across origins.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blob:https://example.com/5f2a...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;  &lt;span class="c1"&gt;// serialises fine&lt;/span&gt;
&lt;span class="c1"&gt;// ...and means nothing to whoever reads it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  The memory leak nobody notices
&lt;/h1&gt;

&lt;p&gt;Blob URLs must be revoked. Every &lt;code&gt;createObjectURL&lt;/code&gt; call pins a &lt;code&gt;Blob&lt;/code&gt; in memory until either the page is destroyed or you call &lt;code&gt;revokeObjectURL&lt;/code&gt;. In a preview that updates on every keystroke — exactly the pattern this site's decoder uses — forgetting to revoke means you accumulate one pinned image per paste:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;currentUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;revokeObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// release the previous one&lt;/span&gt;
  &lt;span class="nx"&gt;currentUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;currentUrl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Revoking a URL that an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; has already loaded is safe and correct: the image keeps its decoded pixels, and the underlying bytes are released. Revoking &lt;em&gt;while&lt;/em&gt; an image is still loading cancels the load in some browsers, so revoke the old URL when you replace it, not the new one you just assigned.&lt;/p&gt;

&lt;p&gt;The same leak exists for data URIs, but in a less visible form: a long string held in a DOM attribute is memory that only goes away when the element does. A 400 KB string set as &lt;code&gt;img.src&lt;/code&gt; and then replaced leaves the browser free to release it, but the string in your own variable does not go anywhere until you drop the reference.&lt;/p&gt;

&lt;h1&gt;
  
  
  Where each one wins in practice
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Use a blob URL when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;previewing a file or a decoded payload, especially more than a few hundred kilobytes;&lt;/li&gt;
&lt;li&gt;the image is generated (canvas, &lt;code&gt;createImageBitmap&lt;/code&gt;, a decoded Base64 payload) and you control its lifetime;&lt;/li&gt;
&lt;li&gt;you are downloading it — &lt;code&gt;URL.createObjectURL&lt;/code&gt; plus the &lt;code&gt;download&lt;/code&gt; attribute is the standard way to save bytes without a server;&lt;/li&gt;
&lt;li&gt;the same bytes are shown more than once, since the URL is reusable and the data URI would be duplicated in each attribute.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use a data URI when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the value has to survive outside the current document — JSON, &lt;code&gt;localStorage&lt;/code&gt;, an email template, an HTML file;&lt;/li&gt;
&lt;li&gt;you are embedding a small asset in CSS or an HTML document where a second request would cost more than the bytes;&lt;/li&gt;
&lt;li&gt;you are producing something a human will copy and paste, which is the entire premise of a Base64 converter.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Neither, when:&lt;/strong&gt; you already have a real URL. An object or file served over HTTP is cached, streamable, and costs the browser nothing to display. Encoding bytes to Base64 to avoid a request is a trade that only pays off for small assets — see &lt;a href="https://toolstead.net/blog/base64-overhead-33-percent/" rel="noopener noreferrer"&gt;why Base64 makes your page 33% bigger&lt;/a&gt; for where that line falls.&lt;/p&gt;

&lt;p&gt;There is one more case worth naming, because it is the one people reach for a data URI to solve: getting the string into a place that only accepts text — a JSON field, a config file, an email template. Neither a blob URL nor a data URI is the right answer there if the image is large; a real URL plus a text reference is. And if you are producing that string by hand rather than in code, &lt;a href="https://toolstead.net/image-to-base64/" rel="noopener noreferrer"&gt;Image to Base64&lt;/a&gt; exists precisely to emit the right wrapper for the consumer you have in mind.&lt;/p&gt;

&lt;p&gt;A note on decoding the other direction, since it comes up in the same code paths: if what you hold is a Base64 string rather than a &lt;code&gt;Blob&lt;/code&gt;, you can go straight to a blob URL without touching &lt;code&gt;atob&lt;/code&gt; — &lt;code&gt;await (await fetch(dataUri)).blob()&lt;/code&gt; does the decoding internally and hands you something &lt;code&gt;createObjectURL&lt;/code&gt; accepts. That is the shortest path from a pasted string to a rendered preview, and it is what this site's decoder does; &lt;a href="https://toolstead.net/base64-to-image/" rel="noopener noreferrer"&gt;Base64 to Image&lt;/a&gt; shows the result while also verifying that the bytes are what the header claimed.&lt;/p&gt;

&lt;p&gt;Either way the starting point is the same: bytes you already hold. When those bytes come from a file the user picked, the conversion step itself — read the file, encode it, attach a header that matches the real format — is in &lt;a href="https://toolstead.net/blog/image-to-base64-javascript/" rel="noopener noreferrer"&gt;Image to Base64 in JavaScript&lt;/a&gt;.&lt;/p&gt;

&lt;h1&gt;
  
  
  The one security note worth carrying
&lt;/h1&gt;

&lt;p&gt;Neither scheme executes scripts on its own: an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; element does not run scripts inside an SVG, whether the source is a data URI or a blob URL. The risk appears when the resource stops being an image — navigating a top-level document to an &lt;code&gt;image/svg+xml&lt;/code&gt; data URI, or passing user-supplied bytes to something that treats them as markup. Keep the rule narrow and concrete: images go in &lt;code&gt;img&lt;/code&gt;, and a &lt;code&gt;blob:&lt;/code&gt; URL is how you hand the browser bytes you already trust in memory.&lt;/p&gt;

&lt;p&gt;One CSP detail catches people out in both directions. Data URIs need &lt;code&gt;img-src data:&lt;/code&gt; to be allowed, and blob URLs need &lt;code&gt;img-src blob:&lt;/code&gt;. A page with &lt;code&gt;img-src 'self'&lt;/code&gt; blocks both — silently, with only a console violation to explain it. If you add either technique to a site with a strict CSP, add the matching source expression at the same time, or you will spend an afternoon debugging Base64 that was never broken.&lt;/p&gt;

&lt;h1&gt;
  
  
  Questions people actually ask
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;When should I use a data URI instead of a blob URL?&lt;/strong&gt; When the value has to survive outside the current document — a JSON response, &lt;code&gt;localStorage&lt;/code&gt;, an HTML file, an email template, or something a person will copy and paste. A data URI is a value that serialises; a blob URL is a handle that only means something inside the document that created it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does a &lt;code&gt;blob:&lt;/code&gt; link stop working after a reload?&lt;/strong&gt; Because it is scoped to the document. Reload the page and every blob URL the previous document created becomes invalid; open the same URL in a new tab and it refers to nothing; save it to a file and it is a dead reference. It cannot cross origins either. The bytes only ever existed in that document's memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does each one cost?&lt;/strong&gt; A data URI is about 33% larger, and that is structural — Base64 packs three bytes into four characters, and image bytes are already compressed, so gzip does not win it back. Inside JavaScript the string is stored as UTF-16, so a 300 KB photograph becomes a 400 KB string occupying roughly 800 KB of memory. A blob URL costs essentially nothing: the bytes stay in one &lt;code&gt;Blob&lt;/code&gt; and the URL is a short handle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I have to revoke blob URLs?&lt;/strong&gt; Yes. Every &lt;code&gt;createObjectURL&lt;/code&gt; call pins a &lt;code&gt;Blob&lt;/code&gt; in memory until the page is destroyed or you call &lt;code&gt;revokeObjectURL&lt;/code&gt;. In a preview that updates on every keystroke, forgetting this accumulates one pinned image per paste. Revoke the previous URL when you assign a new one — revoking a URL an image has already loaded is safe, but revoking while it is still loading cancels the load in some browsers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which should I use to preview a large file the user just selected?&lt;/strong&gt; A blob URL. Large or transient payloads are exactly the case where the 33% overhead and the doubled memory of a string in the DOM are pure loss, and you control the lifetime, so the revoke discipline is easy to get right.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>browser</category>
      <category>performance</category>
    </item>
    <item>
      <title>Base64 Line Breaks and Padding: Why They Break Decoders</title>
      <dc:creator>zbinze</dc:creator>
      <pubDate>Sat, 07 Nov 2026 01:42:07 +0000</pubDate>
      <link>https://dev.to/toolstead/base64-line-breaks-and-padding-why-they-break-decoders-5ckh</link>
      <guid>https://dev.to/toolstead/base64-line-breaks-and-padding-why-they-break-decoders-5ckh</guid>
      <description>&lt;p&gt;Two formatting rules cause more Base64 failures than every other cause combined. Both are part of the standard, both are routinely violated in the wild, and strict decoders reject the results — which means the same string can work in one language and fail in another.&lt;/p&gt;

&lt;p&gt;The first rule is that Base64 output is often wrapped across lines. The second is that the tail is padded with &lt;code&gt;=&lt;/code&gt; to round the length up to a multiple of four. Neither changes the data. Both change the string, and decoders disagree about how forgiving to be.&lt;/p&gt;

&lt;h1&gt;
  
  
  Why 76 characters
&lt;/h1&gt;

&lt;p&gt;Base64 predates JSON, HTTP/2 and email attachments. It was designed to survive transport through systems that cared about line length, and the dominant one was email: RFC 2045 wraps encoded bodies at &lt;strong&gt;76 characters&lt;/strong&gt;, a limit chosen to stay under SMTP's 998-octet line maximum while keeping quoted-printable output readable. Every four Base64 characters represent exactly three bytes, so 76 characters is 57 bytes per line — an awkward-looking number that is really just "72 characters plus a little headroom".&lt;/p&gt;

&lt;p&gt;That is why you see this shape in PEM files, MIME bodies and log output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAF
AAH/q842iQAAAABJRU5ErkJggg==
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The newline is not data. It is transport furniture that leaked into the copy-paste layer, and a decoder that treats the string as opaque bytes will stop at the first &lt;code&gt;\n&lt;/code&gt; and report an illegal character at position 76.&lt;/p&gt;

&lt;h1&gt;
  
  
  Why the padding exists, and why the remainder matters
&lt;/h1&gt;

&lt;p&gt;Each group of four Base64 characters encodes three bytes. When the input length is not a multiple of three, the encoder pads the &lt;em&gt;output&lt;/em&gt; with &lt;code&gt;=&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1 leftover byte → 2 characters + &lt;code&gt;==&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;2 leftover bytes → 3 characters + &lt;code&gt;=&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;0 leftover bytes → no padding&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the encoded length is always a multiple of four, once padding is included. Turn that around and you get the single most useful diagnostic rule in this whole area:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;After normalisation, a complete Base64 string's length modulo 4 is &lt;strong&gt;0, 2 or 3. A remainder of 1 is impossible.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A remainder of 1 does not mean "slightly malformed". It means characters are missing. There is no 1-byte remainder case in the encoding, so some input byte pair has been cut in half. In practice the cause is almost always truncation: a log line capped at 1024 characters, a terminal that wrapped and lost a line during selection, a database column with a &lt;code&gt;VARCHAR(n)&lt;/code&gt; limit, or a copy that stopped a character early.&lt;/p&gt;

&lt;p&gt;This is worth stating plainly because it is the difference between a fixable problem and an unfixable one. A decoder can repair missing padding — adding &lt;code&gt;=&lt;/code&gt; is deterministic and lossless. It cannot repair a missing character; anything it produces will be a corrupted image, and a tool that "helpfully" pads and decodes a remainder-of-1 string is handing you a picture that is wrong in a way you cannot see.&lt;/p&gt;

&lt;h1&gt;
  
  
  What different decoders do about it
&lt;/h1&gt;

&lt;p&gt;The behaviour you get depends entirely on which implementation you use, and the differences are not documented prominently anywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Python&lt;/strong&gt; is strict and explicit. &lt;code&gt;base64.b64decode(s)&lt;/code&gt; ignores newlines but rejects other junk; adding &lt;code&gt;validate=True&lt;/code&gt; makes it reject any character outside the alphabet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;
&lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;validate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# binascii.Error on bad input
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Node&lt;/strong&gt; is the opposite extreme. &lt;code&gt;Buffer.from(s, 'base64')&lt;/code&gt; silently discards characters it does not recognise. It will decode a string containing spaces, quotes and stray prose without complaint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;iVBO&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s1"&gt;Rw0K Ggo=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;   &lt;span class="c1"&gt;// 10 — no error, junk skipped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That leniency is convenient when you know the input is nearly right, and dangerous when you do not: a truncated string still returns bytes, just fewer of them, and nothing tells you the difference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browsers&lt;/strong&gt; sit in the middle. &lt;code&gt;atob&lt;/code&gt; throws &lt;code&gt;InvalidCharacterError&lt;/code&gt; on a newline — but only in strict spec-compliant form; historically it ignored whitespace. Normalising first removes the ambiguity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\r\n\t&lt;/span&gt;&lt;span class="sr"&gt; &lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  A normalisation order that does not lose information
&lt;/h1&gt;

&lt;p&gt;The order matters, because some repairs change what the next check sees. This sequence is what our &lt;a href="https://toolstead.net/base64-to-image/" rel="noopener noreferrer"&gt;Base64 to Image&lt;/a&gt; tool applies, in this order, reporting each step it took:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Strip the &lt;code&gt;data:&lt;/code&gt; prefix if present&lt;/strong&gt; — everything up to the first comma. If there is no comma, there is no prefix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decode percent-encoding&lt;/strong&gt; (&lt;code&gt;%3D&lt;/code&gt; → &lt;code&gt;=&lt;/code&gt;). This happens before the alphabet check, otherwise &lt;code&gt;%&lt;/code&gt; looks like an illegal character.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Remove whitespace and line breaks.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strip surrounding quotes&lt;/strong&gt;, which appear when a string is copied out of JavaScript, JSON or a shell script.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unescape literal &lt;code&gt;\n&lt;/code&gt; and &lt;code&gt;\t&lt;/code&gt; sequences&lt;/strong&gt;, which appear when the encoded string was itself stored inside a JSON string.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Convert the URL-safe alphabet&lt;/strong&gt; (&lt;code&gt;-&lt;/code&gt; → &lt;code&gt;+&lt;/code&gt;, &lt;code&gt;_&lt;/code&gt; → &lt;code&gt;/&lt;/code&gt;) if those characters are present.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix padding&lt;/strong&gt; — add the &lt;code&gt;=&lt;/code&gt; characters the length says are missing, or remove surplus ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the remainder.&lt;/strong&gt; If it is 1, stop and tell the user the data is truncated. Do not decode.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 7 and 8 are the pair that separates a useful decoder from a plausible one. Without step 8 you ship a corrupted image silently; without step 7 you reject every string that lost its padding in transit, which is a large fraction of them.&lt;/p&gt;

&lt;p&gt;Step 1 assumes there is a header to strip. In logs, database columns and message queues the string usually arrives bare, with no &lt;code&gt;data:&lt;/code&gt; at all — nothing to strip, and it is worth &lt;a href="https://toolstead.net/base64-without-prefix/" rel="noopener noreferrer"&gt;decoding the raw string without the prefix&lt;/a&gt; rather than pasting it into a converter that insists on one.&lt;/p&gt;

&lt;h1&gt;
  
  
  Deciding when to accept a repaired string
&lt;/h1&gt;

&lt;p&gt;Repair is not the same as validation. Adding padding is safe because it is reversible and determined by the data itself. Stripping whitespace is safe for the same reason. But once you have repaired the &lt;em&gt;form&lt;/em&gt;, the only real check on the &lt;em&gt;content&lt;/em&gt; is structural: does the decoded byte stream look like the thing it is supposed to be?&lt;/p&gt;

&lt;p&gt;That is why the tool reports what it changed rather than quietly fixing things. "Removed whitespace and line breaks (57 positions)" is information: it tells you the string went through a MIME-aware channel. "Added 2 padding characters" tells you the source dropped them — often a URL, a query parameter, or a JavaScript string that was trimmed. Both are normal, and both are worth knowing about when the image that comes out is not the image you expected.&lt;/p&gt;

&lt;p&gt;The one repair that should never be silent is removing characters from the middle of the data. If a decoder has to throw away something other than whitespace to make a string valid, the honest answer is that the input is not Base64 — see &lt;a href="https://toolstead.net/blog/what-is-base64-data-uri/" rel="noopener noreferrer"&gt;what a data URI actually allows&lt;/a&gt; for the header half of this problem, and &lt;a href="https://toolstead.net/blog/base64-image-broken-fix/" rel="noopener noreferrer"&gt;Base64 image not showing&lt;/a&gt; for the full troubleshooting order when you have already decoded something and it still will not display.&lt;/p&gt;

</description>
      <category>base64</category>
      <category>javascript</category>
      <category>debugging</category>
      <category>webdev</category>
    </item>
    <item>
      <title>URL-Safe Base64 vs Standard: How to Tell Them Apart</title>
      <dc:creator>zbinze</dc:creator>
      <pubDate>Thu, 29 Oct 2026 01:36:41 +0000</pubDate>
      <link>https://dev.to/toolstead/url-safe-base64-vs-standard-how-to-tell-them-apart-2h6l</link>
      <guid>https://dev.to/toolstead/url-safe-base64-vs-standard-how-to-tell-them-apart-2h6l</guid>
      <description>&lt;p&gt;Standard Base64 uses 64 characters: &lt;code&gt;A&lt;/code&gt;–&lt;code&gt;Z&lt;/code&gt;, &lt;code&gt;a&lt;/code&gt;–&lt;code&gt;z&lt;/code&gt;, &lt;code&gt;0&lt;/code&gt;–&lt;code&gt;9&lt;/code&gt;, and then &lt;code&gt;+&lt;/code&gt; and &lt;code&gt;/&lt;/code&gt;. Those last two are the problem. In a URL, &lt;code&gt;+&lt;/code&gt; means space in &lt;code&gt;application/x-www-form-urlencoded&lt;/code&gt;; &lt;code&gt;/&lt;/code&gt; is a path separator. In a filename, &lt;code&gt;/&lt;/code&gt; is a directory separator. So a string that is perfectly valid Base64 becomes structurally dangerous the moment you put it in a query parameter, a cookie value, or a JWT.&lt;/p&gt;

&lt;p&gt;The fix is an alphabet substitution, and it is where a surprising number of decoding failures begin.&lt;/p&gt;

&lt;h1&gt;
  
  
  The substitution, and the name it goes by
&lt;/h1&gt;

&lt;p&gt;URL-safe Base64 — also called base64url, or RFC 4648 §5 — replaces the two troublesome characters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;+&lt;/code&gt; becomes &lt;code&gt;-&lt;/code&gt; (hyphen)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/&lt;/code&gt; becomes &lt;code&gt;_&lt;/code&gt; (underscore)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything else is identical. Sixty-two characters are shared, so most strings are valid in both alphabets and decode to the same bytes. Only strings containing &lt;code&gt;+&lt;/code&gt;, &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;-&lt;/code&gt; or &lt;code&gt;_&lt;/code&gt; tell you which alphabet the producer had in mind.&lt;/p&gt;

&lt;p&gt;Two of those four characters are ambiguous, which is the heart of the problem. If a string contains &lt;code&gt;-&lt;/code&gt;, it is nearly certainly base64url. If it contains &lt;code&gt;+&lt;/code&gt;, it is standard. But a string containing only &lt;code&gt;[A-Za-z0-9]&lt;/code&gt; and &lt;code&gt;=&lt;/code&gt; is valid in both, and there is no way to tell from the text alone. Decoding it either way gives the same answer, so ambiguity is harmless there.&lt;/p&gt;

&lt;p&gt;The dangerous middle case is a &lt;strong&gt;standard&lt;/strong&gt; Base64 string that happens to contain &lt;code&gt;-&lt;/code&gt; or &lt;code&gt;_&lt;/code&gt;... except it cannot, because those characters are not in the standard alphabet. Which means the rule is simpler than it looks: if you see &lt;code&gt;-&lt;/code&gt; or &lt;code&gt;_&lt;/code&gt;, treat it as base64url and convert; if you see &lt;code&gt;+&lt;/code&gt; or &lt;code&gt;/&lt;/code&gt;, treat it as standard. If you see a mix, the string is not valid in either alphabet and something upstream concatenated two different strings.&lt;/p&gt;

&lt;h1&gt;
  
  
  Padding, again
&lt;/h1&gt;

&lt;p&gt;RFC 4648 §5 allows base64url to omit &lt;code&gt;=&lt;/code&gt; padding, and most producers of JWTs and URL tokens do exactly that. The padding carries no information — it is determined by the length — so it is pure overhead in a token that will be embedded in a header.&lt;/p&gt;

&lt;p&gt;This is why a raw JWT segment often has a length that is not a multiple of four:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is 36 characters, divisible by 4 by luck. A payload segment is often not. Strict decoders will reject it, so restore padding before decoding — it is deterministic and lossless:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;padded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  Converting between the two
&lt;/h1&gt;

&lt;p&gt;In JavaScript, browsers, and Node, the conversion is a two-character substitution in each direction, applied to the text form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toUrlSafe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\+&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/=+$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fromUrlSafe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/-/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;+&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/_/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;b64&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Node has it built in, in both directions, on both buffers and strings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64url&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// encode&lt;/span&gt;
&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64url&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// decode&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Python calls it &lt;code&gt;urlsafe_b64decode&lt;/code&gt;, and its counterpart is &lt;code&gt;urlsafe_b64encode&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;
&lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlsafe_b64decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the asymmetry that catches people out: &lt;code&gt;urlsafe_b64decode&lt;/code&gt; accepts both alphabets' characters in some versions, so it will not complain about a standard string containing &lt;code&gt;+&lt;/code&gt;. It is forgiving in the direction where forgiveness is harmless, and strict about padding in the direction where being wrong matters.&lt;/p&gt;

&lt;h1&gt;
  
  
  Why a decoder should report the alphabet it used
&lt;/h1&gt;

&lt;p&gt;If a string contains &lt;code&gt;-&lt;/code&gt; or &lt;code&gt;_&lt;/code&gt; and you decode it as standard Base64, you get an error at the exact character that is out of range — which at least tells you something is wrong. But some decoders quietly translate the characters for you, and that is where the worst failure mode lives: a string that is &lt;em&gt;actually standard&lt;/em&gt; Base64 containing a legitimate &lt;code&gt;+&lt;/code&gt; or &lt;code&gt;/&lt;/code&gt; gets mangled into the wrong bytes because the decoder assumed base64url.&lt;/p&gt;

&lt;p&gt;For a picture, that means an image that decodes without error and will not render. For a JWT segment, it means a signature that fails to verify with no explanation.&lt;/p&gt;

&lt;p&gt;That is why the decoder reports which alphabet it chose, and why a checkbox is offered to override it. Our &lt;a href="https://toolstead.net/url-safe-base64-to-image/" rel="noopener noreferrer"&gt;URL-safe Base64 to Image&lt;/a&gt; page exists for the case where you know the source used the URL-safe alphabet and want it treated that way from the start, without guessing. When the input contains &lt;code&gt;-&lt;/code&gt; or &lt;code&gt;_&lt;/code&gt; and the switch is off, the tool says so explicitly rather than silently picking one interpretation.&lt;/p&gt;

&lt;h1&gt;
  
  
  The practical rule
&lt;/h1&gt;

&lt;p&gt;Three questions, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Will this string travel through a URL, a filename, or a cookie?&lt;/strong&gt; If yes, produce base64url. It costs nothing and removes an entire class of escaping bugs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the string contain &lt;code&gt;+&lt;/code&gt; or &lt;code&gt;/&lt;/code&gt;?&lt;/strong&gt; Treat it as standard Base64. Those characters cannot appear in base64url.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does it contain &lt;code&gt;-&lt;/code&gt; or &lt;code&gt;_&lt;/code&gt;?&lt;/strong&gt; Treat it as base64url.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And one habit worth building: never re-encode a string you received before decoding it. Converting a standard string to URL-safe form and back is lossless, but doing it &lt;em&gt;speculatively&lt;/em&gt; — normalising before you know the alphabet — is how a &lt;code&gt;+&lt;/code&gt; becomes a space in a query string and then becomes a different byte after decoding. Decode first, from the form you received, then re-encode in whatever form the next consumer needs.&lt;/p&gt;

&lt;p&gt;The alphabet is only half of what makes a value safe to put in a URL. The other half is the characters around it, where an unescaped &lt;code&gt;&amp;amp;&lt;/code&gt; or &lt;code&gt;#&lt;/code&gt; splits the value with no error at all. &lt;a href="https://toolstead.net/blog/percent-encoding-reserved-characters/" rel="noopener noreferrer"&gt;Percent-encoding: the reserved characters that break URLs&lt;/a&gt; covers that half.&lt;/p&gt;

&lt;h1&gt;
  
  
  Where this shows up in practice
&lt;/h1&gt;

&lt;p&gt;JWTs are the most familiar case: three base64url segments separated by dots, no padding, no line breaks. Their compactness is the whole point, and standard Base64 would break every one of them at the &lt;code&gt;+&lt;/code&gt; and &lt;code&gt;/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Less obvious cases include opaque session cookies, signed URLs (AWS presigned URLs use standard Base64 in some parameters and URL-safe in others), correlation IDs in headers, and image payloads passed as query parameters in APIs that were never designed to carry binary data. In all of them, the failure looks the same: a string that "should decode" produces either an error at one character position, or bytes that decode cleanly and mean nothing.&lt;/p&gt;

&lt;p&gt;The second outcome is the one to watch for. If you decoded something and the result is not the file or the payload you expected, check the alphabet before you check anything else — see &lt;a href="https://toolstead.net/blog/base64-image-broken-fix/" rel="noopener noreferrer"&gt;Base64 image not showing&lt;/a&gt; for the full order of checks, which starts with exactly that question. If you would rather have the alphabet handled for you, the &lt;a href="https://toolstead.net/url-safe-base64-to-image/" rel="noopener noreferrer"&gt;URL-safe Base64 to Image&lt;/a&gt; page treats &lt;code&gt;-&lt;/code&gt; and &lt;code&gt;_&lt;/code&gt; as the expected alphabet from the start, and &lt;a href="https://toolstead.net/base64-to-image/" rel="noopener noreferrer"&gt;Base64 to Image&lt;/a&gt; auto-detects it while telling you which interpretation it used.&lt;/p&gt;

</description>
      <category>base64</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>security</category>
    </item>
    <item>
      <title>Why Card Number Formats Differ: Hex, Decimal, Byte Order</title>
      <dc:creator>zbinze</dc:creator>
      <pubDate>Wed, 28 Oct 2026 01:35:13 +0000</pubDate>
      <link>https://dev.to/toolstead/why-card-number-formats-differ-hex-decimal-byte-order-3fp5</link>
      <guid>https://dev.to/toolstead/why-card-number-formats-differ-hex-decimal-byte-order-3fp5</guid>
      <description>&lt;p&gt;A card holds a few bytes of opaque identifier. The number a reader shows you is a &lt;strong&gt;projection&lt;/strong&gt; of those bytes — a base and a byte order chosen by whoever wrote the firmware — and nothing more. That is why the same credential can read as &lt;code&gt;04A2243A&lt;/code&gt; on the installer's laptop, &lt;code&gt;04:A2:24:3A&lt;/code&gt; on the controller, &lt;code&gt;77734970&lt;/code&gt; in one database and &lt;code&gt;975479300&lt;/code&gt; in another. Four correct answers, zero agreement.&lt;/p&gt;

&lt;p&gt;Once the formats are seen as &lt;em&gt;bytes plus two decisions&lt;/em&gt; — which base, and which end of the byte string is byte zero — the conversions stop looking like magic and start looking like a checklist.&lt;/p&gt;

&lt;h1&gt;
  
  
  A card UID is bytes; the number is a projection
&lt;/h1&gt;

&lt;p&gt;125 kHz proximity cards and Mifare Classic carry a &lt;strong&gt;4-byte&lt;/strong&gt; UID. DESFire, Ultralight and many ISO 14443-4 cards carry &lt;strong&gt;7 bytes&lt;/strong&gt;; a handful carry 10. Those bytes are an identifier, not a quantity: there is no "true" decimal value for a UID, only the value some system computed from it.&lt;/p&gt;

&lt;p&gt;The length of the number is therefore evidence. A decimal number above &lt;code&gt;4,294,967,295&lt;/code&gt; (2^32 − 1) cannot have come from 4 bytes. A number above roughly &lt;code&gt;72,057,594,037,927,935&lt;/code&gt; (2^56 − 1) cannot have come from 7 bytes. Those two bounds usually narrow the card family down before anything else has been looked at — and they are the reason a "16-digit decimal card number" is a contradiction unless the producer is printing something other than a raw UID, such as a printed credential number from a separate database column.&lt;/p&gt;

&lt;h1&gt;
  
  
  The forms, and how they relate
&lt;/h1&gt;

&lt;p&gt;Take one card whose UID is &lt;code&gt;04 A2 24 3A&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bytes on the card      04 A2 24 3A
big-endian hex         04A2243A            ← the standard hex print
colon-grouped hex      04:A2:24:3A         ← the same bytes, readable
decimal (big-endian)   77,734,970          ← 04A2243A as a 32-bit integer
little-endian hex      3A24A204            ← the byte string reversed
reversed decimal       975,479,300         ← the reversal, as an integer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that list again, because it contains the whole problem: &lt;code&gt;77,734,970&lt;/code&gt; and &lt;code&gt;975,479,300&lt;/code&gt; are the &lt;em&gt;same card&lt;/em&gt;. A system given the wrong one will accept nothing at all, or worse, accept a number that belongs to a different credential.&lt;/p&gt;

&lt;p&gt;The two directions that appear in the wild are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Big-endian (network order).&lt;/strong&gt; The first byte is the most significant. This is what hex printing produces, what a &lt;code&gt;printf&lt;/code&gt; loop over bytes produces, and what most access-control software stores.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Little-endian, reversed.&lt;/strong&gt; The byte string is flipped before it becomes a number. This is what a large share of 125 kHz readers print, especially in Asia, which is why the number on the card holder's paperwork rarely matches the number in the controller.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mapping is a &lt;strong&gt;byte&lt;/strong&gt; reversal, not a digit reversal. Reversing &lt;code&gt;975,479,300&lt;/code&gt; as digits gives &lt;code&gt;003,974,579&lt;/code&gt;, which is not a card at all; reversing the bytes gives &lt;code&gt;3A24A204&lt;/code&gt;, and reversing that gives back &lt;code&gt;04A2243A&lt;/code&gt;. A converter that reverses decimal digits has misunderstood the format, and the numbers it produces look entirely plausible.&lt;/p&gt;

&lt;h1&gt;
  
  
  Where the conversion invents a number
&lt;/h1&gt;

&lt;p&gt;Three failure modes account for most "this card works on one door and not the other" reports.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Thousands separators.&lt;/strong&gt; Copy a column out of Excel and you get the &lt;em&gt;display text&lt;/em&gt;: &lt;code&gt;2,864,434,397&lt;/code&gt;, commas included. Split naively on commas and one card becomes two cells, &lt;code&gt;2&lt;/code&gt; and &lt;code&gt;864,434,397&lt;/code&gt; — and a converter that reports success has just produced two card numbers that do not exist. The correct reading is that &lt;code&gt;2,864,434,397&lt;/code&gt; is one value (it is &lt;code&gt;AABBCCDD&lt;/code&gt; in hex), and the correct behaviour for the ambiguous case is to refuse the input rather than guess. The judgement call is whether a comma is a thousands separator or a column delimiter; it needs a test, not a comment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Leading zeros.&lt;/strong&gt; A byte of &lt;code&gt;0x0A&lt;/code&gt; is &lt;code&gt;10&lt;/code&gt; in decimal and &lt;code&gt;0A&lt;/code&gt; in hex — the leading zero survives in hex and disappears in decimal. A 4-byte UID whose first byte is &lt;code&gt;0x00&lt;/code&gt; is legal, and it becomes indistinguishable from a 3-byte number the moment it is stored as an integer. Store UIDs as byte strings or as fixed-width hex, never as integers, or the first byte you lose will be the one that made the card unique.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Precision.&lt;/strong&gt; A 7-byte UID does not always fit in a JavaScript &lt;code&gt;Number&lt;/code&gt;. &lt;code&gt;Number.MAX_SAFE_INTEGER&lt;/code&gt; is &lt;code&gt;9,007,199,254,740,992&lt;/code&gt; (2^53), and any 7-byte UID whose first byte is &lt;code&gt;0x20&lt;/code&gt; or above exceeds it. NXP-style UIDs that begin with &lt;code&gt;0x04&lt;/code&gt; top out at &lt;code&gt;1,407,374,883,553,279&lt;/code&gt; and stay inside the safe range by luck — a property of that one leading byte, not something to rely on. Parse the hex digits with &lt;code&gt;BigInt&lt;/code&gt; when the field is 7 bytes wide:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;461B6C44F53981&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                       &lt;span class="c1"&gt;// 19733400197085570  ← wrong&lt;/span&gt;
&lt;span class="nc"&gt;BigInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;         &lt;span class="c1"&gt;// 19733400197085569  ← the card&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two values differ in the last digit, and the wrong one is not an error: it is a valid number that will enroll happily and never match the card again. That is the characteristic failure of this whole subject — a plausible wrong answer instead of a failure.&lt;/p&gt;

&lt;h1&gt;
  
  
  Parsing a column instead of a value
&lt;/h1&gt;

&lt;p&gt;Real work arrives as a column pasted out of a spreadsheet, not as one clean string, and the column is rarely uniform: some rows decimal as the reader printed them, some hex with colons, some reversed. Guessing a format per cell is how half a table ends up wrong while the tool reports no failures at all.&lt;/p&gt;

&lt;p&gt;What is needed is a converter that says which row it rejected and why. "Row 14: five colon-separated groups is not a 4-byte or 7-byte UID" is actionable; "invalid input" for 200 rows is not. The &lt;a href="https://toolstead.net/card-number-converter/" rel="noopener noreferrer"&gt;card number converter&lt;/a&gt; works that way by design: paste a column in any of the forms above and it shows every form of each value, refuses rows it cannot read unambiguously, and exports the table as CSV for whatever system needs it next. It reads the bytes and prints projections — it never rounds a projection back into bytes and calls the result the card.&lt;/p&gt;

&lt;h1&gt;
  
  
  When two systems disagree, check in this order
&lt;/h1&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Length.&lt;/strong&gt; 4 bytes, 7 bytes or 10? Half the hypotheses are eliminated by this alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Byte order.&lt;/strong&gt; Reverse the bytes and compare again. If the reversed value matches, the disagreement is found and it is not a hardware fault.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leading zeros.&lt;/strong&gt; Compare the hex forms rather than the decimal ones; if one side is a byte short, that is the difference.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Base.&lt;/strong&gt; &lt;code&gt;10000000&lt;/code&gt; could be decimal, or hex, or a number that happens to look round. Compare as fixed-width hex before concluding that two systems hold different cards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frame, not UID.&lt;/strong&gt; If the number came out of a Wiegand frame rather than being a raw UID, the split between facility code and card number is involved — see &lt;a href="https://toolstead.net/blog/wiegand-26-vs-34/" rel="noopener noreferrer"&gt;Wiegand 26 vs 34&lt;/a&gt;, where one credential legitimately becomes two different numbers.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;While debugging, keep hex and Base64 apart. Hex is one character per nibble and is a &lt;em&gt;representation&lt;/em&gt; of the bytes; Base64 is a different encoding with a four-for-three expansion, and a string that looks hexadecimal is not automatically hex — &lt;a href="https://toolstead.net/blog/base64-vs-hex-base32-ascii85/" rel="noopener noreferrer"&gt;the comparison of hex, Base32, Base64 and Ascii85&lt;/a&gt; covers how to tell them apart from the string itself.&lt;/p&gt;

&lt;p&gt;The habit that prevents most of this: normalise on the way in, keep the bytes, and print only the form the receiving system asked for. Every extra round trip through a decimal integer is another chance to reverse something that was already reversed.&lt;/p&gt;

</description>
      <category>hardware</category>
      <category>security</category>
      <category>programming</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Percent-Encoding: The Reserved Characters That Break URLs</title>
      <dc:creator>zbinze</dc:creator>
      <pubDate>Tue, 27 Oct 2026 01:43:42 +0000</pubDate>
      <link>https://dev.to/toolstead/percent-encoding-the-reserved-characters-that-break-urls-foi</link>
      <guid>https://dev.to/toolstead/percent-encoding-the-reserved-characters-that-break-urls-foi</guid>
      <description>&lt;p&gt;A URL is not a string with escapes in it. It is a small grammar with several interchangeable parts — scheme, authority, path, query, fragment — and a character's meaning depends on which part it appears in. Percent-encoding exists so that data can be carried inside those parts without being mistaken for the grammar itself. When that goes wrong, nothing reports an error: the value simply arrives shorter, split in two, or pointing somewhere the server never sees.&lt;/p&gt;

&lt;h1&gt;
  
  
  The two sets, and one detail about them
&lt;/h1&gt;

&lt;p&gt;RFC 3986 divides characters into two groups.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unreserved&lt;/strong&gt; — &lt;code&gt;A&lt;/code&gt;–&lt;code&gt;Z&lt;/code&gt;, &lt;code&gt;a&lt;/code&gt;–&lt;code&gt;z&lt;/code&gt;, &lt;code&gt;0&lt;/code&gt;–&lt;code&gt;9&lt;/code&gt;, &lt;code&gt;-&lt;/code&gt;, &lt;code&gt;.&lt;/code&gt;, &lt;code&gt;_&lt;/code&gt;, &lt;code&gt;~&lt;/code&gt;. These never need encoding, and encoding them anyway creates a second spelling of the same URL. That looks harmless until two spellings reach a cache, a signature calculation or a URL-based access rule, and are treated as two different resources.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reserved&lt;/strong&gt;, in two flavours:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;gen-delims&lt;/strong&gt; — &lt;code&gt;:&lt;/code&gt;, &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;?&lt;/code&gt;, &lt;code&gt;#&lt;/code&gt;, &lt;code&gt;[&lt;/code&gt;, &lt;code&gt;]&lt;/code&gt;, &lt;code&gt;@&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;sub-delims&lt;/strong&gt; — &lt;code&gt;!&lt;/code&gt;, &lt;code&gt;$&lt;/code&gt;, &lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;'&lt;/code&gt;, &lt;code&gt;(&lt;/code&gt;, &lt;code&gt;)&lt;/code&gt;, &lt;code&gt;*&lt;/code&gt;, &lt;code&gt;+&lt;/code&gt;, &lt;code&gt;,&lt;/code&gt;, &lt;code&gt;;&lt;/code&gt;, &lt;code&gt;=&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reserved does not mean "always encode". It means "this character has a job here". &lt;code&gt;:&lt;/code&gt; is a delimiter in the authority (&lt;code&gt;host:port&lt;/code&gt;) and perfectly ordinary data in a path segment. &lt;code&gt;&amp;amp;&lt;/code&gt; is a separator between query parameters and perfectly ordinary data inside a path. The rule is always relative to the component you are writing.&lt;/p&gt;

&lt;p&gt;One detail that trips parsers: percent-encoded triplets are &lt;strong&gt;case-insensitive&lt;/strong&gt; — &lt;code&gt;%2F&lt;/code&gt; and &lt;code&gt;%2f&lt;/code&gt; mean the same octet — but string equality in your code is not. Canonicalise to uppercase hex before comparing, hashing or signing, or two identical URLs will produce two different signatures.&lt;/p&gt;

&lt;h1&gt;
  
  
  The characters that cause incidents
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;&amp;amp;&lt;/code&gt; in a query value.&lt;/strong&gt; The value ends at the &lt;code&gt;&amp;amp;&lt;/code&gt;; everything after it becomes another parameter. Symptom: a long token arrives truncated at an arbitrary point, and an extra parameter appears in the request that no client intended to send.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;#&lt;/code&gt; anywhere in a query value.&lt;/strong&gt; Everything from the &lt;code&gt;#&lt;/code&gt; onward becomes the fragment, and a browser never sends the fragment to the server. So the server sees the value cut at that point, and the browser's own address bar shows the rest — the single most confusing version of this bug, because the URL looks complete to everyone who inspects it locally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;+&lt;/code&gt; in a query value.&lt;/strong&gt; Under &lt;code&gt;application/x-www-form-urlencoded&lt;/code&gt; rules it means a space, so &lt;code&gt;C++&lt;/code&gt; becomes &lt;code&gt;C&lt;/code&gt;. In a path it is a literal plus. Send a literal plus as &lt;code&gt;%2B&lt;/code&gt; if you want it to survive being handled by two different libraries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;%&lt;/code&gt; on its own.&lt;/strong&gt; A literal percent sign must be &lt;code&gt;%25&lt;/code&gt;. A single &lt;code&gt;%&lt;/code&gt; followed by something that is not two hex digits makes the URL invalid, and strict parsers reject the whole thing rather than the character — which is why an input like &lt;code&gt;100%&lt;/code&gt; or &lt;code&gt;%AB&lt;/code&gt; in a template can break an otherwise fine request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Space.&lt;/strong&gt; &lt;code&gt;%20&lt;/code&gt; in a path and in a standards-compliant query; &lt;code&gt;+&lt;/code&gt; in form-encoded data. Both are widely accepted on the way in, and that mutual tolerance is precisely why the bug survives so long: it works with the parser you tested and fails with the one in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/&lt;/code&gt; inside a path segment value.&lt;/strong&gt; Encode it as &lt;code&gt;%2F&lt;/code&gt; and the value no longer looks like two segments — but many servers decode the path &lt;em&gt;before&lt;/em&gt; routing, so &lt;code&gt;%2F&lt;/code&gt; is turned back into a separator and your value splits anyway. Some servers reject encoded slashes outright, and the reason is security: &lt;code&gt;%2F&lt;/code&gt; and &lt;code&gt;%2E&lt;/code&gt; are the building blocks of path traversal attempts, so proxies and application servers have strong opinions about them. The honest conclusion is that a value containing a slash does not belong in a single path segment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;?&lt;/code&gt; in a query value.&lt;/strong&gt; Legal in a query by the grammar, and best encoded anyway, since a second &lt;code&gt;?&lt;/code&gt; invites a second round of parsing in intermediaries.&lt;/p&gt;

&lt;h1&gt;
  
  
  Non-ASCII is bytes, not characters
&lt;/h1&gt;

&lt;p&gt;This is where percent-encoding stops looking like an escaping scheme and starts looking like an encoding.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;%XX&lt;/code&gt; triplet carries &lt;strong&gt;one byte&lt;/strong&gt;, not one character. A character outside ASCII has to be turned into bytes first, and the only interoperable choice is UTF-8:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;é&lt;/code&gt; is U+00E9, two bytes in UTF-8 (&lt;code&gt;C3 A9&lt;/code&gt;), so it encodes to &lt;code&gt;%C3%A9&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A CJK character such as U+4E2D is three bytes (&lt;code&gt;E4 B8 AD&lt;/code&gt;), so it encodes to &lt;code&gt;%E4%B8%AD&lt;/code&gt; — nine characters to carry one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the producer used a different legacy encoding, the triplets are still perfectly well-formed and still decode to something — just not to the text that was sent. A GBK-encoded Chinese string arriving at a UTF-8 decoder produces a page of replacement characters, and nothing in the request looks wrong. So "the encoding is valid" and "the characters are correct" are separate questions, and the second one needs the producer's encoding to be known.&lt;/p&gt;

&lt;p&gt;The related rule for implementers: always percent-encode &lt;strong&gt;bytes&lt;/strong&gt;, never code points and never characters. In languages where a string is a sequence of UTF-16 code units, encoding a character directly is how astral-plane characters get written as surrogate halves, which no decoder will accept.&lt;/p&gt;

&lt;h1&gt;
  
  
  A decoder should refuse, not guess
&lt;/h1&gt;

&lt;p&gt;Decoding is where the damage becomes permanent, because a decoder that is generous with invalid input turns a visible bug into a plausible value.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An &lt;strong&gt;incomplete triplet&lt;/strong&gt; at the end of the input (&lt;code&gt;%E4%B8&lt;/code&gt;) is invalid. Truncating it silently produces text that is almost right.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;invalid UTF-8 byte sequence&lt;/strong&gt; must be an error. Substituting U+FFFD without saying so means the caller never learns that the sender used the wrong encoding — and a value with a replacement character in it can be stored, indexed and passed along for years.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over-long encodings&lt;/strong&gt; — the historical trick of writing &lt;code&gt;/&lt;/code&gt; as &lt;code&gt;%C0%AF&lt;/code&gt; — must be rejected rather than decoded to the shorter form, since that is precisely what makes them dangerous in a security context.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;%&lt;/code&gt; that is not followed by two hex digits is a malformed URL. Reporting it as malformed is more useful than treating &lt;code&gt;%&lt;/code&gt; as literal, because the caller then finds the template that forgot to escape.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A decoder that does the opposite — normalising, repairing and reporting success — is the reason two systems can disagree about the same URL while both claim to have decoded it. The strict-but-explicit behaviour is what the &lt;a href="https://toolstead.net/url-encoder/" rel="noopener noreferrer"&gt;URL encoder and decoder&lt;/a&gt; implements: it shows which characters it changed, keeps UTF-8 byte handling separate from the text, and points at the exact position of a malformed sequence instead of quietly dropping it.&lt;/p&gt;

&lt;h1&gt;
  
  
  The rules worth keeping
&lt;/h1&gt;

&lt;ol&gt;
&lt;li&gt;Encode per component, not per URL. &lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;=&lt;/code&gt;, &lt;code&gt;#&lt;/code&gt; and &lt;code&gt;+&lt;/code&gt; are data in one part and syntax in another.&lt;/li&gt;
&lt;li&gt;Never decode twice. &lt;code&gt;%2520&lt;/code&gt; is a percent sign followed by &lt;code&gt;20&lt;/code&gt;, not a space; if a second decode "fixes" it, the producer double-encoded, and that is their bug to fix.&lt;/li&gt;
&lt;li&gt;Compare canonical forms: uppercase hex triplets, no needlessly encoded unreserved characters, one spelling per resource.&lt;/li&gt;
&lt;li&gt;Treat non-ASCII as UTF-8 bytes and say so explicitly; where the sender's encoding is unknown, ask rather than assume.&lt;/li&gt;
&lt;li&gt;Prefer refusing malformed input to repairing it — this is the difference between a decoder and a data corruption device.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;a href="https://toolstead.net/blog/url-encoding-vs-base64/" rel="noopener noreferrer"&gt;comparison with Base64&lt;/a&gt; covers which of the two layers a given payload needs; when the payload is a token that must survive a query string untouched, the &lt;a href="https://toolstead.net/blog/url-safe-base64-vs-standard/" rel="noopener noreferrer"&gt;URL-safe alphabet&lt;/a&gt; is usually the better starting point than escaping your way out of an alphabet that already contains &lt;code&gt;+&lt;/code&gt; and &lt;code&gt;/&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>http</category>
      <category>javascript</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Wiegand 26 vs 34 Bit Format: The Facility Code Split</title>
      <dc:creator>zbinze</dc:creator>
      <pubDate>Mon, 26 Oct 2026 01:56:48 +0000</pubDate>
      <link>https://dev.to/toolstead/wiegand-26-vs-34-bit-format-the-facility-code-split-l02</link>
      <guid>https://dev.to/toolstead/wiegand-26-vs-34-bit-format-the-facility-code-split-l02</guid>
      <description>&lt;p&gt;Wiegand is a wire, not a card format. A reader sends bits to a controller over two lines — DATA0 pulses for zeros, DATA1 pulses for ones, roughly 100 microseconds each with about a millisecond between them — and the controller reconstructs a number from the pulse count. No clock line, no packet length, no checksum. The "26" and "34" in Wiegand 26 and Wiegand 34 are simply the number of bits in that burst.&lt;/p&gt;

&lt;p&gt;The part that causes trouble is what happens &lt;em&gt;inside&lt;/em&gt; those bits. A frame carries a facility code, a card number, and two parity bits, and where the boundary between the two fields sits is a &lt;strong&gt;vendor convention&lt;/strong&gt;, not something the frame declares. Two systems can read the same card, over the same wire, and disagree about who it is — with no error anywhere.&lt;/p&gt;

&lt;h1&gt;
  
  
  The frame, bit by bit
&lt;/h1&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Wiegand 26   [P][ facility 8 bits ][ card number 16 bits ][P]
Wiegand 34   [P][ facility 16 bits][ card number 16 bits ][P]

             P = parity, one bit on each end of the frame
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 26-bit layout, used by HID H10301 and most 125 kHz proximity installations, is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;bit 1 — &lt;strong&gt;even parity&lt;/strong&gt; over bits 2–13&lt;/li&gt;
&lt;li&gt;bits 2–9 — &lt;strong&gt;facility code&lt;/strong&gt;, 8 bits, &lt;code&gt;0&lt;/code&gt;–&lt;code&gt;255&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;bits 10–25 — &lt;strong&gt;card number&lt;/strong&gt;, 16 bits, &lt;code&gt;0&lt;/code&gt;–&lt;code&gt;65,535&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;bit 26 — &lt;strong&gt;odd parity&lt;/strong&gt; over bits 14–25&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 34-bit layout — HID H10306 and the AWID 34-bit credentials that share its shape — moves the boundary a long way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;bit 1 — &lt;strong&gt;even parity&lt;/strong&gt; over bits 2–17&lt;/li&gt;
&lt;li&gt;bits 2–17 — &lt;strong&gt;facility code&lt;/strong&gt;, 16 bits, &lt;code&gt;0&lt;/code&gt;–&lt;code&gt;65,535&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;bits 18–33 — &lt;strong&gt;card number&lt;/strong&gt;, 16 bits, &lt;code&gt;0&lt;/code&gt;–&lt;code&gt;65,535&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;bit 34 — &lt;strong&gt;odd parity&lt;/strong&gt; over bits 18–33&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the card number is the same size in both. Everything the extra eight bits buy goes into the &lt;em&gt;site&lt;/em&gt; field: 34-bit raises the number of distinct facility codes from 256 to 65,536. That is the whole difference, and it is the reason a site with more than 256 doors, tenants or buildings eventually migrates.&lt;/p&gt;

&lt;h1&gt;
  
  
  Why the boundary cannot be guessed
&lt;/h1&gt;

&lt;p&gt;The frame length is agreed; the division is not. The same total can be split differently by different product families — 36-bit is a 17/17 site-and-card split in one, and an 8-bit site code with a 16-bit card number at entirely different bit offsets in another — and 34-bit credentials exist that use the same 16/16 shape but treat the site field as an issuer code rather than a facility code. Nothing in the transmission records which convention was used, which has a blunt consequence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A controller configured for the wrong layout does not fail. It produces a different, valid number.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Feeding a 34-bit credential to a controller configured for 26 bits is the perfect example. The parser takes the first 26 bits of the burst: bit 1 becomes its even-parity bit, bits 2–9 become an 8-bit facility code that is really the top half of a 16-bit one, and bits 10–25 become a card number that is really the bottom half of the facility code followed by the top half of the real card number. The last eight bits of the frame, including the frame's own odd-parity bit, arrive after the parser has stopped reading. Almost always the parity check fails, the reader looks broken, and the card is perfectly fine.&lt;/p&gt;

&lt;p&gt;The practical version of this, which turns up far more often than a hard failure, is a &lt;em&gt;silent&lt;/em&gt; mismatch: a split that parses, passes parity and identifies a different person. With only 16 bits of card number, collisions are not theoretical. A site that has enrolled more than 65,535 credentials, or that reuses numbers across facility codes the panel can no longer see, ends up with two cards that are the same person to the controller — and the log will show a legitimate number for the wrong door.&lt;/p&gt;

&lt;h1&gt;
  
  
  Parity is the entire error-detection budget
&lt;/h1&gt;

&lt;p&gt;Two parity bits over a 32-bit payload is a thin defence, and it is worth knowing exactly how thin.&lt;/p&gt;

&lt;p&gt;Even parity over bits 2–13 means the twelve data bits plus the parity bit contain an even number of ones. If one pulse is dropped — a barrier motor starting up next to an unshielded run of cable is the classic cause — the bit count changes and the parity check fails. If &lt;strong&gt;two&lt;/strong&gt; pulses are lost inside the same parity window, the count is even again and the frame passes. Wiegand has no way to notice, because there is nothing else in the frame to cross-check against: no length field, no sequence number, no CRC.&lt;/p&gt;

&lt;p&gt;This is also why parity is computed over halves of the frame rather than the whole payload. Two 12-bit windows catch error patterns that one 26-bit window would not, at the cost of the two bits themselves. It is a 1950s answer to a 1950s problem, and it survives because the alternative — a bidirectional protocol — requires wires that the installed base does not have.&lt;/p&gt;

&lt;h1&gt;
  
  
  Reading a number out of a frame
&lt;/h1&gt;

&lt;p&gt;Once a controller has the frame, the number it stores is the card number field, usually printed as decimal. That is where card-number formats start to blur: the same credential may be printed by one tool as a 26-bit decimal card number, by another as the raw hex of the whole frame — hex being a 1:1 representation of the bytes rather than an encoding, as &lt;a href="https://toolstead.net/blog/base64-vs-hex-base32-ascii85/" rel="noopener noreferrer"&gt;the comparison of hex, Base32, Base64 and Ascii85&lt;/a&gt; sets out — and by a third as a UID in reversed byte order. If the numbers you have in hand do not line up, the field boundary and the byte order are separate questions — &lt;a href="https://toolstead.net/blog/card-number-formats-explained/" rel="noopener noreferrer"&gt;Card Number Formats Explained&lt;/a&gt; walks the order to check them in, and the &lt;a href="https://toolstead.net/card-number-converter/" rel="noopener noreferrer"&gt;card number converter&lt;/a&gt; will show every form of a value you paste in, including the reversed reading that explains most "the system has the wrong number" tickets.&lt;/p&gt;

&lt;p&gt;One useful sanity check before any of that: a 26-bit card number cannot exceed &lt;code&gt;65,535&lt;/code&gt;. If the number in your database is larger, it did not come from a 26-bit card number field — it came from a raw UID, a 34-bit-plus layout, or a mis-split frame.&lt;/p&gt;

&lt;p&gt;When you want the frames computed rather than explained, the &lt;a href="https://toolstead.net/wiegand-26-34-converter/" rel="noopener noreferrer"&gt;Wiegand 26/34 converter&lt;/a&gt; takes a whole frame, a hex payload, a facility-code pair or a bare card number and shows both widths side by side — including the parity check that tells you whether a captured frame survived the wiring.&lt;/p&gt;

&lt;h1&gt;
  
  
  You cannot convert 26 to 34
&lt;/h1&gt;

&lt;p&gt;There is no arithmetic that turns a 26-bit credential into a 34-bit one, because nothing is being computed — a field boundary is being moved. Two honest options exist:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reconfigure the panel&lt;/strong&gt; to the format the credentials already use. This is a controller setting, and it is the cheap path when the reader, the cards and the panel all support the layout you are switching to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-enroll&lt;/strong&gt; the credentials into the new split, which means re-issuing or re-reading every card and rebuilding the access lists.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What does not work is "converting" the numbers: mapping a 900-series card number into a different facility code produces a credential that no card in the building carries. If a migration is on the table, the audit worth doing first is a count of distinct facility codes and the highest card number in use — those two numbers decide whether 34-bit is needed or merely convenient.&lt;/p&gt;

&lt;h1&gt;
  
  
  The security footnote that outlives the format
&lt;/h1&gt;

&lt;p&gt;Wiegand is one-directional and unauthenticated. The reader talks; the controller listens; nothing is encrypted, and a facility code is printed on the card and known to anyone who has ever ordered credentials. 125 kHz proximity cards are also trivially clonable with commodity hardware, and because card numbers are usually sequential, an attacker who captures one frame has a good guess at its neighbours.&lt;/p&gt;

&lt;p&gt;None of that is fixed by moving to 34 bits — it is fixed by replacing the protocol, which is what OSDP does: bidirectional, with a secure channel and encryption, so a reader can be authenticated and a cloned card alone is not enough. The reason 26-bit is still everywhere is not that it is good; it is that the wire, the readers and the doors are already installed, and the format travels with them.&lt;/p&gt;

&lt;p&gt;If you are debugging a credential that reads differently on two systems, start with the bit split and the byte order — in that order. The frames are not lying; they are answering a slightly different question than the one being asked.&lt;/p&gt;

</description>
      <category>security</category>
      <category>hardware</category>
      <category>programming</category>
      <category>embedded</category>
    </item>
    <item>
      <title>Base64 Image Not Showing? Six Checks, Cheapest First</title>
      <dc:creator>zbinze</dc:creator>
      <pubDate>Sun, 25 Oct 2026 00:09:01 +0000</pubDate>
      <link>https://dev.to/toolstead/base64-image-not-showing-six-checks-cheapest-first-2be1</link>
      <guid>https://dev.to/toolstead/base64-image-not-showing-six-checks-cheapest-first-2be1</guid>
      <description>&lt;p&gt;A Base64 image that will not display is one of the least informative failures in web development. You get a broken-image icon, or an empty box, or nothing at all — and the browser console is silent. There is no exception to catch, because the browser did exactly what it was told: it tried to render a resource and gave up quietly.&lt;/p&gt;

&lt;p&gt;The good news is that there are only six realistic causes, they are cheap to check in order, and each one produces a different symptom if you look closely.&lt;/p&gt;

&lt;h1&gt;
  
  
  Check 1: is the string actually Base64?
&lt;/h1&gt;

&lt;p&gt;Before anything else, establish that the payload is in the right alphabet. Two count-based checks catch most of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;length % 4 =&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                        &lt;span class="c1"&gt;// 1 means truncated&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;illegal =&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9+&lt;/span&gt;&lt;span class="se"&gt;/&lt;/span&gt;&lt;span class="sr"&gt;=_-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A remainder of 1 is fatal: no complete Base64 string can produce it, so characters were lost in transit. Everything else can usually be repaired. Illegal characters tell you &lt;em&gt;what&lt;/em&gt; went wrong — a newline means MIME wrapping (see &lt;a href="https://toolstead.net/blog/base64-line-breaks-padding/" rel="noopener noreferrer"&gt;line breaks and padding&lt;/a&gt;), a &lt;code&gt;%3D&lt;/code&gt; means the string went through a URL, and a &lt;code&gt;-&lt;/code&gt; or &lt;code&gt;_&lt;/code&gt; means it is URL-safe Base64 (see &lt;a href="https://toolstead.net/blog/url-safe-base64-vs-standard/" rel="noopener noreferrer"&gt;URL-safe vs standard&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;If the string is full of &lt;code&gt;0-9a-f&lt;/code&gt; and nothing else, it is hexadecimal. Decoding it as Base64 will produce bytes without error, and those bytes will not be an image.&lt;/p&gt;

&lt;h1&gt;
  
  
  Check 2: is the prefix complete?
&lt;/h1&gt;

&lt;p&gt;The syntax is &lt;code&gt;data:[&amp;lt;mediatype&amp;gt;][;base64],&amp;lt;data&amp;gt;&lt;/code&gt; and all three parts are load-bearing. Three prefix bugs produce three different symptoms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;;base64&lt;/code&gt; flag.&lt;/strong&gt; &lt;code&gt;data:image/png,iVBORw0KGgo...&lt;/code&gt; makes the browser read the payload as literal text. You get an empty image box and no error, because you asked for a text file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No comma.&lt;/strong&gt; &lt;code&gt;data:image/png;base64&lt;/code&gt; followed by the payload is not a data URI at all. If your code strips "everything up to the first comma", the strip silently does nothing and the decoder chokes on the &lt;code&gt;:&lt;/code&gt; in the header.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;;base64&lt;/code&gt; typo&lt;/strong&gt; such as &lt;code&gt;base64;&lt;/code&gt; or &lt;code&gt;;base-64&lt;/code&gt;. Same as the first case: the payload is treated as text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In a strict decoder these all fail loudly at a specific character position. In a browser they fail silently. That asymmetry is worth remembering when debugging: the browser is the least helpful place to ask "why".&lt;/p&gt;

&lt;h1&gt;
  
  
  Check 3: did it decode to any bytes at all?
&lt;/h1&gt;

&lt;p&gt;A zero-byte result is a real outcome, and it has a specific cause: the payload contained nothing but padding characters, or the normalisation step removed everything it considered junk. Print the decoded length before you attempt to render:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// 0 → nothing to render, however good the string looks&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the length is in the hundreds of kilobytes, you have data. Whether it is an image is Check 4.&lt;/p&gt;

&lt;h1&gt;
  
  
  Check 4: are the bytes an image — and the right one?
&lt;/h1&gt;

&lt;p&gt;This is where the declared media type gets tested against reality. Read the first bytes and compare:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;89 50        PNG
FF D8 FF     JPEG
47 49 46 38  GIF
52 49 46 46  WebP (with "WEBP" at offset 8)
1F 8B        gzip — not an image
25 50 44 46  PDF
50 4B 03 04  ZIP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two distinct failures live here. The first is that the bytes are &lt;strong&gt;not an image at all&lt;/strong&gt; — gzipped JSON that was Base64-encoded to move it through a text-only channel is the classic case, and it is worth naming explicitly because "it decoded fine" makes people stop looking. The second is that the bytes &lt;strong&gt;are&lt;/strong&gt; an image but a different one from what the header claims: &lt;code&gt;data:image/jpeg;base64,iVBORw0KGgo...&lt;/code&gt; renders in most browsers, then fails in every tool that trusts the declared type. The bytes win in both cases, which is why our &lt;a href="https://toolstead.net/base64-to-image/" rel="noopener noreferrer"&gt;Base64 to Image&lt;/a&gt; tool prints the declared type and the signature side by side instead of picking one.&lt;/p&gt;

&lt;h1&gt;
  
  
  Check 5: is the image complete?
&lt;/h1&gt;

&lt;p&gt;This is the cause people miss, and it produces the most confusing symptom: the image &lt;strong&gt;does&lt;/strong&gt; display, then stops halfway. A grey band across the bottom of a photo, or a picture that renders correctly in a browser but is rejected by an image editor.&lt;/p&gt;

&lt;p&gt;The reason is that JPEG has no total-length field in its header. A JPEG truncated at any point still looks like a perfectly valid JPEG as long as the markers parsed so far were legal, so decoders render what they have and stop. Truncation is common because Base64 travels through places that truncate: log lines with length caps, terminal selection that drops a wrapped line, database columns sized for the average case, chat messages with attachment limits.&lt;/p&gt;

&lt;p&gt;The test is arithmetic, not visual:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Decode the string and record the byte count.&lt;/li&gt;
&lt;li&gt;Compare it with the size the source reports for the original file.&lt;/li&gt;
&lt;li&gt;If the decoded count is lower, you have a truncated copy — go back to the source rather than trying to repair it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For PNG the same check works, and there is an extra structural signal: a complete PNG ends with an &lt;code&gt;IEND&lt;/code&gt; chunk. Reading the last twelve bytes and looking for &lt;code&gt;49 45 4E 44&lt;/code&gt; (&lt;code&gt;IEND&lt;/code&gt;) is a cheap completeness test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tail&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;IEND&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  Check 6: is the browser refusing it for a reason unrelated to Base64?
&lt;/h1&gt;

&lt;p&gt;The last two causes have nothing to do with the string, which is exactly why they are missed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Content Security Policy.&lt;/strong&gt; If your page sends &lt;code&gt;img-src 'self'&lt;/code&gt; without &lt;code&gt;data:&lt;/code&gt;, every data URI image is blocked — silently, and with no Base64 problem anywhere. The browser reports the violation to the console in most cases, but only if you know to look there, and tools that inject their own CSP by default (static hosts, reverse proxies) can add this without you noticing. Our own pages ship &lt;code&gt;img-src 'self' blob: data:&lt;/code&gt; precisely for this reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Size.&lt;/strong&gt; Data URIs are string data and live inside whatever document contains them. Very large ones — a few megabytes of Base64 in an inline &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; or a generated HTML file — hit parser and memory limits that vary by browser, and the failure looks like nothing at all happening. If a 40 KB icon works and a 4 MB photo does not, this is your answer, and the fix is a real URL instead.&lt;/p&gt;

&lt;h1&gt;
  
  
  The order matters
&lt;/h1&gt;

&lt;p&gt;Run the checks in this order and stop at the first failure, because the later ones produce misleading results when the earlier ones are broken: a truncated string appears to "decode fine" and a wrong alphabet produces bytes that look like a corrupt image. Six checks, roughly a minute of work, and each one tells you a different thing to fix.&lt;/p&gt;

&lt;p&gt;When you have the answer, the two repairs worth knowing are both structural rather than textual: pad the string if the remainder says it is short (safe, reversible), and go back to the source if the remainder is 1 or the decoded length does not match what the producer reported (not safe, not reversible, do not guess). Anything else — re-encoding, "trying" a different alphabet, appending bytes until it renders — is guessing at data you cannot see.&lt;/p&gt;

</description>
      <category>base64</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>debugging</category>
    </item>
  </channel>
</rss>
