Do you have to deal with Base64 format? Then this site is perfect for you! Use our super handy online tool to encode or decode your data.

Base64 Decoding in C++ (Cpp): A Complete Guide

You got the string. A long ribbon of letters, digits, the occasional + or /, maybe an = or two at the end, and somewhere in your ticket, contract or database column, the promise that it is Base64. Now you need the original bytes back, in C++, and you need them right. The home page of this site walks through the format in depth, so here is only the short version: four alphabet characters carry three bytes, a one or two character = tail marks where the real data stopped, and the encoded form runs about 33 percent larger than the original. Decoding is the shrinking direction, so a decoder can never need more memory than the payload it already holds. That is a genuinely pleasant property, and one of the quiet joys of working in this direction.

The bigger headline is that C++ itself will not decode a single character for you. The standard library has had thirty years to grow a base64 function and has spent them all on other things, so every C++ program brings its own decoder from a bench of three very different personalities, plus the option of writing about forty lines of your own. One is a workhorse that has carried the internet since the 1990s, one is a silent type that stops mid-sentence and never says a word about it, and one is a stickler who throws an exception at a single stray space. Once you know what each one forgives, what it refuses, and what it quietly does behind your back, decoding stops being a source of mystery bugs. Let us open some packages.

The Toolbox: Four Decoders, Four Temperaments

Here is the landscape at a glance. All four handle the standard alphabet; the differences are in the edges, and the edges are where bugs live.

Decoder Where it comes from Error model Quirk to remember
OpenSSL EVP <openssl/evp.h>, link -lcrypto Returns -1 on broken input The one-shot version zero-fills the tail
Boost.Beast <boost/beast/core/detail/base64.hpp>, header-only None: it just stops No error channel of any kind
Boost.Serialization iterators <boost/archive/iterators/binary_from_base64.hpp>, header-only Throws dataflow_exception Treats = as a real zero value
Your own forty lines Nowhere: it is yours Your choice, down to the byte position You own every edge case forever

Installation is one package name per distro. For OpenSSL: libssl-dev on Debian and Ubuntu, openssl-devel on Fedora and RHEL, openssl on Arch, and brew install openssl on macOS. For Boost, whose current release is 1.92.0 from August 2026 in a project that has been building libraries since 1998: libboost-dev or boost-devel. Both Boost decoders below are header-only, so there is nothing to link at all. If your project is CMake-based, the whole setup is three lines:

find_package(OpenSSL REQUIRED)
find_package(Boost REQUIRED)
target_link_libraries(my_app PRIVATE OpenSSL::Crypto)

One version note before the code, because it changes what your decoder returns. OpenSSL 3.5, released in April 2025 as a long-term-support line, fixed a real bug in the streaming decoder (more in a minute), and the newer 4.0 feature release from April 2026 inherited the fix. If your build pins an old 3.0 or 3.3, read the version paragraph in the OpenSSL section below before you trust a tail length.

OpenSSL: The Decoder You Probably Already Link

If your C++ program touches TLS, hashing, or certificates, OpenSSL is already in the binary, and its EVP base64 routines are the most battle-tested decoders in the business. The one-shot function is a single call:

int EVP_DecodeBlock(unsigned char *t, const unsigned char *f, int n);

Hand it a buffer of base64 characters and its length, and it writes the decoded bytes to t. It trims leading whitespace, trims trailing whitespace, newlines and carriage returns, and then it applies rules with no compromise: no internal whitespace, and the trimmed length must be a multiple of 4. Every four input characters produce exactly three output bytes, and here is the part that surprises people: padding characters are decoded to six zero bits, and the man page calmly notes that the caller is responsible for taking trailing padding into account. In other words, the function is doing the arithmetic for you and then quietly adding two bonus zero bytes at the end. The idiomatic C++ wrapper hides the buffer math behind a std::string:

#include <cstddef>
#include <cstdio>
#include <string>
#include <vector>
#include <openssl/evp.h>

std::string openssl_decode(const std::string &b64) {
  std::vector<unsigned char> out(b64.size() * 3 / 4 + 4);
  int n = EVP_DecodeBlock(out.data(),
                          reinterpret_cast<const unsigned char *>(b64.data()),
                          static_cast<int>(b64.size()));
  if (n < 0) return {};
  size_t pads = 0;
  for (size_t i = b64.size(); i > 0 && b64[i - 1] == '='; --i) ++pads;
  return std::string(reinterpret_cast<const char *>(out.data()),
                     static_cast<size_t>(n) - pads);
}

int main() {
  std::string one = openssl_decode("TQ==");
  std::printf("%zu bytes: %02x\n", one.size(),
              static_cast<unsigned char>(one[0]));
  std::string word = openssl_decode("TWFuZQ==");
  std::printf("%zu bytes: %s\n", word.size(), word.c_str());
}

