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 Encoding in C++ (Cpp): A Complete Guide

The inverse problem is the one with the bigger headline: you have bytes - a certificate, an image, a random blob, a signature - and you need them to travel through something that only speaks text: a JSON field, an email header, a URL, an environment variable. The home page of this site walks through the format in depth, so here is only the short version: three bytes become four alphabet characters, a short tail gets one or two = marks, and the encoded form runs about 33 percent larger than the original. Encoding is the growing direction, so every buffer in this article is sized for that, and the arithmetic is a one-liner - 4 * ((n + 2) / 3) - that does not change no matter which encoder you pick.

Like the decoding side, C++ itself will not encode a single byte 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 encoder from a bench of four very different personalities, plus the option of writing about forty lines of your own. One is a workhorse that has carried TLS since the 1990s and that pads, null-terminates, and wraps lines without asking permission. One is a fast header-only type hiding in a namespace its authors labeled "detail". One is a 2002 iterator that has apparently never met a padding character. One is a function the operating system has shipped for decades that appends CRLF to the end of your token. And the fifth option is yours. Once you know what each one adds, refuses, or quietly appends, encoding stops being a source of off-by-one bugs. Let us get to the packing.

The Standard Has Never Shipped a Packer

Every standard since C++98 - and there have been seven of them, through C++23 in 2024 - has looked at the 64-character alphabet and moved on. There is no <base64>, no std::base64, nothing in <string> or <vector> that will pack your bytes. C++26 is in progress, its draft adds a <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, and it is hard to blame the committee: text encoding is about character sets, and base64 is about bytes, so the new header was never the right home. In practice the ecosystem did the work. OpenSSL's EVP base64 routines are documented as available in every OpenSSL release, the Boost libraries carry two independent encoders, Windows ships a CryptoAPI function with a flag table for the job, and a forty-line snippet has been copy-pasted across the language since 2008. If your project is CMake-based, the whole dependency setup is three lines:

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

The Boost release to note is 1.92.0, from August 2026, in a project that has been shipping libraries since 1998. Both Boost encoders below are header-only - there is nothing to link at all - while OpenSSL wants -lcrypto, which most C++ programs that touch TLS already have in the binary.

First, the Math: Sizing Every Buffer in This Article

Base64 groups bytes in threes, so the output length has a shape that never surprises once you know it: for every 3 bytes of input, 4 characters out, and a short tail gets padded to a full group. The exact count for n bytes of input is:

4 * ((n + 2) / 3)

The +2 is the ceiling trick: integer division rounds down, so adding 2 first makes it round up to the next multiple of three. From there, every buffer size in this article is a substitution. OpenSSL's one-shot function wants a buffer that can hold the encoded data plus the NUL it appends at the end - the man page illustrates the contract with 16 input bytes becoming 24 encoded bytes plus 1 NUL, 25 bytes total, and the function returns the length without the NUL. Its streaming path processes input in 48-byte blocks, and the man page sizes the output at 65 bytes per block (64 characters plus the newline that each block always produces) with one more byte for the NUL. The Boost.Beast header hands you the exact formula as a constexpr function. And your own code reserves (n + 2) / 3 * 4 and calls it a day. Here are the numbers you will actually hit:

Input Output (padded) What to notice
1 byte 4 chars The smallest padded form: QQ==
2 bytes 4 chars Three data characters and one pad
3 bytes 4 chars One full group, no padding at all
48 bytes 64 chars Exactly one OpenSSL streaming block
500 bytes 668 chars Wrap it at 64 and it is 11 lines, 679 chars with newlines
1 GB about 1.33 GB Budget the column, the file, and the wire for the tax

If the receiving side is a fixed-size column, a buffer, or a line in a text file, this formula is the whole design document. The one direction in which it can bite you is the other one: the decode side needs 3n/4 minus pads, and a decode buffer sized with the encode formula is a classic over-allocation that grows up into a memory-bug ticket. Sizing the shrinking direction is the sister guide's problem; here you only ever grow.

Here is the landscape, because the differences are all in the extras - the padding, the newlines, the NULs - rather than in the core packing, which every row implements identically:

Encoder Where it comes from Padding Extra bytes to budget Quirk to remember
EVP_EncodeBlock <openssl/evp.h>, link -lcrypto Always 1 (a NUL in the buffer) The man page's 16-byte example is the contract
EVP_EncodeUpdate + Final same Always 65 per 48-byte block Hard-wraps at 64 chars, every block ends in a newline
Boost.Beast encode boost/beast/core/detail/base64.hpp, header-only Always 0 Sits in a namespace named detail
Boost.Serialization iterators boost/archive/iterators/base64_from_binary.hpp, header-only Never 0 - you add the 1 or 2 pads yourself Oldest encoder in the toolbox, 2002
CryptBinaryToStringA wincrypt.h, crypt32.lib Always 2 (a CRLF) unless NOCRLF Has a URL-safe flag the rest of the toolbox lacks
Your own forty lines Nowhere: it is yours Your choice Your choice You own every edge case forever

