Base64 Encoding and Decoding in C: Working Code and the Bugs to Avoid

The most copied base64 implementation in C has a heap over-read in its own example. Here is a correct one, checked against the RFC 4648 test vectors.

Three blocks re-divided into four narrower blocks, representing base64 regrouping three bytes into four characters

Base64 encoding takes about thirty lines of C, and it is easy to write thirty lines that print the right answer for Hello World! while still reading past the end of a buffer. The difference between an implementation that works and one that only appears to comes down to a handful of details — and they are the same details that matter everywhere in C.

This guide covers what base64 actually does, a correct implementation checked against the RFC 4648 test vectors, the pitfalls that catch most short implementations, and when you should reach for a library instead of writing this at all. The code on this page was compiled and run for this article with GCC 13.3 and Clang 18.1.3 using -std=c11 -Wall -Wextra -pedantic. The complete library lives in a GitHub repository where every commit rebuilds it on GCC, Clang, Apple Clang and MSVC with warnings treated as errors, runs the RFC 4648 test suite on each, and runs it again under AddressSanitizer and UndefinedBehaviorSanitizer. Every warning, sanitizer report and output block is captured verbatim.

What Is Base64 Encoding?

Base64 is a way of representing arbitrary binary data using only 64 printable ASCII characters, so that data can pass through systems that expect text. It works by regrouping bits: three input bytes, twenty-four bits, become four six-bit values, each of which indexes into a fixed alphabet of AZ, az, 09, + and /. Large inputs therefore grow by about a third; small ones grow by more, because output always rounds up to a whole group of four characters — a single byte becomes four. When the input length is not a multiple of three, the final group is padded with = so the output length stays a multiple of four. The alphabet, the padding rules and the standard test vectors are defined by RFC 4648.

Base64 is not encryption. It uses no key, hides nothing, and anyone can reverse it in one command. It exists to make binary data survive a text-only channel — email attachments via MIME, data: URLs, JSON string fields, HTTP basic auth headers.

Three bytes in, four characters out Twenty-four bits regrouped from three groups of eight into four groups of six. input bytes M a n 77 97 110 as bits 01001101 01100001 01101110 regrouped by six 010011 010110 000101 101110 as numbers 19 22 5 46 output T W F u index into the 64-character alphabet Output is always 4/3 the size of the input, rounded up to a multiple of four. When the input does not divide by three, padding fills the gap "foo" → Zm9v 3 bytes, no padding needed "fo" → Zm8= 2 bytes, one = ; "f" → Zg== , two = Base64 is an encoding, not encryption — anyone can reverse it. The alphabet and the padding rules are defined by RFC 4648, whose test vectors this article's implementation is checked against.
Base64 regroups bits, it does not hide them. Twenty-four bits of input become four six-bit indexes into a fixed alphabet, so large inputs grow by about a third — small ones by more, since the output rounds up to a whole group of four — and the result is trivially reversible by anyone. Inputs that are not a multiple of three bytes are padded with = so the length stays a multiple of four.

A Correct Implementation

Four details separate a base64 implementation that works from one that only appears to: the encoder must write a terminating NUL, lookup tables must be indexed with unsigned char so a byte above 127 cannot produce a negative subscript, a zero-length input must be handled before anything reads in[len - 1], and characters outside the alphabet must be rejected rather than decoded. The implementation below handles each one, and the test suite checks all four.

The fixes are structural rather than clever. The library allocates nothing, keeps no global state, needs no cleanup call, and tells the caller how much space to provide:

/* base64.h */
#define B64_ERROR ((size_t)-1)

size_t b64_encoded_size(size_t n);   /* includes the terminating NUL */
size_t b64_decoded_size(size_t n);

size_t b64_encode(const unsigned char *in, size_t len,
                  char *out, size_t out_size);
size_t b64_decode(const char *in, size_t len,
                  unsigned char *out, size_t out_size);