Run it and the zero-fill shows up exactly where the documentation promised: decoding the four-character string TQ== yields three bytes, the letter M plus two zeros, before the wrapper trims them away. Feed it TWFuZQ== and you get the clean four bytes of "Mane". Feed it a character outside the alphabet and you get back an empty string, because the function answered with -1. Notice what std::string is doing quietly in this wrapper: it tracks its own length and happily contains zero bytes, so a decoded JPEG can live in your text type and be compared, hashed and passed around. In C you would be counting your blessings for a length variable; here the string just works.

For data that arrives in pieces, OpenSSL hands you a context you feed chunks into and pull results out of, and the three functions have a compact return-value vocabulary:

Call Returns What it means
EVP_DecodeUpdate -1 Invalid character, or a pad sign in the middle of the data
EVP_DecodeUpdate 1 More input is expected
EVP_DecodeUpdate 0 End of data: the last group carried padding, or the soft end-of-input marker appeared
EVP_DecodeFinal 1 / -1 Stream ended cleanly / residual characters were not a multiple of 4

Two behaviors make the streaming decoder the forgiving ear in the toolbox. It skips spaces, tabs, carriage returns and newlines anywhere in the stream, so a MIME email block with 76-character CRLF lines flows through exactly like a one-line string, and it reports the true byte count: the same TQ== that fooled the one-shot function gives you exactly one byte here, no arithmetic required. It chews input in chunks of up to 80 base64 characters and buffers whatever does not fit a group of four, which is why you can feed it in arbitrary piece sizes. The wrapper:

#include <algorithm>
#include <cstdio>
#include <string>
#include <vector>
#include <openssl/evp.h>

std::string openssl_decode_stream(const std::string &b64) {
  EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
  EVP_DecodeInit(ctx);
  std::string out;
  std::vector<unsigned char> chunk(1024);
  int outl = 0;
  for (size_t pos = 0; pos < b64.size(); pos += chunk.size()) {
    size_t take = std::min(chunk.size(), b64.size() - pos);
    int ret = EVP_DecodeUpdate(ctx, chunk.data(), &outl,
                               reinterpret_cast<const unsigned char *>(b64.data()) + pos,
                               static_cast<int>(take));
    if (ret < 0) {
      EVP_ENCODE_CTX_free(ctx);
      return {};
    }
    out.append(reinterpret_cast<const char *>(chunk.data()), outl);
    if (ret == 0) break;
  }
  unsigned char tail[3];
  int tail_l = 0;
  int fin = EVP_DecodeFinal(ctx, tail, &tail_l);
  EVP_ENCODE_CTX_free(ctx);
  if (fin != 1) return {};
  out.append(reinterpret_cast<const char *>(tail), tail_l);
  return out;
}

And now the version footnote from the toolbox table, because it is exactly the kind of thing that turns a "solved" ticket back open: on every OpenSSL release before 3.5, the streaming path had the same zero-fill habit as the block decoder. It was reported in February 2025 as issue 26677, fixed by a pull request merged on February 27, 2025, and the official man page now records it in its history section: from OpenSSL 3.5 onward, EVP_DecodeUpdate produces the number of bytes the documentation always claimed and no longer decodes padding to zero bits. If your codebase pins an old OpenSSL and your tail lengths look one or two bytes long, this is the first thing to check. And there is one eccentricity inherited from the PEM era: the hyphen - is not an alphabet character at all, it is a soft end-of-input marker. If your stream contains one after a multiple of 4 valid characters, the decoder returns 0 and asks you to stop, which is why a base64url string that genuinely needs its - characters will not simply decode - transcode first, in the section below, and the time traveler disappears.

Boost.Beast: The Fast Decoder That Never Complains

If Boost is already in the project, its HTTP library ships a base64 codec at the unlikely address boost/beast/core/detail/base64.hpp. The detail:: namespace is Boost's way of saying "this is our internal business", and the maintainers have declined to promote it to a public API. Everyone uses it anyway, because it is small, fast, and header-only: define BOOST_BEAST_HEADER_ONLY before the include and there is nothing to link. It is also, incidentally, the codec Boost's own HTTP client uses for Basic auth headers, so it has been chewing real traffic for years.

The decoding function's personality is the surprise. It takes your output buffer, the input, and its length, and returns a pair: the number of octets written and the number of characters read. It stops at the first =, and it also stops at the first invalid character - and in both cases it does so without telling you. There is no error code, no exception, no status flag. A corrupted payload, a line-wrapped payload, and a truncated tail all produce a successful partial result:

decode("TWFuZQ==")  -> 4 bytes "Mane", 8 characters read
decode("TWF!ZQ==")  -> 3 bytes "Man",  4 characters read  (stopped at '!')
decode("TWFu\nZQ==") -> 3 bytes "Man",  4 characters read  (stopped at '\n')
decode("TWFuZQ")    -> 4 bytes "Mane", 6 characters read  (tail was truncated)
decode("TQ==")      -> 1 byte 'M',      2 characters read  (padding handled fine)