The core algorithm is identical in every row - that is the comforting part of a 1987 format. What differs is what each implementation adds around the payload, and nearly every pitfall in this article is one of those additions meeting a consumer that did not expect it.

OpenSSL: The Encoder Your TLS Stack Already Links

If your program already links OpenSSL for TLS, you do not need to add anything. The one-shot function is a single call:

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

Hand it the source bytes and the length and it writes the padded, one-line encoding. The contract is worth memorizing, because the man page states it with an example: for every 3 bytes of input, 4 bytes of output; a tail that is not divisible by 3 is padded so the output is always divisible by 4; and a NUL terminator character is added on top. The documented example is 16 bytes in, 24 encoded bytes plus 1 NUL, 25 bytes total in the buffer, with the function returning 24 - the length without the NUL. Size the buffer accordingly and the wrapper is a few lines:

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

std::string openssl_encode(const std::string &in) {
  std::string out;
  out.resize(4 * ((in.size() + 2) / 3) + 1);
  int n = EVP_EncodeBlock(reinterpret_cast<unsigned char *>(out.data()),
                          reinterpret_cast<const unsigned char *>(in.data()),
                          static_cast<int>(in.size()));
  if (n < 0) return {};
  out.resize(static_cast<size_t>(n));
  return out;
}

int main() {
  std::printf("%s\n", openssl_encode("Mane").c_str());
  std::printf("%s\n", openssl_encode("M").c_str());
  std::printf("%s\n", openssl_encode("").c_str());
}

Notice what the std::string is doing that C would force on you: it grows to exactly the returned length, so the NUL OpenSSL appended is simply beyond the tracked length and never becomes part of the payload. Encode "Mane" and you get TWFuZQ==, the classic four-character tail with its single pad; encode one byte and you get a two-character data pair wearing a two-character pad costume; encode nothing and you get the empty string, the one case where a base64 encoder behaves exactly like the identity function. The one line of real logic in the whole function is the resize: it turns "bytes written plus a NUL" into "exactly the payload".

For data that arrives in pieces - a file, a socket, a stream you do not want to buffer - OpenSSL has a context you feed and finish, and the man page's block arithmetic is unusually explicit. Only full blocks of 48 bytes are processed immediately; any remainder is held inside the context and released by a later call or by the final one. Each processed block writes 64 characters plus a newline - 65 bytes - and the final call handles the partial block, which is why its documented ceiling is 65 bytes plus the NUL. The consequence to know before you call: this API wraps at 64 characters. It is not configurable. It is what the streaming encoder is.

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

std::string openssl_encode_wrapped(const std::string &in) {
  EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
  EVP_EncodeInit(ctx);
  std::string out;
  out.reserve(4 * ((in.size() + 2) / 3) + in.size() / 48 + 2);
  std::vector<unsigned char> buf(128);
  int outl = 0;
  for (size_t pos = 0; pos < in.size();) {
    size_t take = std::min<size_t>(48, in.size() - pos);
    EVP_EncodeUpdate(ctx, buf.data(), &outl,
                     reinterpret_cast<const unsigned char *>(in.data()) + pos,
                     static_cast<int>(take));
    out.append(reinterpret_cast<const char *>(buf.data()), outl);
    pos += take;
  }
  EVP_EncodeFinal(ctx, buf.data(), &outl);
  out.append(reinterpret_cast<const char *>(buf.data()), outl);
  EVP_ENCODE_CTX_free(ctx);
  return out;
}

int main() {
  std::string s = openssl_encode_wrapped(std::string(500, 'A'));
  std::printf("500 bytes -> %zu chars\n", s.size());
  int lines = 0;
  size_t longest = 0, run = 0;
  for (char c : s) {
    if (c == '\n') { lines++; run = 0; }
    else run++;
    longest = std::max(longest, run);
  }
  std::printf("lines=%d longest=%zu lastchar=%c\n", lines, longest, s.back());
}

Feed it 500 bytes of the letter A and the accounting comes out exactly as the man page promised: 668 encoded characters, and because the output is cut into 64-character lines, you get 11 lines, 679 characters in all, and the very last character is a newline. That trailing newline is the one that breaks consumers: paste the result into a JSON string and you have a control character where a quote was supposed to be; use it as a token segment and you have invented a new segment. The rule of thumb: the block API for one-line payloads (tokens, headers, config values), the streaming API when the consumer wants MIME-shaped wrapped output, and when in doubt, strip the trailing newline with a while (out.back() == '\n') before the payload crosses a boundary that does not expect it.