B64_ERROR is SIZE_MAX, which is safe to use as a sentinel because no real object can be that large, so no successful call can return it. A NULL input pointer is accepted only for a zero-length input; anything longer needs a real buffer.

The encoder:

static const char ENCODE[65] =
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

/* Count groups without computing n + 2, which wraps for n near SIZE_MAX.
   On overflow, return B64_ERROR - which is SIZE_MAX, a size no allocation
   can satisfy, so a caller who ignores the check still fails safely. */
size_t b64_encoded_size(size_t n)
{
    const size_t groups = n / 3 + (n % 3 != 0);
    if (groups > (SIZE_MAX - 1) / 4)
        return B64_ERROR;
    return groups * 4 + 1;
}

size_t b64_encode(const unsigned char *in, size_t len,
                  char *out, size_t out_size)
{
    if (out == NULL || (in == NULL && len > 0))
        return B64_ERROR;
        const size_t need = b64_encoded_size(len);
    if (need == B64_ERROR || out_size < need)
        return B64_ERROR;

    size_t j = 0;
    for (size_t i = 0; i < len; i += 3) {
        const unsigned a = in[i];
        const unsigned b = (i + 1 < len) ? in[i + 1] : 0u;
        const unsigned c = (i + 2 < len) ? in[i + 2] : 0u;
        const unsigned triple = (a << 16) | (b << 8) | c;

        out[j++] = ENCODE[(triple >> 18) & 0x3F];
        out[j++] = ENCODE[(triple >> 12) & 0x3F];
        out[j++] = (i + 1 < len) ? ENCODE[(triple >> 6) & 0x3F] : '=';
        out[j++] = (i + 2 < len) ? ENCODE[triple & 0x3F]        : '=';
    }

    out[j] = '\0';
    return j;
}

Four details in the encoder are deliberate. b64_encoded_size adds one for the NUL, and the function writes it. The caller’s buffer size is checked rather than assumed. size_t throughout, so no signedness warnings and no truncation. And padding is decided by position during the loop rather than patched in afterwards, which is both clearer and one fewer place to get the arithmetic wrong.

The size helper deserves a note of its own, because it is the same class of problem the rest of this code avoids. The obvious formula, 4 * ((n + 2) / 3) + 1, is right for every size you will realistically meet and silently wrong past about three quarters of SIZE_MAX, where it returns 1. Counting groups first avoids the wrap, and returning B64_ERROR on overflow means a caller who hands the result straight to malloc gets a failed allocation rather than a one-byte buffer. b64_decoded_size needs no such guard: it divides before it multiplies, so its result is always smaller than its input.

The decoder’s important change is that it refuses bad input:

/* Reverse lookup, built at compile time rather than lazily at run time.
   Indexed by unsigned char, so a byte >= 0x80 cannot produce a negative
   subscript. VALID says whether a character is in the alphabet at all,
   which is what stops an invalid byte quietly decoding to zero. */
static const unsigned char DECODE[256] = {
    ['A']= 0,['B']= 1,['C']= 2,['D']= 3,['E']= 4,['F']= 5,['G']= 6,['H']= 7,
    ['I']= 8,['J']= 9,['K']=10,['L']=11,['M']=12,['N']=13,['O']=14,['P']=15,
    ['Q']=16,['R']=17,['S']=18,['T']=19,['U']=20,['V']=21,['W']=22,['X']=23,
    ['Y']=24,['Z']=25,['a']=26,['b']=27,['c']=28,['d']=29,['e']=30,['f']=31,
    ['g']=32,['h']=33,['i']=34,['j']=35,['k']=36,['l']=37,['m']=38,['n']=39,
    ['o']=40,['p']=41,['q']=42,['r']=43,['s']=44,['t']=45,['u']=46,['v']=47,
    ['w']=48,['x']=49,['y']=50,['z']=51,['0']=52,['1']=53,['2']=54,['3']=55,
    ['4']=56,['5']=57,['6']=58,['7']=59,['8']=60,['9']=61,['+']=62,['/']=63,
};