Read that list a second time, because it is the entire threat model of a decoder that never complains: it did its best, it stopped where it stopped, and it is up to you to notice. The check has one wrinkle: for padded input the "characters read" count stops at the first =, so you add the pads back before comparing, and you also require the total to be a multiple of four, because that is the only shape a real payload has:

#define BOOST_BEAST_HEADER_ONLY
#include <boost/beast/core/detail/base64.hpp>
#include <cstddef>
#include <string>

namespace b64 = boost::beast::detail::base64;

std::string beast_decode(const std::string &in) {
  std::string out;
  out.resize(in.size() / 4 * 3 + 3); /* slack for odd lengths */
  auto result = b64::decode(out.data(), in.data(), in.size());
  out.resize(result.first);
  size_t pads = 0;
  for (size_t i = in.size(); i > 0 && in[i - 1] == '='; --i) ++pads;
  if (result.second + pads != in.size() || in.size() % 4 != 0)
    return {}; /* it stopped early, or the tail was impossible */
  return out;
}

Two details to file away. First, the decoded_size(n) helper the header points you to is only a valid upper bound when n is a multiple of 4 - the function's own comment says as much - which is why the wrapper above adds a couple of bytes of slack instead of trusting it for arbitrary lengths. Second, provenance: the source files are copyrighted 2016-2019 by Vinnie Falco, with a footer attributing portions to a snippet by Rene Nyffenegger from 2004-2008. That snippet is the base64 pair that has been copy-pasted across the English-speaking internet for two decades, and it is now shipping inside Boost, in your binary, doing HTTP Basic auth for the whole web.

Boost.Serialization: The Decoder That Throws at a Stray Space

Boost's serialization library carries the oldest base64 in the C++ ecosystem: a set of composable iterator adapters from 2002, authored by Robert Ramey, that treat "be liberal in what you accept" as a personal insult. The decoding direction lives in binary_from_base64.hpp (yes, the name is from the output's point of view) and pairs with a width transformer that repacks six-bit values into eight-bit bytes:

#include <boost/archive/iterators/binary_from_base64.hpp>
#include <boost/archive/iterators/transform_width.hpp>
#include <cstddef>
#include <string>

namespace it = boost::archive::iterators;

std::string boost_decode(const std::string &in) {
  using dec =
      it::transform_width<it::binary_from_base64<const char *>, 8, 6>;
  std::string out(dec(in.data()), dec(in.data() + in.size()));
  size_t pads = 0;
  for (size_t i = in.size(); i > 0 && in[i - 1] == '='; --i) ++pads;
  out.resize(out.size() - pads);
  return out;
}

The inner iterator converts each base64 character into its six-bit value, and the outer one regroups those values into bytes. Its strictness is total: any character outside the alphabet - including a single space - makes the iterator throw boost::archive::iterators::dataflow_exception with the message "attempt to decode a value not in base64 char set". That is the "reject unless told otherwise" behavior the RFCs later made explicit - implemented in 2002, a full year before RFC 3548 codified the same rule. The practical consequence is that MIME-wrapped input must be stripped of its line breaks before it touches this iterator. The second quirk is more subtle: in the lookup table, the padding character = is not skipped, it is rendered as the value zero. Decoding TWFuZQ== therefore produces six bytes - 4d 61 6e 65 00 00 - because both pad characters contributed real (zero) data, and the resize(size - pads) line in the snippet is load-bearing, not decorative. Decode TQ== and you get three bytes that trim down to the single letter M, exactly where you want to land.

Forty Lines You Own

Base64 is small enough that a correct decoder is a respectable thing to own, and in C++ the payoff is better than in any other language: std::string makes the buffer management pleasant, and a hand-rolled decoder can do something none of the library versions above do, which is point at the exact byte that hurt. This version follows RFC 4648's strict reading - trim the ends, reject internal whitespace, reject padding in the middle, enforce the length rules, and even check the pad bits the RFC says a conforming encoder must have zeroed:

#include <cstddef>
#include <cstring>
#include <string>

std::string strict_decode(const std::string &in, size_t *error_pos = nullptr) {
  static const char *table =
      "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
  auto fail = [&](size_t pos) {
    if (error_pos) *error_pos = pos;
    return std::string();
  };
  size_t start = 0;
  size_t end = in.size();
  while (start < end && (in[start] == ' ' || in[start] == '\t' ||
                         in[start] == '\r' || in[start] == '\n'))
    ++start;
  while (end > start && (in[end - 1] == ' ' || in[end - 1] == '\t' ||
                         in[end - 1] == '\r' || in[end - 1] == '\n'))
    --end;
  size_t pads = 0;
  while (end > start && in[end - 1] == '=') {
    --end;
    ++pads;
  }
  size_t body = end - start;
  if (pads > 2 || (pads == 1 && body % 4 != 3) ||
      (pads == 2 && body % 4 != 2) || (pads == 0 && body % 4 == 1))
    return fail(in.size());
  if (body >= 2 && body % 4 != 0) {
    int leftover = static_cast<int>((body % 4) * 6 % 8);
    int last = static_cast<int>(std::strchr(table, in[end - 1]) - table);
    if ((last & ((1 << leftover) - 1)) != 0)
      return fail(end - 1); /* non-canonical pad bits */
  }
  int value = 0;
  int bits = -8;
  std::string out;
  out.reserve(body / 4 * 3);
  for (size_t i = start; i < end; ++i) {
    const char *p = std::strchr(table, in[i]);
    if (!p)
      return fail(i);
    value = (value << 6) + static_cast<int>(p - table);
    bits += 6;
    if (bits >= 0) {
      out.push_back(static_cast<char>((value >> bits) & 0xFF));
      bits -= 8;
    }
  }
  return out;
}