Boost.Beast: A Fast Packer in a detail:: Namespace

Boost's 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 the codec to a public API. Everyone uses it anyway: it is small, it is fast, it is header-only (define BOOST_BEAST_HEADER_ONLY before the include and there is nothing to link), and it is the same codec Boost's own HTTP client uses when it builds a Basic auth header, which means it has been chewing real traffic for years.

On the encoding side the API is almost insultingly calm. A constexpr helper gives you the exact output size - 4 * ((n + 2) / 3), the same formula as the math section, now with a compiler to check it - and the encode function writes the padded result into your buffer and tells you how many characters it used. There is no error channel, because encoding cannot fail: any byte is valid input, and the output length is a pure function of the input length. The wrapper:

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

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

std::string beast_encode(const std::string &in) {
  std::string out(b64::encoded_size(in.size()), '\0');
  std::size_t n = b64::encode(out.data(), in.data(), in.size());
  out.resize(n);
  return out;
}

int main() {
  std::printf("%s\n", beast_encode("Mane").c_str());
  std::printf("%s\n", beast_encode("M").c_str());
}

Encode "Mane" and you get TWFuZQ==; encode the single byte M and you get TQ== - the same bytes the OpenSSL wrapper produced, with no NUL to worry about and no lines to strip. Two things to file away. First, the provenance: the source is copyrighted 2016-2019 by Vinnie Falco, with a footer attributing portions to a snippet by Rene Nyffenegger from 2004-2008 - the same folk song that started the C++ base64 story, now shipping inside Boost, in your binary, doing HTTP Basic auth for the whole web. Second, the practical one: because the codec pads and never wraps, it is the right tool for anything that must be one line - tokens, headers, API payloads - and the encoded_size formula gives you a buffer that is exactly right, never an approximation.

Boost.Serialization: The Iterator That Forgot Padding Exists

The oldest base64 in the C++ ecosystem is not a function but a set of composable iterator adapters, written by Robert Ramey in 2002 for Boost's serialization library. The encoding direction is a two-adapter chain: a width transformer that regroups your raw bytes eight-to-six, and an iterator that turns each regrouped value into an alphabet character:

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

namespace it = boost::archive::iterators;

std::string boost_iter_encode(const std::string &in) {
  using enc =
      it::base64_from_binary<it::transform_width<const char *, 6, 8>>;
  std::string out(enc(in.data()), enc(in.data() + in.size()));
  switch (in.size() % 3) {
    case 1: out += "=="; break;
    case 2: out += '=';  break;
    default: break;
  }
  return out;
}

int main() {
  std::printf("%s\n", boost_iter_encode("Mane").c_str());
  std::printf("%s\n", boost_iter_encode("M").c_str());
}

The iterator does the core packing and nothing else - no padding, no NUL, no newlines, and no error channel, because the core packing cannot fail. Encode "Mane" and the iterator hands you five characters, TWFuZQ, with a straight face: a real encoding of four bytes is eight characters, and it never occurred to a 2002 iterator to care. That is why the switch statement is load-bearing, not decorative: one byte short of a group gets two pads, two bytes short gets one. The same chain minus the switch is what you get if you forget that step, and the result is a string that decodes fine under a lenient decoder, fails under a strict one, and turns your API consumer's error message into a mystery. (The decoding side of this same iterator family is the one that throws an exception at a single stray space - more in the sister guide.)

Forty Lines, Zero Dependencies

Base64 is small enough that a correct encoder 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, the formula gives you the exact size up front, and a hand-rolled encoder is the one that has no opinions at all - no NUL, no newlines, no platform habits - which is exactly what you want under a config file or an API boundary. This version packs in 3-byte groups against a 64-character table:

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

std::string base64_encode(const std::string &in) {
  static const char *table =
      "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
  std::string out;
  out.reserve((in.size() + 2) / 3 * 4);
  const unsigned char *p =
      reinterpret_cast<const unsigned char *>(in.data());
  size_t n = in.size();
  for (size_t i = 0; i < n; i += 3) {
    unsigned v = p[i] << 16;
    if (i + 1 < n) v |= p[i + 1] << 8;
    if (i + 2 < n) v |= p[i + 2];
    out.push_back(table[(v >> 18) & 63]);
    out.push_back(table[(v >> 12) & 63]);
    out.push_back(i + 1 < n ? table[(v >> 6) & 63] : '=');
    out.push_back(i + 2 < n ? table[v & 63] : '=');
  }
  return out;
}

int main() {
  std::printf("%s\n", base64_encode("Mane").c_str());
  std::printf("%s\n", base64_encode("M").c_str());
  std::printf("%s\n", base64_encode("M\250\277").c_str());
}