static const unsigned char VALID[256] = {
    ['A']=1,['B']=1,['C']=1,['D']=1,['E']=1,['F']=1,['G']=1,['H']=1,
    ['I']=1,['J']=1,['K']=1,['L']=1,['M']=1,['N']=1,['O']=1,['P']=1,
    ['Q']=1,['R']=1,['S']=1,['T']=1,['U']=1,['V']=1,['W']=1,['X']=1,
    ['Y']=1,['Z']=1,['a']=1,['b']=1,['c']=1,['d']=1,['e']=1,['f']=1,
    ['g']=1,['h']=1,['i']=1,['j']=1,['k']=1,['l']=1,['m']=1,['n']=1,
    ['o']=1,['p']=1,['q']=1,['r']=1,['s']=1,['t']=1,['u']=1,['v']=1,
    ['w']=1,['x']=1,['y']=1,['z']=1,['0']=1,['1']=1,['2']=1,['3']=1,
    ['4']=1,['5']=1,['6']=1,['7']=1,['8']=1,['9']=1,['+']=1,['/']=1,
};

Both tables are static const, built at compile time. There is no lazy initialisation, which also means no thread-safety problem — a table built on first use, from more than one function, would need synchronisation to be safe.

The whole decoder:

size_t b64_decode(const char *in, size_t len,
                  unsigned char *out, size_t out_size)
{
    if (in == NULL || out == NULL)
        return B64_ERROR;
    if (len == 0)
        return 0;                       /* empty input, empty output */
    if (len % 4 != 0)
        return B64_ERROR;

    size_t pad = 0;
    if (in[len - 1] == '=') pad++;
    if (pad == 1 && in[len - 2] == '=') pad++;

    const size_t produced = len / 4 * 3 - pad;
    if (out_size < produced)
        return B64_ERROR;

    size_t j = 0;
    for (size_t i = 0; i < len; i += 4) {
        unsigned quad[4];
        int seen_pad = 0;

        for (int k = 0; k < 4; ++k) {
            const unsigned char ch = (unsigned char)in[i + k];

            if (ch == '=') {
                /* Padding is legal only in the last group, and only in
                   its third or fourth position. */
                if (i + 4 != len || k < 2)
                    return B64_ERROR;
                seen_pad = 1;
                quad[k] = 0;
                continue;
            }
            /* Once padding has started, nothing but padding may follow. */
            if (seen_pad)
                return B64_ERROR;
            if (!VALID[ch])
                return B64_ERROR;
            quad[k] = DECODE[ch];
        }

        /* Reject non-canonical encodings. The bits a padded group does not
           use must be zero - otherwise "Zh==" and "Zg==" would both decode
           to "f", and two different strings would mean the same bytes. */
        if (i + 4 == len) {
            if (pad == 2 && (quad[1] & 0x0F) != 0) return B64_ERROR;
            if (pad == 1 && (quad[2] & 0x03) != 0) return B64_ERROR;
        }

        const unsigned triple = (quad[0] << 18) | (quad[1] << 12)
                              | (quad[2] <<  6) |  quad[3];

        if (j < produced) out[j++] = (unsigned char)((triple >> 16) & 0xFF);
        if (j < produced) out[j++] = (unsigned char)((triple >>  8) & 0xFF);
        if (j < produced) out[j++] = (unsigned char)( triple        & 0xFF);
    }

    return j;
}

The check just before the arithmetic enforces canonical encoding. A padded group leaves some bits unused — four of them when two = signs follow, two when one does — and RFC 4648 requires them to be zero. Without that check, four different strings would all decode to the single byte f:

Zg==   -> accepted, 1 byte(s): 66
Zh==   -> accepted, 1 byte(s): 66
Zi==   -> accepted, 1 byte(s): 66
Zv==   -> accepted, 1 byte(s): 66