Walk through what it enforces. Leading and trailing whitespace is trimmed, because a payload copied from an email header is very likely to arrive wearing one. Internal whitespace is rejected, because RFC 4648 says a decoder may reject non-alphabet characters unless the surrounding specification says otherwise, and at a security boundary you want the strict reading. A pad sign in the middle of the data is rejected, as is any length that cannot correspond to a real payload: one character short of a group is impossible, and a single pad is only legal behind three body characters. The canonical check at the end is the one most implementations skip: if the last group had one or two pad characters, the unused low bits of the final alphabet character must be zero, or the same bytes could be written as two visibly different strings. That malleability is why the check exists, and it costs four lines. Finally, the decoder accepts unpadded input, which is exactly what JWT segments are. And on failure it hands you the position: feed it TWF!ZQ== and the error sits at index 3, on the exclamation mark, which is the difference between a bug report and a fix.

Base64url: The Alphabet Tokens Speak

The standard alphabet has two characters that do not survive a URL: + means space in a query string, and / means directory in a path. RFC 4648 section 5 fixes this with two character swaps - + becomes - and / becomes _ - and is blunt about the result: this encoding "should not be regarded as the same as the base64 encoding". It is the alphabet of JWTs, OAuth PKCE code challenges, YouTube video identifiers, and most API tokens, and it routinely drops the = padding too, because in a token the length is known implicitly and the padding would just be a percent-escape waiting to happen. None of the C++ decoders above speaks it natively - OpenSSL even treats a - as that soft end-of-input marker - so the fix is a small transcode before you decode. It is so short it is easy to keep in your head:

#include <string>

std::string url_to_standard(std::string in) {
  for (char &c : in) {
    if (c == '-') c = '+';
    else if (c == '_') c = '/';
  }
  switch (in.size() % 4) {
    case 2: in += "=="; break; /* restore the dropped padding */
    case 3: in += '=';  break;
    default: break;
  }
  return in;
}

Apply it to a real JWT and the first two segments are plain JSON waiting to happen. A classic example token decodes to a header of {"alg":"HS256","typ":"JWT"} and a set of claims containing a subject, a name, and an issued-at timestamp. The third segment decodes the same way and gives you the raw signature bytes - not text, and not proof of anything. Decoding a token tells you what it claims; verifying the signature tells you whether to believe it, and that is a cryptography job no base64 library will do for you. On Windows the situation is one-sided in the same direction: CryptBinaryToStringA has a CRYPT_STRING_BASE64URI flag for the encoding side, but the decoding direction has no URL-safe flag at all, so the transcode above earns its place in your muscle memory on every platform.

From Bytes Back to Text

Ask a C++ decoder "what charset did I just decode?" and you get the most honest answer the language has: none. Decoders are byte-oriented end to end. They do not see text, they see bytes, and they hand you back exactly the bytes that were packed. If the original was UTF-8, you now hold UTF-8, and nothing more is needed. The nice C++ twist is that std::string itself is a byte container with a length member, so the classic C failure mode - a string function that stops at the first NUL - mostly evaporates. A decoded file, a decoded certificate, a decoded image: all of them can live in a string, be compared with ==, hashed, and passed by value, and the zero bytes inside are simply bytes. Just do not convert to a C string and then measure it with strlen; use size().

For the legacy encodings that still lurk in old databases, exports, and hand-written tools - ISO-8859-1, Windows-1252, and their relatives - the standard tool is POSIX iconv, which ships with glibc. Decode to bytes first, then convert those bytes to UTF-8 with the codec that matches the source:

#include <iconv.h>
#include <cstddef>
#include <string>
#include <vector>

std::string to_utf8(const std::vector<unsigned char> &raw,
                    const char *source_charset) {
  iconv_t cd = iconv_open("UTF-8", source_charset);
  if (cd == (iconv_t)-1) return {};
  char *inptr = reinterpret_cast<char *>(const_cast<unsigned char *>(raw.data()));
  size_t inleft = raw.size();
  std::vector<char> utf8buf(raw.size() * 4 + 8);
  char *outptr = utf8buf.data();
  size_t outleft = utf8buf.size();
  if (iconv(cd, &inptr, &inleft, &outptr, &outleft) == (size_t)-1) {
    iconv_close(cd);
    return {};
  }
  iconv_close(cd);
  return std::string(utf8buf.data(),
                     static_cast<size_t>(outptr - utf8buf.data()));
}

