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 A–Z, a–z, 0–9, + 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.
= 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:
| Option | Where it fits |
|---|---|
OpenSSL EVP_EncodeBlock / EVP_DecodeBlock | Already linked in anything doing TLS. Note EVP_DecodeBlock ignores the padding count, so the caller has to trim trailing bytes itself |
| libb64 | Small, public domain, incremental API for streaming |
glib g_base64_encode | Natural if you are already in a GLib program |
| Your platform’s own | CryptBinaryToStringA 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:
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:
| Compiler | Platform | Standards |
|---|---|---|
| GCC | Ubuntu | C11, C17 |
| Clang | Ubuntu | C11, C17 |
| Apple Clang | macOS | C11 |
| MSVC | Windows | C11 |
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. Acharsubscript 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, notprintf("%s", ...). - Zero-length input is a real case.
0 % 4 == 0, so a length check alone lets it through todata[-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.