Only Zg== is the canonical encoding. That matters whenever anything compares the encoded form rather than the decoded bytes — a cache key, a token equality check — because two strings that mean the same thing can then be used to get around it. With the check, Zh==, Zi== and Zv== are all refused.

That seen_pad flag exists because my first version failed its own test suite. I had checked that padding only appears in the last group’s third or fourth position, which correctly rejects A=AA and =AAA — but happily accepted Zm9vYg=A, where real data follows the padding. The test caught it; I would not have.

Using It

#include "base64.h"

const char *text = "Hello World!";
const size_t len = strlen(text);

size_t need = b64_encoded_size(len);
if (need == B64_ERROR) { /* input too large to encode */ }

char *encoded = malloc(need);
if (encoded == NULL) { /* out of memory */ }

size_t n = b64_encode((const unsigned char *)text, len, encoded, need);
if (n == B64_ERROR) { /* buffer too small */ }

printf("%s\n", encoded);   /* safe: b64_encode wrote the NUL */
free(encoded);

Decoding, with the detail that matters:

size_t need = b64_decoded_size(strlen(encoded));
unsigned char *decoded = malloc(need ? need : 1);   /* malloc(0) may return NULL */
if (decoded == NULL) { /* out of memory */ }

size_t n = b64_decode(encoded, strlen(encoded), decoded, need);
if (n == B64_ERROR) { /* not valid base64 - do not use the buffer */ }

fwrite(decoded, 1, n, stdout);   /* NOT printf("%s", ...) */
free(decoded);

Decoded output is binary and is not NUL-terminated. Base64 encodes arbitrary bytes, including zero bytes, so treating the result as a string is wrong in general even when it happens to be text. Use the returned length. The same applies going in: the strlen(text)above works because this example’s input is a C string, but for binary input — an image, a key —strlenstops at the first zero byte, and you must pass the real length. That is also why the encoder takesconst unsigned char *: the input is a sequence of bytes, not a string.

The command-line front end:

$ ./base64-demo encode "Hello World!"
SGVsbG8gV29ybGQh

$ ./base64-demo decode "SGVsbG8gV29ybGQh"
Hello World!

$ ./base64-demo decode "!!!!"
not valid base64

Tested Against RFC 4648

The specification publishes test vectors, which makes this one of the rare cases where you can check an implementation against something other than itself:

RFC 4648 test vectors
  encode("") == ""                                           ok
  encode("f") == "Zg=="                                      ok
  encode("fo") == "Zm8="                                     ok
  encode("foo") == "Zm9v"                                    ok
  encode("foob") == "Zm9vYg=="                               ok
  encode("fooba") == "Zm9vYmE="                              ok
  encode("foobar") == "Zm9vYmFy"                             ok

Round trip over every byte value
  all 256 byte values survive a round trip                   ok

Invalid input is rejected
  length not a multiple of four                              ok
  characters outside the alphabet                            ok
  bytes >= 0x80                                              ok
  padding before the end of the last group                   ok
  padding in the first position                              ok
  data after padding                                         ok
  non-canonical padding bits (one byte)                      ok
  non-canonical padding bits (two bytes)                     ok

Buffer sizing
  encode refuses a buffer that is too small                  ok
  decode refuses a buffer that is too small                  ok
  encoded_size(0) leaves room for the NUL                    ok
  encoded_size reports overflow instead of wrapping          ok
  encoded_size still answers for large in-range sizes        ok
  encoded_size reports overflow just past three quarters     ok

all checks passed

A round trip alone would not have been enough. It proves the two directions are inverses, which they can be while both being wrong — the vectors check the implementation against the standard, and the rejection tests check that it fails when it should.

When Not to Write This Yourself

Encoding is thirty lines. Getting the validation right, keeping it thread-safe and staying fast on large inputs is more work than it looks. The implementation on this page is written for clarity and correctness and has not been optimised or benchmarked; libraries that use SIMD instructions will be faster on large inputs. Several have done the work already:

OptionWhere it fits
OpenSSL EVP_EncodeBlock / EVP_DecodeBlockAlready linked in anything doing TLS. Note EVP_DecodeBlock ignores the padding count, so the caller has to trim trailing bytes itself
libb64Small, public domain, incremental API for streaming
glib g_base64_encodeNatural if you are already in a GLib program
Your platform’s ownCryptBinaryToStringA on Windows, NSData on Apple platforms

Use a library when base64 is incidental to your program, you are handling untrusted input, or you need streaming for data larger than memory.

Write it yourself when you are learning how it works, you are on an embedded target where a dependency is expensive, or you need a variant nothing ships — base64url, for instance, which swaps + and / for - and _ and many Base64URL applications omit omit = padding — two changes to the alphabet and one to the padding rules.

Whichever you pick, run the RFC 4648 vectors against it.

Complete Source Code and Tests

Everything above lives in the MYCPLUS C examples repository, under encoding/base64:

Base64 build
encoding/base64/
├── CMakeLists.txt
├── README.md
├── include/
│   └── base64.h
├── src/
│   ├── base64.c          the library
│   └── base64-demo.c     command line front end
└── tests/
    └── test_base64.c     RFC 4648 vectors and validation tests

Build it with CMake and run the tests:

git clone https://github.com/mycplus/c-examples.git
cd c-examples/encoding/base64
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure

Or straight from the compiler:

cc -std=c11 -Wall -Wextra -pedantic -Iinclude \
   src/base64.c tests/test_base64.c -o test_base64 && ./test_base64

And on Windows, from a Developer Command Prompt:

cl /nologo /TC /W4 /WX /Iinclude src\base64.c src\base64-demo.c /Fe:base64-demo.exe

What the build checks

Every push rebuilds the library across four toolchains, with warnings treated as errors so a warning fails the build rather than scrolling past:

CompilerPlatformStandards
GCCUbuntuC11, C17
ClangUbuntuC11, C17
Apple ClangmacOSC11
MSVCWindowsC11

Unlike a networking example, base64 needs no privileges and no network, so the build can check behaviour rather than just compilation. Each job runs the full RFC 4648 test suite, then drives the command-line demo: it encodes Hello World! and compares the result byte for byte, decodes it back, and confirms that invalid input is refused with the right exit status rather than decoded. Separate jobs build through CMake with ctest, and run the whole suite under AddressSanitizer and UndefinedBehaviorSanitizer.

The badge above is live. Green means every one of those checks passed on every toolchain against the current code.

Key Takeaways

  • Base64 regroups three bytes into four characters, expanding the data by a third. It is an encoding, not encryption, and carries no key.
  • The encoder must write a terminating NUL if callers will use %s. Without it, printing the result with %s reads past the end of the buffer.
  • Index lookup tables with unsigned char. A char subscript goes negative for bytes above 127 and reads before the table.
  • Reject invalid input rather than decoding it. An uninitialised lookup table turns bad characters into silent garbage the caller cannot detect.
  • Decoded output is binary and not NUL-terminated. Use the returned length and fwrite, not printf("%s", ...).
  • Zero-length input is a real case. 0 % 4 == 0, so a length check alone lets it through to data[-1].
  • Test against RFC 4648’s vectors, not just a round trip. A round trip passes when both directions are wrong in the same way.
  • Prefer a library when base64 is incidental or the input is untrusted.

Frequently Asked Questions

Conclusion

Base64 is a good exercise precisely because it looks finished before it is. Thirty lines gets you output that matches the expected string for Hello World!, and it is tempting to stop there.

What separates the two versions here is not cleverness. It is the NUL, the bounds check, the unsigned char subscript and the refusal to decode input that is not base64. All four are dull, and all four are what the test suite is for. The rest of our C programming guides take the same approach, and the Vigenère cipher article covers the other half of the confusion this page opened with — why a cipher that really does use a key still is not protection.

Scroll to Top