The round trip is lossless in both directions: pack a string as ISO-8859-1, base64 it, ship it, decode it, convert it, and you get exactly what you started with, with the accented characters intact. And binary data has no charset at all - a PNG is a PNG whether you like it or not, which is the most liberating answer in the whole article.

Decoding Files

Small files are a four-step dance: open in binary, read into a vector, decode, write the result back in binary. Binary mode, every time, on every platform - on Windows a text-mode read would translate CRLF pairs into single newlines and quietly change your data before the decoder even sees it:

#include <fstream>
#include <iterator>
#include <string>
#include <vector>

std::vector<unsigned char> read_file(const std::string &path) {
  std::ifstream in(path, std::ios::binary);
  return {std::istreambuf_iterator<char>(in),
          std::istreambuf_iterator<char>()};
}

Notice the double set of parentheses around the first iterator. With single parentheses, a line of the form std::vector<unsigned char> bytes(istreambuf_iterator<char>(file), istreambuf_iterator<char>()) is the infamous "most vexing parse": the compiler reads it as a declaration of a function that returns a vector, and it is entirely correct to do so. The braced initializer form above sidesteps the grammar entirely. Once the bytes are in hand, feed them through any decoder from this article and write the result with std::ofstream in std::ios::binary, using write(data.data(), data.size()) rather than the stream operator, so any embedded zero bytes survive the journey to disk. A .b64 file and its decoded twin then differ by exactly the 33 percent tax you paid on the way in, which makes for a satisfying checksum moment.

Big Files: Stream Both Directions

For files too big to hold in memory, the streaming decoder from the OpenSSL section does the whole job: read a chunk, push it through the context, write out whatever came out, repeat. Only a small buffer lives in RAM at any moment, so a 10 GB base64 file decodes with the same code as a 10 KB one, and MIME-style line wrapping needs no preprocessing on the way through because the streaming decoder shrugs at newlines:

#include <fstream>
#include <string>
#include <vector>
#include <openssl/evp.h>

bool decode_stream_to_file(const std::string &in_path,
                           const std::string &out_path) {
  std::ifstream in(in_path, std::ios::binary);
  std::ofstream out(out_path, std::ios::binary);
  if (!in || !out) return false;
  EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
  EVP_DecodeInit(ctx);
  std::string chunk(65536, '\0');
  std::vector<unsigned char> decoded(49152);
  bool ok = true;
  for (;;) {
    std::streamsize got = in.read(chunk.data(), chunk.size()).gcount();
    if (got < 0) { ok = false; break; }
    if (got == 0) break;
    int outl = 0;
    int ret = EVP_DecodeUpdate(ctx, decoded.data(), &outl,
                               reinterpret_cast<const unsigned char *>(chunk.data()),
                               static_cast<int>(got));
    if (ret < 0) { ok = false; break; }
    out.write(reinterpret_cast<const char *>(decoded.data()), outl);
    if (ret == 0) break;
  }
  unsigned char tail[3];
  int tail_l = 0;
  if (ok && EVP_DecodeFinal(ctx, tail, &tail_l) != 1)
    ok = false;
  if (ok)
    out.write(reinterpret_cast<const char *>(tail), tail_l);
  EVP_ENCODE_CTX_free(ctx);
  return ok;
}

The buffer sizes are not arbitrary: the length parameters of the EVP functions are int, so a single call is safe up to 2 GB, and the numbers above keep each chunk at 64 KB of input with a 48 KB output buffer, which is exactly 3 out of 4. That int ceiling is the whole reason the streaming path exists, and it is worth knowing as a hard fact rather than discovering it as a platform bug. If the input ever turns out to be corrupt, the function returns false at the first chunk that cannot be decoded, and the output file holds whatever was valid before that - which, depending on your pipeline, may be exactly the partial result you wanted.

HTTP, APIs and the JSON Fields That Hide Bytes

Base64 shows up in HTTP in two shapes. The first is data: a JSON response with a "certificate" or "avatar" field full of base64, an upload endpoint that accepts the bytes in a text-safe column, a download endpoint that hands you a .b64 file. The pattern is always the same - parse the JSON, pull the string out, decode it, treat the result as bytes - and the decode side of this article is the whole implementation. The second shape is credentials: the Authorization: Basic header is the base64 of user:password, and it has been the standard's one base64 use case for thirty years. Parsing it is two steps, and the first one is where people put a NUL-terminated C string in the middle of binary-adjacent data and wonder why:

#include <optional>
#include <string>

/* strict_decode from the "Forty Lines You Own" section */

std::optional<std::pair<std::string, std::string>> parse_basic_auth(
    const std::string &b64) {
  std::string raw = strict_decode(b64);
  size_t colon = raw.find(':');
  if (colon == std::string::npos)
    return std::nullopt;
  return std::make_pair(raw.substr(0, colon), raw.substr(colon + 1));
}