Walk through the parts. The reserve line is the math section: (n + 2) / 3 * 4 characters, exactly, so there is no reallocation mid-loop. The reinterpret_cast to const unsigned char * is not ceremony - on platforms where char is signed, a byte above 127 would otherwise be a negative number, and the moment it touched a table index you would have undefined behavior wearing a lab coat. Each iteration pulls up to three bytes into a 24-bit value, pushes the four 6-bit slices into the table, and at the tail emits = in place of whatever byte was not there - the i + 1 < n and i + 2 < n guards are the entire padding logic. Feed it "Mane" and you get TWFuZQ==. Feed it a single M and you get TQ==. Feed it a byte above 127 - the 0xCA 0xBF pair in the third line of the example - and the output stays pure ASCII (Tcq/), because a byte above 127 is just a byte, and the table does not care what it means. Forty lines, no dependencies, and every edge case is a line you wrote, which is the whole point.

Windows CryptoAPI: The OS-Built-In Packer

On Windows there is a base64 encoder in the operating system itself, older than most of the frameworks in this article: CryptBinaryToStringA from wincrypt.h, in crypt32.lib, part of the CryptoAPI that has shipped with Windows for decades. It converts a byte array into a formatted string, and its flag table reads like a menu of the format's whole history:

Flag Value What you get
CRYPT_STRING_BASE64HEADER 0x0 Base64 wrapped in certificate BEGIN/END header lines
CRYPT_STRING_BASE64 0x1 Plain base64, no headers
CRYPT_STRING_BASE64URI 0xD The URL-safe alphabet: + becomes -, / becomes _, per RFC 4648 section 5
CRYPT_STRING_NOCRLF 0x40000000 No newline appended at the end
CRYPT_STRING_NOCR 0x80000000 A bare LF instead of the default CRLF

The first thing to know is the default: unless you pass CRYPT_STRING_NOCRLF, the function appends a carriage-return/line-feed pair to the end of your string - the documented behavior is that every non-binary format gets a newline sequence - so a base64 token that must fit on one line wants BASE64 | NOCRLF, and that combination is the idiomatic call. The second thing is the calling convention, which is the classic Windows two-step: call with a NULL buffer to ask how much space is needed (the answer includes the terminating NUL), allocate, call again, and read back the length without the NUL:

#include <windows.h>
#include <wincrypt.h>
#include <cstddef>
#include <string>

std::string win32_encode(const std::string &in,
                         DWORD flags = CRYPT_STRING_BASE64) {
  DWORD need = 0;
  if (!CryptBinaryToStringA(reinterpret_cast<const BYTE *>(in.data()),
                            static_cast<DWORD>(in.size()),
                            flags | CRYPT_STRING_NOCRLF,
                            nullptr, &need))
    return {};
  std::string out(need, '\0');
  DWORD got = 0;
  if (!CryptBinaryToStringA(reinterpret_cast<const BYTE *>(in.data()),
                            static_cast<DWORD>(in.size()),
                            flags | CRYPT_STRING_NOCRLF,
                            out.data(), &got))
    return {};
  out.resize(got);
  return out;
}

Two more notes. The URI flag is the only native base64url in this entire article - on Windows you can encode the token alphabet directly, and the transcode approach in the section below is strictly for the other platforms. And the CRYPT_STRING_BASE64HEADER entry, with its value of 0, is also the flag you get if you pass zero, so a call that "meant" no flags at all quietly wraps the payload in the certificate header lines - the PEM-era habit of framing, useful for generating .pem files and a surprise for everything else. Link against crypt32.lib and the function is yours for the rest of the program's life.

Base64url: The Alphabet for Tokens and URLs

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 pads would just be percent-escapes waiting to happen.

Of the encoders in this article, only the Windows flag emits the alphabet natively - OpenSSL has no URL-safe mode, and neither Boost flavor does - so on most platforms the recipe is: encode standard, swap the two characters, drop the pads. It is a dozen lines:

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

/* base64_encode from the "Forty Lines, Zero Dependencies" section */

std::string base64url_encode(const std::string &in, bool pad = false) {
  std::string out = base64_encode(in);
  for (char &c : out) {
    if (c == '+') c = '-';
    else if (c == '/') c = '_';
  }
  if (!pad)
    while (!out.empty() && out.back() == '=')
      out.pop_back();
  return out;
}

int main() {
  std::printf("%s\n", base64url_encode("M\250\277"));
  std::printf("%s\n", base64url_encode("M"));
  std::printf("%s\n", base64url_encode("M", true));
}