Pass it the header value after the Basic prefix and it hands you the user and password as proper length-tracked strings, or nothing at all if the payload is not a user:pass pair. The security note belongs here even though it is not a C++ topic: Basic auth is obfuscation, not protection. The header rides in the clear for anyone who can read the network, so it is only acceptable behind TLS, and even then it is the choice for machine-to-machine calls, not for people.

JWTs: Reading What a Token Claims

A JSON Web Token is three base64url segments glued with dots: header, claims, signature. The first two are JSON objects; the third is a cryptographic signature over the string header.claims, computed with the algorithm named in the header. C++ has no built-in JWT type, but reading a token needs nothing more than the transcode from the base64url section and a decoder, because the interesting part is the reading:

#include <cstddef>
#include <cstdio>
#include <string>

/* url_to_standard and strict_decode from earlier sections */

int main() {
  const std::string token =
      "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
      "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ."
      "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
  size_t dot1 = token.find('.');
  size_t dot2 = token.find('.', dot1 + 1);
  std::string header = strict_decode(
      url_to_standard(token.substr(0, dot1)));
  std::string claims = strict_decode(
      url_to_standard(token.substr(dot1 + 1, dot2 - dot1 - 1)));
  std::printf("header: %s\n", header.c_str());
  std::printf("claims: %s\n", claims.c_str());
}

The header comes back as {"alg":"HS256","typ":"JWT"} and the claims as {"sub":"1234567890","name":"John Doe","iat":1516239022} - the subject, the name, and an issued-at timestamp. That is the entire read side, and it is genuinely useful: logging what a token claims, debugging a 401 by looking at the expiry field, or deciding which claims to trust is all one decode away. What it is not is verification. The signature segment is base64url too, and decoding it gives you 32 or 64 raw bytes that prove nothing on their own; the signature is only meaningful when you recompute the hash of header.claims with the shared secret or public key and compare. Treat a decoded JWT the way you treat a letter: it says what it says, and verifying the seal is a separate, cryptographic job.

Data URIs: Files That Pasted Themselves Into a Page

A data URI is a URL whose payload is right there in the address: data: followed by an optional media type, an optional ;base64 marker, a comma, and then the data itself - the whole scheme of RFC 2397. Browsers use them to embed images, fonts, and small scripts directly in HTML and CSS with no extra request, and if you ever see a page that keeps working with the network disabled, a data URI is a strong suspect. On the C++ side, the decoding job is to split the URI and then run the payload through your usual decoder, because when the ;base64 marker is present the payload is plain standard base64 - usually padded, usually on one line:

#include <cstddef>
#include <string>

std::string data_uri_payload(const std::string &uri, bool *is_base64) {
  const std::string prefix = "data:";
  if (uri.rfind(prefix, 0) != 0)
    return {};
  size_t comma = uri.find(',');
  if (comma == std::string::npos)
    return {};
  std::string meta = uri.substr(prefix.size(), comma - prefix.size());
  *is_base64 = meta.size() >= 7 &&
               meta.compare(meta.size() - 7, 7, ";base64") == 0;
  return uri.substr(comma + 1);
}

Call it with data:image/png;base64,iVBORw0KGgo=... and it hands you the payload plus the flag telling you which decoding path to take. Two pitfalls. First, when the marker is absent the payload is URL-encoded text, not base64, so the flag is not a formality - a URI that looks like base64 but was generated as percent-encoded text will decode to garbage. Second, some generators wrap long data URIs with newlines the way MIME does; your strict decoder will refuse those, so strip line breaks before decoding if the source is not under your control. Decoding the payload of a data:image/png URI gives you the exact PNG bytes, header and all, which is the quiet satisfaction of the whole exercise.

Email, MIME and the 76-Character Habit

Email is the reason base64 learned to wrap its lines. SMTP, in its original form, was built to carry seven-bit ASCII, so anything binary had to be rewritten as printable text before it could travel. Privacy-Enhanced Mail did it in 1987 with 64-character lines, and MIME, when it standardized the encoding for email in 1997, relaxed the limit to 76 characters and added the rule that a compliant decoder must simply ignore line breaks. The habit survived: an email attachment is still base64 today, wrapped at 76, and the exact arithmetic works out to 4/3 times 78/76 - about 137 percent of the original size, plus a few hundred bytes of headers. Your C++ decoder shrinks it all the way back to 100 percent, which is the point of the whole format.

The wrinkle in C++ is that the decoders in this article disagree about line breaks, and each one has a reason. The OpenSSL streaming decoder skips them anywhere in the stream, which is exactly MIME's rule. The OpenSSL one-shot function refuses any whitespace inside the payload. Boost.Beast stops at the first newline without saying so. The Boost iterators throw at a single space. So when a payload comes from email, your first decision is which decoder to use, or you strip the line breaks yourself - a one-line erase-remove pass over \r and \n - and let any decoder you like do the real work. Stripping up front is the boring, reliable choice, and it is the one that keeps your decoder's choice independent of your payload's history.

Databases, Config Files and Environment Variables

The third home of base64 is the storage layer: a column in a legacy database whose documentation says "base64" and nothing more, a config blob in a JSON file, a payload in an environment variable that one service base64'd so it could survive a shell. The decoding pattern is the same as everywhere else - read the string, decode, treat as bytes - but the unlabeled input deserves a special paragraph, because sometimes you genuinely do not know which alphabet was used. You cannot know, but you can test, because four characters do most of the work:

  • Contains + or / - only the standard alphabet can be right.
  • Contains - or _ - only the URL-safe alphabet can be right.
  • Neither, but ends in = - padded standard, or a padded URL-safe string whose payload never happened to need the two swapped characters.
  • Neither, no padding - could be either; the raw URL-safe form is the common one on the web, so for data born in a URL or a token, try that first.

Strings that use none of the four distinguishing characters decode identically under both alphabets, so for those the order in which you try them is a matter of where the data came from: things born in email want the standard alphabet, things born in a URL want the URL one. And remember to try the padded and unpadded reading of the same string - one missing = is the difference between "refused" and "solved", which is why the strict decoder above accepts both.

The Command Line Has Two Base64s

For one-off jobs, a Linux box usually has two base64 decoders, and they behave differently in exactly the way that bites people. The first is base64 from GNU coreutils (some newer distributions ship the uutils reimplementation instead, and both speak the same flags - check with base64 --version). It conforms to RFC 4648, wraps at 76 characters when encoding (with -w 0 turning that off), and on decode it happily accepts newlines anywhere; its -i flag makes garbage-tolerance explicit instead of accidental. The second is OpenSSL's, and here is the twist: openssl base64 is not its own app at all. Since OpenSSL 3.0, the enc program checks its own invocation name, and if it was called "base64" it switches itself into base64 mode - a string comparison on argv[0], which is the C way of shipping an alias. Without -A, it expects a newline somewhere in the first 1024 bytes of input, so a long single-line string comes back empty, with exit code 0. With -A it reads one line, and the documented bug list for the enc command is a two-item museum: the -A option does not work properly with large files, and without -A, if the first 1024 bytes hold no newline, the first two lines of input are ignored. In a pipeline, a silent empty file looks exactly like a successful decode of an empty payload.

# the honest one-liners
base64 -d < payload.b64 > payload.bin
openssl base64 -d -A < payload.b64 > payload.bin

Neither speaks base64url natively, which is one more reason the transcode snippet belongs in your muscle memory. For anything that matters, decode in your program, where errors come back as numbers you can test and the exit code of a silent tool is not your only signal.

Pitfalls That Specifically Bite C++

  • The zero-fill. EVP_DecodeBlock returns three bytes for TQ==: the letter M plus two zeros. Recover the real length from the padding, or use the streaming API, which is honest about the count.
  • The pre-3.5 streaming quirk. On OpenSSL releases before 3.5.0 (April 2025), EVP_DecodeUpdate had the same zero-fill habit. Code written against a 3.0 or 3.3 pin may be lying to you about tail lengths; the fix is recorded in the man page's history section.
  • The silent stop. Boost.Beast's decode has no error channel: it stops at any invalid character, any newline, and any impossible tail length, and returns a partial result with a straight face. Check that consumed + pads == input.size() and that the total is a multiple of 4, or you are decoding whatever it decided to decode.
  • The decoded_size trap. beast64::decoded_size(n) is only an upper bound for n divisible by 4. Two characters of input can yield one byte into a zero-byte buffer - add slack for odd lengths.
  • The zero-byte pads. The Boost iterators decode = as the value zero, so TWFuZQ== becomes six bytes including two trailing zeros. Subtract the pad count, or enjoy your ghosts.
  • Whitespace, four ways. OpenSSL streaming skips it, OpenSSL one-shot refuses it internally, the archive iterators throw on it, and Beast stops at it. Pasted strings love to carry whitespace, and each decoder has its own opinion about it.
  • Signed char indexing. If you roll your own decode table indexed by character, index with unsigned char. On platforms where char is signed, a byte above 127 becomes a negative index, which is undefined behavior wearing a lab coat.
  • The minus sign is a time traveler. In OpenSSL, - is a PEM-era soft end-of-input marker, not an alphabet character. Transcode base64url before you decode.
  • int, not size_t. The EVP length parameters are int. Above 2 GB, only the chunked streaming path is safe, which is why it exists.
  • Text mode on Windows. Opening a file for text translates CRLF to LF and corrupts your input before decoding. std::ios::binary, every time, on every platform.
  • The most vexing parse. std::vector<char> v(istreambuf_iterator<char>(f), istreambuf_iterator<char>()) is a function declaration. Use brace initialization or a pointer pair.
  • Non-canonical pad bits. A lenient decoder may accept strings whose unused pad bits are non-zero, so two visibly different strings decode to the same bytes (base64 malleability). At security boundaries, reject what you do not need - RFC 4648 says decoders may do exactly that.
  • The command line fails silently. openssl base64 -d without -A swallows single-line input (empty output, exit 0); the documented bugs cover large files and newline-less input in both directions. Check your output in pipelines.
  • strlen on binary. std::string keeps zero bytes happy, but the moment you hand a C string to a legacy API, strlen stops at the first NUL. Pass length and pointer, never a bare pointer.