The first line of output is Tcq-, where the standard alphabet would have written /; the second two lines show the pad switch at work - TQ unpadded by default, TQ== when the consumer wants it back. That pad argument is the one to think about, because the consumers disagree: JWT segments want no pads, PKCE challenges want no pads, but a base64url value that ends up in a field where the decoder is strict about length may want them back, and the switch is a bool, not a rewrite. And the failure mode to remember in the opposite direction: a - inside a standard-alphabet payload is simply invalid, so the two alphabets are not interchangeable at the byte level - a token encoded with the wrong alphabet does not decode, it fails, which is the failure you want at a security boundary.

Line Wrapping: 64, 76, or Never

Wrapped base64 has three line lengths in the wild, each with a history. The OpenSSL streaming encoder is hard at 64 characters - the PEM habit, where the 1987 standard for privacy-enhanced mail wrapped at 64. MIME, when it standardized the encoding for email in 1997, moved to 76 characters, and that number is the default of the coreutils base64 command (its -w flag sets the width, and -w 0 turns wrapping off entirely) and of most of the ecosystem's tools. The RFC 4648 recommendation is 76 as well. Which one you emit depends on who consumes it, and the consumer - not the format - is the design constraint.

Wrapping is a post-processing step on the encoded string, never an input step: the 4-character groups are the unit of meaning, so cutting the string at any multiple of the width is a safe cut - every line boundary lands between groups. The C++ version is a loop:

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

/* base64_encode from the "Forty Lines, Zero Dependencies" section */

std::string wrap_lines(std::string s, size_t width = 76) {
  std::string out;
  for (size_t i = 0; i < s.size(); i += width)
    out += s.substr(i, width) + "\r\n";
  return out;
}

int main() {
  std::string mime = wrap_lines(base64_encode(std::string(200, 'x')));
  int lines = 0;
  for (char c : mime)
    if (c == '\n') lines++;
  std::printf("mime: %d lines, %zu chars\n", lines, mime.size());
}

The accounting: 200 bytes encode to 268 characters, and wrapped at 76 with CRLF terminators that is 4 lines - three full lines and a 40-character tail - 272 characters on the wire. The CRLF choice in the snippet is email's choice; for everything else, LF is the modern default, and the one rule that is not negotiable is consistency - a decoder that expects CRLF will read a lone LF as a data character if it is being strict. (MIME's rule is that decoders must ignore line breaks, which is why email has never suffered from the difference.) The third habit to know: the openssl base64 command - which is the enc program in a trench coat, checking its own name in argv[0] - wraps at 64 without -A and emits one line with -A, and it is the one tool on the command line whose behavior you check per run rather than trust from memory.

Binary in JSON and Config

A JSON string has a small list of characters it cannot contain raw: the quote, the backslash, and the control characters below 0x20. A certificate, a random key, a signature - all of them are full of bytes that would become a cascade of escapes if they tried to ride inside a raw string field, and the control characters would make some parsers choke outright. Base64 is the fix, and it is the default answer every config format that must carry binary gives you: the value is stored as a single line of pure alphabet characters, and the JSON library's quoting rules have nothing left to do.

The C++ pattern is the whole implementation: read the bytes (in binary mode, obviously), encode, store the string. The consumer decodes on the other side. The one JSON-specific trap is the wrapped string: a 76-character-wrapped certificate pasted straight into a JSON file is a string full of literal control characters, which is either a parse error or a silent corruption, depending on the parser's mood. If the value must be wrapped for human eyes, it must be escaped or it must be one line - and for machine-to-machine config, one line is the answer. The other trap is the unlabeled value: a config column that says base64 in a man page from 2014 is usually padded standard alphabet, but tokens from the API era are unpadded URL-safe, and the four-character test from the decoding guide - does it contain + or /? - or _? an = at the end? - is the whole diagnostic.

Data URIs: Files That Paste Into Pages

A data URI is a URL whose payload is right there in the address: data:, an optional media type, an optional ;base64 marker, a comma, and 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 a page keeps working with the network disabled, a data URI is a strong suspect. On the C++ side, the encoding job is to assemble the string, which is string concatenation with one constant:

#include <cstdio>
#include <string>

/* base64_encode from the "Forty Lines, Zero Dependencies" section */

std::string make_data_uri(const std::string &mime_type,
                          const std::string &binary) {
  return "data:" + mime_type + ";base64," + base64_encode(binary);
}

int main() {
  std::printf("%s\n", make_data_uri("text/plain", "hi").c_str());
}

The pitfalls are all in the details. The ;base64 marker is exactly seven characters, which is the length that off-by-one bugs pick on: a parser that checks six is a parser that accepts data:text/plain;base4,... and decodes garbage with a straight face. And a base64 data URI's payload is one line - newlines are not part of the URI grammar, so if your encoder wrapped the image at 76 (and MIME-shaped encoders will, by default), the URI is broken before it reaches the browser. The rule for this consumer: encode, do not wrap, and keep the media type accurate - a wrong image/png on a JPEG is the kind of lie that only shows up as a broken thumbnail at 2 a.m.

Tokens: JWTs, PKCE and API Keys

The highest-stakes base64 on the internet is in a token. A JSON Web Token is three base64url segments glued with dots: a header JSON, a claims JSON, and a signature computed over the string header.claims. C++ has no built-in JWT type, but building one is the base64url encoder from above plus one HMAC call, because the whole token is base64url until it is not - until it is a signature:

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

/* base64_encode and base64url_encode from earlier sections */

std::string jwt_hmac256(const std::string &signing_input,
                        const std::string &secret) {
  unsigned char digest[EVP_MAX_MD_SIZE];
  unsigned int len = 0;
  HMAC(EVP_sha256(), secret.data(), static_cast<int>(secret.size()),
       reinterpret_cast<const unsigned char *>(signing_input.data()),
       signing_input.size(), digest, &len);
  return std::string(reinterpret_cast<const char *>(digest), len);
}

int main() {
  const std::string header_json = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}";
  const std::string claims_json =
      "{\"sub\":\"1234567890\",\"name\":\"John Doe\",\"iat\":1516239022}";
  std::string head = base64url_encode(header_json);
  std::string claims = base64url_encode(claims_json);
  std::string signing_input = head + "." + claims;
  std::string sig = base64url_encode(jwt_hmac256(signing_input, "secret"));
  std::printf("token: %s\n", (signing_input + "." + sig).c_str());
}

Run the example and the token that comes out is the classic HS256 token you will find in JWT documentation everywhere: the header decodes to {"alg":"HS256","typ":"JWT"}, the claims to a subject, a name, and an issued-at timestamp, and the signature is the base64url of an HMAC-SHA256 over the two encoded segments. Three details carry the whole design. The signing input is the encoded segments, not the raw JSON - sign the JSON and you have signed the wrong bytes. The segments are unpadded base64url - the pads would sit in the middle of a URL, and the whole point of the alphabet was to keep the token one clean string. And HS256 means a shared secret, which is a server-to-server algorithm: a secret that lives in a client's code is not a secret, and the token it signs is not a credential. (OAuth's PKCE flow uses the same alphabet one size up: a random verifier, hashed with SHA-256, base64url'd without pads into a code challenge - the encoder from the base64url section is the entire client-side implementation.)

HTTP Basic Auth

The oldest base64 in HTTP is the credentials header: Authorization: Basic followed by the base64 of user:password, a scheme so old it predates JSON. Building it is a concatenation:

#include <cstdio>
#include <string>

/* base64_encode from the "Forty Lines, Zero Dependencies" section */

std::string basic_auth_header(const std::string &user,
                              const std::string &pass) {
  return "Basic " + base64_encode(user + ":" + pass);
}

int main() {
  std::printf("%s\n", basic_auth_header("user", "password").c_str());
}

The output is the string you have probably seen in a captured request: Basic dXNlcjpwYXNzdXdvcmQ=. Two C++ notes. The concatenation user + ":" + pass is where a password containing a colon would confuse a naive parser on the other end - the parsing rule is "split at the first colon", which is why the building side is free to put anything in either field. And if the credentials are not ASCII, the safe reading of the scheme is to treat the user-id and password as UTF-8 before the base64, which in C++ means your std::string is already doing the job - as long as you filled it with UTF-8 bytes and not with whatever the locale decided. The security note belongs with every mention of this scheme: 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. (Boost.Beast's codec - the one from the detail:: namespace - is the same code that builds this header inside Boost's HTTP client, which is the quiet evidence that the pattern has been doing this since 2017.)

Email: Seven-Bit Rules, Base64 Answer

Email is where base64 learned its habits, and the habits are still load-bearing. 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 a CRC checksum glued to the end, 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. 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.

The C++ side is the encoder plus the wrap function from above - encode to one line, wrap at 76 with CRLF, done. The two email-specific details: the final line may or may not carry a trailing newline (decoders are required to ignore it, so either is legal and both are common), and the wrapped value is not a JSON value, an environment variable, or a token - it is a blob that belongs in a MIME body, and moving it anywhere else is where the wrap stops being a habit and starts being a bug. The reverse direction - an attachment arriving wrapped at 76 - is the sister guide's territory, where the four C++ decoders disagree about line breaks in four different ways.

Files, Streams and the Two-Gigabyte Ceiling

Encoding a file is the mirror image of the decoding guide's file work: open in binary mode (on Windows a text-mode read would translate CRLF pairs into single newlines and change your data before the encoder saw it), read the bytes, encode, write in binary. The small-file version is a one-function job:

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

/* base64_encode from the "Forty Lines, Zero Dependencies" section */