A Short History of Base64 in C++

The format is older than the language's modern era. The first standardized use of the encoding now called MIME base64 was the Privacy-Enhanced Mail protocol, proposed in 1987 with 64-character lines and a CRC checksum glued to the end; the name "base64" itself only arrived in 1997, when the MIME standards named it. C++ arrived on the scene as C++98 in 1998 - one year after MIME - and the first base64 code the language's developers reached for was the C pair from 2004-2008 by Rene Nyffenegger, which a Stack Overflow question from October 8, 2008 spread across the web. The nicest part of that story: the top answer on the question was written by Nyffenegger himself, the original author, posting a modified version of his own snippet. The folk song has a license header, and the composer showed up in the comments.

Then the ecosystem did what ecosystems do. In 2002, Robert Ramey's Boost.Serialization shipped the iterator adapters - the oldest base64 in the C++ toolbox, strict to the point of throwing an exception at a single space, a year before RFC 3548 codified the rule it was already enforcing. In 2017, Boost 1.66 brought Beast, and with it the header-only codec that still ships today with the Nyffenegger attribution in its footer. Meanwhile the standard itself went C++11, C++14, C++17, C++20, and C++23 (published in 2024), and every single one of them looked at the 64-character alphabet and moved on. As of 2026, C++26 is in progress, the draft adds a new <text_encoding> header for text codec work, and the next committee vote is expected at the ISO C++ meeting of November 16-21, 2026, in Búzios, Brazil. Base64 is not in the draft. Seven standards, three decades, one header for text encoding - and the committee has now had every possible excuse to add base64 and passed on all of them. The practical history of base64 in C++ is, and remains, the history of its libraries: OpenSSL's EVP routines, two Boost flavors, a Windows API call, and a forty-line snippet you own.

Fun Facts, C++ Edition

  • The same pair of functions appears in the answers to a 2008 Stack Overflow question, in the source of Boost.Beast with an attribution footer, and in the header files of countless private codebases. Ask a C++ developer where their base64 came from and the most honest answer is "I do not know, and neither does the internet".
  • Boost's archive iterators are the oldest base64 in this article, copyright 2002 - the same year the .NET Framework 1.0 SDK shipped. They throw an exception at a single space, which means they were enforcing the "reject non-alphabet characters" rule before the RFCs caught up: RFC 3548 codified it in 2003, and RFC 4648 repeated it in 2006.
  • OpenSSL's streaming decoder processes input in chunks of up to 80 base64 characters, a number that appears nowhere in any RFC. It is an implementation detail from the PEM era, kept for compatibility, and it is one of the last places where 1987 is still doing load-bearing work in 2026.
  • The smallest possible padded base64 is four characters, TQ==: one byte wearing a two-character costume. The smallest unpadded is two characters, TQ. Which one you get to decode depends entirely on who encoded it, and that person was not thinking about you.
  • MIME's math is exact: 4/3 times 78/76, which is why an email attachment arrives about 137 percent of its original size (plus roughly 814 bytes of headers, per the formula). Your C++ decoder shrinks it back down to 100 percent, which is the quiet joy of the whole exercise.
  • On a typical libstdc++ or MSVC, std::string carries small payloads in a stack buffer through small-string optimization instead of allocating. A 9-byte input decodes to 6 bytes and never touches the heap. Your tiny config blob's base64 form may literally live in a stack frame, which is the kind of free lunch the standard library does not advertise.
  • The openssl base64 command you may reach for in a shell is not a command at all. It is the enc program checking its own name in argv[0] and switching personality. An alias by string comparison, which is the C++ way of doing things, in C.
  • YouTube video identifiers are base64url: eleven characters, no padding, no + or / anywhere near a URL. The most-watched encoding format on the planet runs on the "URL and Filename Safe" variant that RFC 4648 added in a section that fits on one page.

When You Need to Pack Instead

Everything you just decoded was packed by the same toolbox on the other side: EVP_EncodeBlock for one-shots, EVP_EncodeUpdate plus EVP_EncodeFinal for streams (and that is where those 64-character lines come from), the same buffer arithmetic in reverse, and the same 33 percent tax that decoding quietly refunds. The full packing story - the size math itemized, the encoders that null-terminate their output, the Boost iterator that has never met a pad character, base64url, MIME wrapping, files, and the Windows API with its CRLF habit - lives in the C++ encoding guide on the sister site. Go read it, then come back and open something big. That is the whole game: no standard library, three trustworthy vendors with three different temperaments, a decoder that points at the exact byte that hurt, a 2025 bugfix that changed the streaming tail, and one zero-filled triplet to remember forever. Happy unpacking.

Last updated: 2026-08-30

Related article: Base64 Encoding in C++ (Cpp): A Complete Guide