std::string encode_file(const std::string &path) {
  std::ifstream in(path, std::ios::binary);
  if (!in) return {};
  std::vector<unsigned char> bytes{std::istreambuf_iterator<char>(in),
                                   std::istreambuf_iterator<char>()};
  return base64_encode(
      std::string(reinterpret_cast<const char *>(bytes.data()), bytes.size()));
}

int main() {
  std::string b64 = encode_file("/etc/hostname");
  std::printf("file -> %zu chars\n", b64.size());
}

The ceiling is the C++-specific fact in the section title: every length parameter in the EVP API is an int. A single EVP_EncodeBlock call can therefore encode at most about 2 GB of input, and the output buffer for that call - 1.33 times bigger - does not fit in an int at all. Below the ceiling, the block API is fine for files that fit in memory. Above it, or for a file you do not want in memory, you chunk - and the chunking rule is the one base64-specific constraint on the loop: chunks must be multiples of 3 bytes, because the grouping is in threes and a chunk boundary in the middle of a group changes the output. 3072, a round 1024 triples, is a comfortable chunk size, and the loop becomes:

#include <algorithm>
#include <cstddef>
#include <string>

/* base64_encode from the "Forty Lines, Zero Dependencies" section */

std::string encode_streamed(const std::string &data) {
  std::string out;
  for (size_t pos = 0; pos < data.size();) {
    size_t take = std::min<size_t>(3072, data.size() - pos);
    out += base64_encode(data.substr(pos, take));
    pos += take;
  }
  return out;
}

Every chunk encodes independently and the concatenation is identical to the one-shot result - which is the property that makes chunking safe at all, and it falls straight out of the 3-byte grouping. (The OpenSSL streaming context from the encoder section does the same job while adding 64-character line wrapping for free, which is the right tool when the consumer wants MIME shape.) And the output side has the same budget as the input side: a 10 GB file becomes a 13.3 GB string, so the buffer - or the file you are writing - is sized with the formula from the math section, and the int ceiling says the chunked path is not a convenience above 2 GB, it is the only path.

Environment Variables and the Command Line

Environment variables have the same problem as JSON strings and a worse answer: they cannot carry NUL bytes at all, and control characters are not their friend either. The standard trick is to base64 the payload so it survives the shell, and in C++ both directions are a one-liner:

#include <cstdio>
#include <cstdlib>
#include <string>

/* base64_encode from the "Forty Lines, Zero Dependencies" section */

int main() {
  setenv("MY_PAYLOAD", base64_encode("hello, env").c_str(), 1);
  std::printf("env: %s\n", getenv("MY_PAYLOAD"));
}

The value that lands in the environment is aGVsbG8sIGVudg==: pure alphabet, safe for the shell, safe for a .env file, safe for a CI dashboard, and decodable on any machine that has a base64 decoder. The command line itself has the same two-tool story as the decoding side, with the encode-direction flags: base64 from coreutils (or the uutils reimplementation that newer distributions ship; check with base64 --version) wraps at 76 by default, and -w 0 gives you one line; openssl base64 - the enc program checking its own name in argv[0] and switching into base64 mode - wraps at 64 and takes -A for a single line:

# one line, for tokens and config
base64 -w 0 < payload.bin > payload.b64
openssl base64 -A < payload.bin > payload.b64

# wrapped, for email and text files
base64 < payload.bin > payload-76.b64
openssl base64 < payload.bin > payload-64.b64

Neither speaks base64url natively, so a token you mint in a shell gets the transcode treatment before it goes into a URL. And the command line is where the encode side's silent-failure habit is most dangerous: an encoder that wrapped when your consumer expected one line will not error, it will just produce a string with newlines in it - which is exactly the failure you are now hunting in production. For anything that matters, encode in your program, where the buffer is sized by the formula and the line shape is a variable you control.

Pitfalls: The C++ Edition

  • The NUL you did not order. EVP_EncodeBlock appends a NUL terminator after the payload. The man page's example: 16 bytes in, 24 encoded plus the NUL, 25 in the buffer, 24 returned. Size for the extra byte and resize to the return value, or your token ends with a zero byte.
  • The hard 64. The OpenSSL streaming API wraps at 64 characters, every block ends in a newline, and there is no flag to change it. Wrapped encoder output in a one-line consumer is a control-character bug.
  • The 48-byte block. EVP_EncodeUpdate only emits output for full 48-byte input blocks; the remainder sits in the context until EVP_EncodeFinal. Budget 65 output bytes per block plus the NUL, and do not read *outl as "bytes of my payload" - it is bytes this call wrote, which for a small first call is zero.
  • The iterator's missing pads. The Boost.Serialization chain never emits =. A 2002 iterator encoding "Mane" gives you five characters. Append the pads yourself, or your strict consumer will refuse the string.
  • The CRLF you did not ask for. CryptBinaryToStringA appends a CR/LF pair unless you pass CRYPT_STRING_NOCRLF. A base64 token built with the default flags is two characters longer than it should be, and the second-to-last character is a carriage return.
  • The NULL call counts the NUL. The Windows size probe returns the required length including the terminating null; the real call hands back the length without it. Mixing the two up is the classic off-by-one, and it writes one byte past the buffer or loses the last character.
  • Wrap after, not during. Line wrapping is a post-processing step on the encoded string. Cut at multiples of the width - always safe, because every 4-character group is self-contained - and never wrap the raw bytes, which is not where line breaks belong.
  • Chunks of three. If you encode a large payload in pieces, the piece boundaries must land on 3-byte groups, or the grouping - and the output - changes. 3072 is a friendly chunk; 3071 is a bug.
  • int, not size_t. Every EVP length parameter is an int. The single-call ceiling is about 2 GB of input, and the output for that input does not fit in an int at all. Above the ceiling, the chunked or streaming path is not a preference.
  • Signed char. If you pack from a char * without the unsigned cast, a byte above 127 is a negative number on platforms where char is signed, and indexing a table with it is undefined behavior. const unsigned char * is not ceremony.
  • The wrapped JSON string. A 76-character-wrapped value pasted into a JSON file is a string of literal control characters. Either it is one line, or it is escaped, or it is not in JSON.
  • Pads are a contract. Some consumers want padding (MIME, most decoders), some do not (JWT, PKCE, tokens in URLs), and a few strict ones reject missing or non-canonical pads outright. The pad is not decoration; it is part of the format agreement.
  • The two alphabets. A - or _ inside a standard-alphabet payload is invalid, and a + or / inside a URL-safe one is invalid. The alphabets are not interchangeable at the byte level - encode with the right one for the destination, and transcode deliberately.
  • std::string and strlen. std::string carries zero bytes happily, but the moment you hand a C string to a legacy API, strlen stops at the first NUL. Pass pointer and length, never a bare pointer.
  • The budget. The output is 4/3 of the input: if the input is 1.5 GB, the output is 2 GB - which is also the int ceiling. Size the receiving buffer, the column, and the wire with the formula, not with a guess.

How C++ Got Its Base64

The format's history is older than the language's modern era, and the C++ story is the story of the language repeatedly not shipping it. 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 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 in the decoding direction and famously unpadded in the encoding direction, a year before RFC 3548 codified the alphabet rules 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. OpenSSL's EVP_EncodeBlock and friends are documented as available in every OpenSSL release, so the workhorse has been in the toolbox as long as the language has been arguing about whether it should be in the standard. On Windows, the story is simply that the operating system shipped it: one function, one flag table, no standard involved at all. 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: an EVP pair, two Boost flavors, a Windows flag, and a forty-line snippet you own.

Oddments Worth Knowing

  • The 48-byte block of the OpenSSL streaming encoder is a number that appears in no RFC. It is 16 base64 groups, chosen so that the output line is exactly 64 characters - the PEM habit - and it is one of the last places where 1987 is still doing load-bearing work in 2026.
  • Boost.Beast's encoded_size is the math section as a constexpr function: 4 * ((n + 2) / 3), evaluated at compile time when you give it a constant. The standard library never got to have this one-liner; Boost shipped it in a detail:: namespace instead.
  • The smallest padded base64 is four characters, QQ==: one byte wearing a two-character costume. The smallest unpadded is two characters, QQ. The pad count is also a message: two pads means the last group had one byte, one pad means it had two, and no pads means it had three - the receiver can recover the input length from the tail alone.
  • MIME's overhead 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. Every encoder in this article pays the same tax; the wrap width only changes how it is billed.
  • 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 encodes to 12 characters and never touches the heap. Your token'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.
  • Four A's - AAAA - encode three zero bytes, because A is the alphabet's zero. If you have ever seen a base64 blob made entirely of one character, now you know what it was saying: nothing.
  • 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".

The Other Direction

Everything you just packed will be unpacked by the same toolbox on the other side, and the unpacking side has its own set of habits: the one-shot OpenSSL function that zero-fills its tail, the 2025 bugfix that changed what the streaming decoder returns for padded input, the Boost.Beast decode that stops at a stray character and never says a word, the iterator that throws at a single space, and the forty-line strict decoder that points at the exact byte that hurt. The full unpacking story - the four decoders' temperaments, base64url transcoding, files, MIME's 76-character habit, and the two command-line tools that fail silently - lives in the C++ decoding guide on the sister site. Go read it, then come back and pack something big. That is the whole game: no standard library, four vendors with four different opinions about newlines and NULs, a formula that sizes every buffer in the article, and one 33 percent tax that every receiver gets to refund. Happy packing.

Last updated: 2026-08-30

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