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: A Complete Guide

You have bytes. Maybe they are a JPEG read from disk, maybe a token, maybe a password a client is about to transmit, maybe the raw bytes of a file some pipeline expects inside a JSON field. And somewhere down the road there is a channel that only speaks text: a JSON string, a URL, an email body, a config file, a database column that pretends to be text. That is where Base64 encoding comes in: it rewrites every three bytes of raw data as four characters from a 64-letter alphabet, so the result is plain ASCII that survives any text pipeline on earth. The home page of this site explains the format in full; this article is about doing the job well in C, where - as usual - the language will not do it for you.

The one number to keep in your head: encoding grows your data. Three bytes become four characters, so every payload leaves your program about a third larger, plus a little more whenever line breaks are added. That is the tax, and there is no way around it - but in C the tax has a line item, because you allocate the output buffer yourself and the buffer must be exactly big enough. Get the math right once and every encoder in this article becomes predictable: no overflows, no underflows, no wondering where the next byte will land. Then there is the toolbox to choose from - OpenSSL, Mbed TLS, APR-Util, GLib - and the choice matters, because each one wraps differently, terminates differently, and errors differently.

Four Encoders, Four Personalities

All four libraries encode the standard alphabet correctly and identically - same bytes in, same characters out, always. The differences are in the packaging, and the packaging is where interoperability bugs hide. Here is the landscape:

Library Header Output style Failure mode
OpenSSL (libcrypto) <openssl/evp.h> No newlines; writes a NUL terminator Effectively none (allocation only)
Mbed TLS <mbedtls/base64.h> No newlines; NUL-terminated Buffer-too-small code with needed size
APR-Util <apr-1.0/apr_base64.h> No newlines; appends a NUL None - trust your buffer size
GLib <glib.h> No newlines; NUL-terminated, heap-allocated Returns NULL (allocation only)

Notice what is missing from the table: none of them wraps lines by default. That is deliberate - RFC 4648 says implementations must not add line feeds unless the surrounding specification explicitly asks for them - and it is a relief, because a stray newline inside a JSON string or a URL is an error, not a feature. Wrapping exists for email and PEM, and when you need it you get it from OpenSSL's streaming path or you wrap yourself in five lines (the email section shows both). For picking a library: use OpenSSL if you already link it, Mbed TLS for embedded builds where every kilobyte is argued about, APR-Util inside the Apache ecosystem, and GLib when the rest of your program is already GLib. Installation: libssl-dev (Debian/Ubuntu) or openssl-devel (Fedora/RHEL) or brew install openssl (macOS); libmbedtls-dev for Mbed TLS; libaprutil1-dev plus libapr1-dev for APR-Util; glib2.0-dev for GLib.

Do The Math Before You Allocate

Before any code, the arithmetic, because C will not save you from a too-small buffer. Every three input bytes produce exactly four output characters. If the input length is not a multiple of three, the final group still produces four characters and the unused slots are marked with = pads: one input byte becomes four characters with two pads, two input bytes become four characters with one pad. So the exact encoded length for n bytes is:

size_t encoded_chars(size_t n) {
  return ((n + 2) / 3) * 4;
}

For 1000 bytes that is 1336 characters; for 1 byte it is 4; for 0 it is 0. Two adjustments follow from that. First, OpenSSL and Mbed TLS both append a NUL terminator after the data (and Mbed TLS reserves the space for it when you ask for the size), so your buffer wants one extra byte: encoded_chars(n) + 1. Second, if you want line-wrapped output, add one newline per line: OpenSSL's streaming encoder emits a 64-character line for every 48 input bytes, so the wrapped length is encoded_chars(n) + (n + 47) / 48. Verify with 1000 bytes: 1336 characters plus 21 newlines is 1357, and that is exactly what the encoder produces. Write the formula once as a function and use it everywhere; it is the difference between "it fits" and a heap corruption at 3 AM.

size_t b64_buffer_size(size_t in_len) {
  return ((in_len + 2) / 3) * 4 + 1; /* characters + NUL */
}
size_t b64_buffer_size_wrapped(size_t in_len) {
  return ((in_len + 2) / 3) * 4 + (in_len + 47) / 48 + 1;
}

OpenSSL: One Block Or A Running Tap

OpenSSL's one-shot function is the workhorse, and it is the friendliest of the bunch:

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  const char *text = "Mane";
  unsigned char out[32];
  int n = EVP_EncodeBlock(out, (const unsigned char *)text,
                          (int)strlen(text));
  printf("len=%d str=%s\n", n, (char *)out);
  return 0;
}

It writes the encoded characters to out, appends a NUL after them, and returns the length without the NUL - so %s printing is safe and the length is available if you need it. The output buffer must hold encoded_chars(n) + 1 bytes. There is no error path to handle: encoding cannot fail, because any byte is legal input, and the function has no input-validation concept to trip on. The only way it can go wrong is you giving it a buffer that is too small, and the math section is the antidote.

The streaming pair is for when the data is big or arrives in pieces. EVP_EncodeUpdate processes input in 48-byte blocks and writes 64 characters plus a newline (65 bytes) per complete block, holding any remainder in the context until more data or the final call:

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
  if (ctx == NULL) {
    return 1;
  }
  EVP_EncodeInit(ctx);
  unsigned char in[1000];
  for (int i = 0; i < 1000; i++) {
    in[i] = (unsigned char)(i % 251);
  }
  unsigned char out[1400]; /* 1336 chars + 21 newlines + room */
  int outl = 0;
  int total = 0;
  EVP_EncodeUpdate(ctx, out + total, &outl, in, 1000);
  total += outl;
  EVP_EncodeFinal(ctx, out + total, &outl);
  total += outl;
  int nl = 0;
  for (int i = 0; i < total; i++) {
    if (out[i] == '\n') nl++;
  }
  printf("encoded 1000 bytes into %d chars, %d newlines\n",
      total, nl);
  EVP_ENCODE_CTX_free(ctx);
  return 0;
}

The output of that program is 1357 bytes with 21 line breaks - the formula from the math section, made real. Two practical notes. The version note: in OpenSSL 3.x the context type is opaque, so allocate with EVP_ENCODE_CTX_new() and free with EVP_ENCODE_CTX_free(); the older stack pattern EVP_ENCODE_CTX ctx; you will find in tutorials does not compile against modern headers. And the design note: because only complete 48-byte blocks are emitted from EVP_EncodeUpdate, the cleanest chunked pipeline feeds it multiples of 48 - then every line the function writes is a finished line, and EVP_EncodeFinal alone decides how the tail is wrapped. If your input arrives in arbitrary sizes (a network read), the context still handles the alignment for you; the multiple-of-48 habit is just what makes the output predictable.

Mbed TLS: Ask, Then Encode, Get A String

Mbed TLS's encode has the cleanest contract in the group, built around a size query you can call with a NULL destination:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <mbedtls/base64.h>
int main(void) {
  const char *text = "Mane";
  size_t slen = strlen(text);
  size_t needed = 0;
  int rc = mbedtls_base64_encode(NULL, 0, &needed,
      (const unsigned char *)text, slen);
  if (rc != PSA_ERROR_BUFFER_TOO_SMALL) {
    printf("size query failed: %d\n", rc);
    return 1;
  }
  printf("needs %zu bytes\n", needed);
  unsigned char *out = malloc(needed);
  size_t olen = 0;
  rc = mbedtls_base64_encode(out, needed, &olen,
      (const unsigned char *)text, slen);
  if (rc != 0) {
    printf("encode failed: %d\n", rc);
    free(out);
    return 1;
  }
  printf("olen=%zu str=%s\n", olen, out);
  free(out);
  return 0;
}

Read the details carefully, because they are a masterclass in a friendly API. The size query reports needed as the encoded characters plus one for the NUL - for "Mane" that is 8 plus 1, i.e. 9 - and it signals itself with the "buffer too small" code (PSA_ERROR_BUFFER_TOO_SMALL, which is -0x002A) because a NULL destination is, by definition, too small. The real call then writes the characters and the NUL, and *olen comes back as 8 - the length without the terminator - so the buffer is already a printable C string. If you hand it a buffer that is one byte short, you get the same "too small" code back with the required size in *olen, so the failure tells you exactly how much you missed by. One more note: the library does its table lookups through constant-time helpers, a small touch of care you will not see in most encoders.

APR-Util And GLib: The Other Two

APR-Util's encoder is a plain pair of functions with int lengths:

#include <stdio.h>
#include <string.h>
#include <apr-1.0/apr_base64.h>
int main(void) {
  const char *text = "Mane";
  int needed = apr_base64_encode_len((int)strlen(text));
  char *out = malloc((size_t)needed);
  int n = apr_base64_encode(out, text, (int)strlen(text));
  printf("n=%d (includes NUL) str=%s\n", n, out);
  free(out);
  return 0;
}

Here apr_base64_encode_len() and the return value both count the NUL, so n is one more than the character count - a bookkeeping difference from OpenSSL and Mbed TLS that has produced off-by-one bugs in more than one codebase. The same 32-bit length limit applies: for values near or above 2 GB, this is not the tool. There is also apr_base64_encode_binary(), which on EBCDIC machines skips the input's EBCDIC-to-ASCII conversion - on the mainframes where that conversion would otherwise happen, and a no-op difference everywhere else. Newer apr-util releases add pool-allocated shortcuts (apr_pbase64_encode); if your header does not have them, you are on an older release and the malloc version above is the whole story.

GLib's encoder is the heap-allocated style - you get a NUL-terminated string and a duty:

#include <stdio.h>
#include <glib.h>
int main(void) {
  const char *text = "Mane";
  gchar *enc = g_base64_encode((const guchar *)text, strlen(text));
  printf("%s\n", enc);
  g_free(enc);
  return 0;
}

No wrapping, NUL-terminated, free it with g_free - the G_GNUC_MALLOC annotation on the prototype is what tells static analyzers that. When you do want line breaks, the incremental pair is the tool: g_base64_encode_step() takes a state integer and a break_lines flag and tells you how many output bytes it wrote, and g_base64_encode_close() finishes the final partial group. That is the same state-machine shape as OpenSSL's streaming pair, just with GLib's parameter style.

Hand-Made URL-Safe Base64

The four libraries above all speak the standard alphabet: A-Z, a-z, 0-9, plus, and slash. The web, however, increasingly speaks the second dialect from RFC 4648 section 5, called base64url: the same encoding with + swapped for - and / swapped for _, and the trailing = padding dropped when the length is known. JSON Web Tokens, OAuth state parameters, and countless API IDs use it, because + and / are both hazardous in URLs while - and _ are unreserved characters that sail through. Since no C library emits this dialect natively, you make it yourself - and it is two lines, because you already have a standard-alphabet encoder:

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
static void to_base64url(const char *std_b64, char *out, size_t out_cap) {
  size_t i = 0;
  for (const char *p = std_b64; *p && *p != '='; p++) {
    char c = *p;
    if (c == '+') c = '-';
    if (c == '/') c = '_';
    out[i++] = c;
  }
  out[i] = '\0'; /* padding intentionally dropped */
}

Usage is a two-step - encode standard, then translate:

unsigned char enc[32];
EVP_EncodeBlock(enc, (const unsigned char *)"hi>there", 8);
char url_safe[32];
to_base64url((const char *)enc, url_safe, sizeof(url_safe));
printf("%s\n", url_safe); /* aGk-dGhlcmU */

Two cautions. The loop stops at the first =, which is what drops the padding - do not "fix" that, dropping the padding is the point (the receiver who needs it can re-add it from the length). And size out for the full encoded length, not fewer: the translation is character-for-character until the pads, so the capacity you already allocated for the standard form is exactly right. One honest note for interoperability: if your data happens to contain no bytes that map to + or /, the standard and URL-safe forms are identical and nothing will ever complain about a mix-up - the bug only surfaces when the data finally contains one. Treat the dialect as a property of the channel (URLs, tokens), not of the data.

Text And Character Sets: UTF-8 Is Just Bytes

A question that surprises C newcomers: what happens to accented text, emoji, CJK characters? The answer is the most freeing fact in this article - nothing has to happen. Base64 operates on bytes, and C is a language of bytes. If your text is UTF-8 (which, in 2026, it probably is), the UTF-8 encoding of "café" is five bytes - 63 61 66 c3 a9 - and Base64 encodes those five bytes exactly like any other five bytes, producing Y2Fmw6k=. No charset parameter, no BOM, no conversion step, no library call. The codec does not know or care what the bytes mean; that is the entire design.

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  const char *utf8 = "caf\303\251"; /* café in UTF-8 */
  unsigned char enc[32];
  int n = EVP_EncodeBlock(enc, (const unsigned char *)utf8,
                          (int)strlen(utf8));
  printf("%.*s\n", n, (char *)enc); /* Y2Fmw6k= */
  return 0;
}

Two traps sit at the edges of this section. The first is wchar_t: if your data arrived as wide characters, you must first convert it to a byte sequence (on Linux, UTF-8, via wcstombs() or your locale machinery) before encoding - Base64 of a wchar_t array is the encoding of an internal representation, not of the text, and it will differ between platforms. The second is source encoding: the string literal in your C file is encoded in the source file's encoding (UTF-8 in any modern project), so writing "café" directly works as long as the file really is UTF-8 and your compiler is told so (it is, by default, in modern toolchains). Encode the bytes you mean to send, and let the receiver deal with what the bytes mean.

Images: From A Buffer To A String

The most common "real" encoding job in web C: a binary file - a JPEG, a PNG, an icon - needs to travel through a text channel, so it becomes a Base64 string. The recipe is read the file into a buffer, size the output with the formula, encode, and move on. The file-reading half deserves care, because it is where C programs actually break:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <openssl/evp.h>
int main(void) {
  FILE *f = fopen("photo.png", "rb");
  if (f == NULL) {
    return 1;
  }
  fseek(f, 0, SEEK_END);
  long size = ftell(f);
  fseek(f, 0, SEEK_SET);
  unsigned char *data = malloc((size_t)size);
  size_t got = fread(data, 1, (size_t)size, f);
  fclose(f);
  size_t out_cap = ((got + 2) / 3) * 4 + 1;
  unsigned char *enc = malloc(out_cap);
  int n = EVP_EncodeBlock(enc, data, (int)got);
  printf("png of %zu bytes becomes %d base64 chars\n", got, n);
  free(data);
  free(enc);
  return 0;
}

Notes: rb for reading binary - non-negotiable on any platform, because text mode can translate bytes and change got; the fseek/ftell pair for sizing (for pipes and sockets without seek, read into a growing buffer instead); and got rather than size for the encode, because a short read is a real possibility. A 1 MB image becomes about 1.33 MB of text - that is the tax, billed in advance, and it is why a base64-in-JSON image payload should make you pause and ask whether an actual file upload would have been cheaper.

Files And The .b64 Habit

The opposite direction of the image job: you need to write a file's Base64 form to disk - a .b64 sidecar, a backup of a binary in a text-safe store, an attachment for a mailer. Same math, different writer. The habit worth adopting is to write the text output with explicit line breaks at a length the receiving side expects - 76 for email, 64 for PEM-style consumers, or none at all if the receiver is your own code:

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  FILE *f = fopen("data.bin", "rb");
  if (f == NULL) {
    return 1;
  }
  fseek(f, 0, SEEK_END);
  long size = ftell(f);
  fseek(f, 0, SEEK_SET);
  unsigned char *data = malloc((size_t)size);
  size_t got = fread(data, 1, (size_t)size, f);
  fclose(f);
  unsigned char *enc = malloc(((got + 2) / 3) * 4 + 1);
  int n = EVP_EncodeBlock(enc, data, (int)got);
  FILE *out = fopen("data.b64", "w");
  for (int i = 0; i < n; i += 76) {
    int chunk = i + 76 < n ? i + 76 : n;
    fwrite(enc + i, 1, (size_t)(chunk - i), out);
    fputc('\n', out);
  }
  fclose(out);
  free(data);
  free(enc);
  return 0;
}

The loop writes 76-character lines and a final shorter line; a decoder that skips whitespace (every serious one does) will not care about the line length at all, which is why the receiver is the one to consult, not your taste. Keep the output file in text mode (w) on the writing side if you want platform line endings, or wb if the receiver counts characters strictly - and if it counts, it wants exactly what you promised: 76 characters plus one line break, nothing else. That promise, not the bytes, is what makes a .b64 file a format.

Data URIs: Embedding For Real

Data URIs (RFC 2397) are the other side of the decode article's favorite arrival: instead of receiving data:image/png;base64,..., you build one. The shape is data:, the media type, ;base64, a comma, the payload - and building it in C is one snprintf after the encode:

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  /* the 8-byte PNG signature */
  const unsigned char png_sig[8] =
    { 0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n' };
  unsigned char enc[32];
  int n = EVP_EncodeBlock(enc, png_sig, 8);
  char uri[96];
  snprintf(uri, sizeof(uri), "data:image/png;base64,%.*s",
      n, (char *)enc);
  printf("%s\n", uri);
  return 0;
}

Three design notes. The media type you put in the URI is a claim you are responsible for - sniff the real file's magic bytes first, or a data:image/png carrying a JPEG will confuse every consumer in a different way. The ;base64 flag is mandatory when the payload is Base64; omit it and the payload must be percent-encoded text instead, which is a different format entirely. And the RFC's own guidance is that data URIs are for short values: embedding a 5 MB logo inline in an HTML page works, but it is a design smell that a real asset URL would fix. The same construction appears all the time in JSON APIs where a client wants an avatar in the same request as the form data - encode, prepend, send.

HTTP And JSON: Payloads That Survive

The biggest modern reason to encode in C is JSON. A JSON string is a sequence of characters with escaping rules, and raw bytes do not fit: a NUL in the middle of a string literal is a C problem, a literal line break inside a JSON string is invalid JSON, and arbitrary bytes need a defined escape story. Base64 sidesteps the whole problem by producing only characters that JSON never has to escape - the 64 alphabet characters plus, in the standard dialect, =, none of which are quotes or backslashes. The binary goes in as a string and comes out on the other side exactly as it went in:

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  unsigned char enc[64];
  int n = EVP_EncodeBlock(enc, (const unsigned char *)"hello", 5);
  char json[160];
  snprintf(json, sizeof(json),
      "{\"avatar\": \"%.*s\"}", n, (char *)enc);
  printf("%s\n", json);
  return 0;
}

That prints {"avatar": "aGVsbG8="} - a complete, valid JSON object, no escaping machinery involved, and the %.*s precision keeps the length exact even if you ever switch to a decoder that does not NUL-terminate. The honest cost is the size: every byte you ship as a JSON string costs you 4/3 of a byte of air plus the field name and quotes, so a 10 KB binary becomes a 13.3 KB string inside the JSON. For occasional small blobs (icons, thumbnails, signatures, tokens) that is a fine price; for a 500 MB upload it is an architecture you will regret, and a real file upload is the tool for that job. Also worth a line: the standard alphabet's + and / are safe inside a JSON string, but if the same string later travels in a URL query, they are not - that is the URL-safe section's job.

JWTs: Three Parts, One Alphabet

The flagship consumer of base64url in C is the JSON Web Token. A compact JWT per RFC 7519 is three base64url-encoded parts joined by dots - header, payload, signature - and building one is a pleasant exercise because every piece is a function you already have: encode standard, translate to URL-safe, sign, repeat. Here is an HS256 token built with OpenSSL's HMAC:

#include <stdio.h>
#include <string.h>
#include <openssl/hmac.h>
#include <openssl/evp.h>
static void to_base64url(const char *std_b64, char *out, size_t out_cap) {
  size_t i = 0;
  for (const char *p = std_b64; *p && *p != '='; p++) {
    char c = *p;
    if (c == '+') c = '-';
    if (c == '/') c = '_';
    out[i++] = c;
  }
  out[i] = '\0';
}
int main(void) {
  const char *secret = "my-hmac-secret-key";
  const char *header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}";
  const char *payload = "{\"sub\":\"114365\",\"name\":\"Alice\"}";
  unsigned char hb[64], pb[64];
  EVP_EncodeBlock(hb, (const unsigned char *)header,
                  (int)strlen(header));
  EVP_EncodeBlock(pb, (const unsigned char *)payload,
                  (int)strlen(payload));
  char hu[64], pu[64];
  to_base64url((const char *)hb, hu, sizeof(hu));
  to_base64url((const char *)pb, pu, sizeof(pu));
  char signing_input[256];
  snprintf(signing_input, sizeof(signing_input), "%s.%s", hu, pu);
  unsigned char mac[EVP_MAX_MD_SIZE];
  unsigned int mac_len = 0;
  HMAC(EVP_sha256(), secret, (int)strlen(secret),
      (const unsigned char *)signing_input,
      (size_t)strlen(signing_input), mac, &mac_len);
  unsigned char mb[64];
  int mn = EVP_EncodeBlock(mb, mac, (int)mac_len);
  char mu[128];
  to_base64url((const char *)mb, mu, sizeof(mu));
  printf("%s.%s.%s\n", hu, pu, mu);
  return 0;
}

Two things the structure teaches. First, the signing input is the two URL-safe parts joined by a dot - exactly the bytes the receiver will see - so the translation to base64url must happen before signing, not after; sign the standard-alphabet form and the receiver's verification fails, which is a bug that compiles, runs, and looks like a key mismatch. Second, the header and payload are plain JSON in a Base64 wrapper: anyone can read them, and that is the design. A token is a signed note, not a sealed envelope - so put nothing in it you would not mind an intercepting user reading, and never, ever put a password in a JWT payload "because it is encoded". The Base64 part of this job is small and boring, which is the highest compliment you can pay a JWT implementation.

HTTP Basic Auth: Building The Token

The oldest authentication header is also the simplest Base64 job: username:password, standard-alphabet encoded, after the word Basic. Building it in C is two lines, and the only subtlety is that the password may contain a colon (and the split on the receiving side must be at the first one):

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  const char *user = "alice";
  const char *pass = "s3cr3t";
  char creds[128];
  snprintf(creds, sizeof(creds), "%s:%s", user, pass);
  unsigned char enc[160];
  int n = EVP_EncodeBlock(enc, (const unsigned char *)creds,
                          (int)strlen(creds));
  printf("Authorization: Basic %.*s\n", n, (char *)enc);
  return 0;
}

That prints Authorization: Basic YWxpY2U6czNjcjN0. The RFC's warning applies on the sending side too: this is encoding, not protection. Over a plain HTTP connection the credential is one base64 -d away from anyone on the wire, so Basic auth is an HTTPS-only habit. (The modern alternatives - bearer tokens, mTLS - all reuse the same machinery: assemble a string, encode it, put it in a header. Base64 has been HTTP's way of smuggling structured data through text headers since the protocol had headers.)

Email And PEM: Where The Wrapping Lives

Email is the reason line wrapping exists at all. SMTP limits line length, so MIME capped encoded lines at 76 characters (PEM, its ancestor, at 64), and every mail system has honored that cap for thirty years. If your C program produces Base64 for an email body or an attachment, the wrapping is not optional cosmetics - an unwrapped 200 KB line will be rejected or mangled by parts of the mail infrastructure. OpenSSL's streaming encoder gives you wrapped output for free (at its 64-character heritage length), and when you need exactly 76, wrapping a one-shot result is a five-line loop:

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  unsigned char enc[64];
  int n = EVP_EncodeBlock(enc, (const unsigned char *)"hello world", 11);
  for (int i = 0; i < n; i += 76) {
    int chunk = i + 76 < n ? i + 76 : n;
    printf("%.*s\r\n", chunk, (char *)enc + i);
  }
  return 0;
}

Note the \r\n: email wants CRLF line endings, and if the encoded text is one of several MIME parts, the part headers around it follow the same rules - the base64 block, the line lengths, the CRLF. PEM files (the format of most keys and certificates) use the same idea with 64-character lines between -----BEGIN and -----END markers, and OpenSSL's tools expect to see that armor when you re-save a key - so if your program touches PEM, wrap at 64 and keep the labels. Everywhere else - JSON, URLs, APIs, databases - the RFC's rule applies and you do not wrap at all.

Smuggling Values Through Configs And Columns

The quiet use case: values that would break a text format get packed as Base64 so they do not. A database DSN with semicolons, a password with quotes, a token with a line break - the ops person encodes it once and the config file never sees the trouble characters:

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  const char *dsn = "pg:host=db;password=qu\"ote";
  unsigned char enc[128];
  int n = EVP_EncodeBlock(enc, (const unsigned char *)dsn,
                          (int)strlen(dsn));
  printf("DB_DSN_B64=%.*s\n", n, (char *)enc);
  return 0;
}

The program prints the exact line to paste into a .env file, and the C code that later reads it is a getenv plus a decode. Three honest caveats, all about what this is not. It is not encryption: anyone who can read the config file can decode the value in one call, so never pack a secret as Base64 and call it protected. It is not escaping: if the format needs structure preserved, a real encoding (percent-encoding for URLs, JSON escaping for JSON) is the correct tool, and Base64 is for the values those formats cannot express - the binary ones. And it costs size: a value stored in a database TEXT column as Base64 occupies about 33 percent more space than the original, which is fine for tokens and a real number for file columns (which is what BLOB columns are for).

Encoding From The Shell

Before reaching for a cc invocation, remember that both of the standard tools encode, and fast. coreutils is the general instrument: base64 encodes with a 76-character wrap by default, -w changes the column, and -w 0 disables wrapping entirely:

base64 photo.png > photo.b64
base64 -w 0 photo.png > photo-oneline.b64
cat note.txt | base64 -w 0

OpenSSL's tool is the same job with TLS-lineage packaging: openssl base64 (the friendly alias of openssl enc -base64) wraps at 64 characters and -A switches it to a single line:

openssl base64 photo.png > photo.b64
openssl base64 -A photo.png > photo-oneline.b64

Why care about the wrapping difference? Because the two tools' default outputs are not interchangeable if a downstream parser counts characters - 76 per line versus 64 per line is a visible difference in the file, and a parser that strips whitespace does not care while one that validates line length absolutely does. When your C program is the producer and the shell the consumer (or vice versa), agree on the wrap first. One dialect note for BSD-flavored systems: the decode flag there historically was -D, and older macOS releases still remember it; the encode side is base64 everywhere, which is the only direction this section is about anyway.

Streaming The Big Stuff

Encoding a multi-gigabyte file in one malloc is a memory problem you did not need. The streaming path exists for exactly this, and OpenSSL's block discipline makes the code almost trivial: feed EVP_EncodeUpdate as much as the file gives you, let it hold the remainder of each partial 48-byte block in the context, and write each block's 65 output bytes straight to the destination file. Peak memory is your two buffers - a few tens of kilobytes - no matter how big the file is:

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
  if (ctx == NULL) {
    return 1;
  }
  EVP_EncodeInit(ctx);
  FILE *in = fopen("video.mp4", "rb");
  FILE *out = fopen("video.b64", "w");
  if (in == NULL || out == NULL) {
    return 1;
  }
  char inbuf[48 * 1024];  /* a multiple of 48: clean lines */
  char outbuf[48 * 1024 + 65];
  size_t got;
  while ((got = fread(inbuf, 1, sizeof(inbuf), in)) > 0) {
    int outl = 0;
    EVP_EncodeUpdate(ctx, outbuf, &outl,
        (const unsigned char *)inbuf, (int)got);
    fwrite(outbuf, 1, (size_t)outl, out);
  }
  char tail[66];
  int outl = 0;
  EVP_EncodeFinal(ctx, tail, &outl);
  fwrite(tail, 1, (size_t)outl, out);
  EVP_ENCODE_CTX_free(ctx);
  fclose(in);
  fclose(out);
  return 0;
}

Two design details are doing work in that loop. The input buffer is a multiple of 48 bytes, so every call hands the encoder complete blocks and every line it writes is a finished 64-character line; the final call then wraps the true tail. If your input comes in arbitrary sizes (a socket, a slow disk), the context still absorbs the misalignment correctly - the multiple-of-48 choice is about output predictability, not correctness. The second detail is the output buffer size: 65 bytes per 48 input bytes plus the final block's headroom (66), which is the wrapped formula from the math section applied per chunk. Progress reporting is one line - count the bytes written to out against the total you computed from the file size - and because encoding grows the data, the output file will land about 33 percent larger than the input: bill the disk in advance.

The Sharp Edges Of Encoding

The traps, collected, all of them C-shaped:

  • Off by one, in both directions. OpenSSL returns the length without its NUL; Mbed TLS's size query includes space for the NUL; APR counts the NUL in its lengths. Three libraries, three bookkeeping conventions. Write the buffer-size function once (the math section) and stop doing arithmetic in your head at the call site.
  • The NUL is a byte you must pay for. Every encoder in this article except a bare buffer-append wants one extra byte in the output for the terminator. A buffer sized exactly at encoded_chars(n) is one byte short the moment anyone wants printf("%s") to work.
  • Encoding cannot fail, so you must not let it overflow. There is no error code that will catch an undersized buffer - the encoder will happily write past the end. The failure model of Base64 encoding in C is entirely yours: size it right, or it corrupts memory with no diagnostics.
  • Double encoding is the classic silent bug. A value that is already Base64, run through the encoder again, produces a perfectly valid Base64 string that decodes to a Base64 string instead of the data. The symptom - "it decodes, but to the wrong thing" - takes an afternoon to find. If a value arrives "pre-encoded", verify its length is a multiple of four and contains only alphabet characters before assuming it is raw data; if it is encoded, skip the encode.
  • The plus sign in URLs. Standard-alphabet output put into a query string arrives with the + turned into a space by the time your server parses the form - the + is a space in percent/form encoding. Tokens and IDs that travel in URLs want the URL-safe dialect, full stop.
  • Text mode on the wrong side of the pipe. Reading a binary file in text mode can translate bytes (on some platforms) and change your length; writing wrapped Base64 with the wrong line-ending convention breaks receivers that count characters. rb for binary input, explicit \r\n or \n where a spec demands one, and never let the C runtime decide your line endings silently.
  • int overflow in the size math. ((n + 2) / 3) * 4 in int arithmetic overflows for inputs above roughly 536 MB, producing a small positive "needed size" and a heap smash. Do the math in size_t (or uint64_t), which is also why APR's int-based API has a 2 GB ceiling you cannot engineer away.
  • Wrapping where the receiver does not expect it. RFC 4648 says: no line feeds unless the surrounding spec asks. A newline inside a JSON string value is invalid; inside a URL it is a different request. Wrap for mail, wrap for PEM, and nowhere else.

The Short Checklist

Compute the buffer size with the formula, not with a guess, and keep one size function for the whole codebase. Keep (pointer, length) together even when the buffer is NUL-terminated, because the length is the contract and the NUL is a convenience. Choose the dialect by the channel: standard for JSON and bodies, URL-safe for URLs and tokens, wrapped for mail and armor, unwrapped everywhere else. Verify the magic bytes before you claim a MIME type in a data URI. Never use Base64 as encryption, as a substitute for percent-encoding, or as a place to hide a secret - it is a box, not a lock. And when the data is big, stream it: the block-based encoders were designed for exactly that, and constant memory is the whole point.

History: How Packing Got Standardized

The encoder's history is the story of line lengths. The first Base64 was a C program from the early 1990s: Privacy Enhanced Mail (RFC 1421, 1993) needed to carry binary through 7-bit mail, and its authors chose six bits per character in 64-character lines - the 64 is a relic of SMTP's line-length tolerance, and the C code did the packing table-lookup by table-lookup. When MIME standardized the same alphabet for the web (RFC 1521 in 1993, RFC 2045 in 1996), it relaxed the line to 76 characters, and the world now carried two habits - 64 and 76 - that both claimed to be "the" Base64 line length. Encoders matched: OpenSSL's streaming path kept 64 (its PEM heritage), coreutils' tool chose 76 (its MIME heritage), and the two tools on the same machine still disagree about where the line breaks go. The standard finally took a position in 2006: RFC 4648 said implementations must not add line feeds at all unless the referring specification explicitly directs them to, which is why every library in this article defaults to unwrapped output and why wrapping is now an opt-in feature for email and armor. The alphabet itself, the padding rules, and the "pad bits must be zero" canonicality rule all date from the same RFC. And the standard's own reference implementation - section 11, an ISO C99 program - is another reminder that in this format, C is not a citizen of the second rank. The C standard library, for its part, has never caught up: C89 froze in 1990, before any of this existed, and C23 in 2024 still ships without a Base64 function. So the libraries you link are the standard, and the choice between them is a small but real design decision - which is what this article has been about.

Odd Little Facts

Some facts that are simply fun, all of them about the packing side in C:

  • The "64" is the radix: each output character is six bits, and 2 to the 6 is 64. The format names its alphabet the way C names its integers - by what the number actually is.
  • The 48-byte block of OpenSSL's streaming encoder is not arbitrary bookkeeping: 48 input bytes are exactly 16 groups of 3, and 64 output characters are exactly 16 groups of 4. Both numbers are multiples of 16, which is the kind of roundness that makes hardware and cache lines happy - or at least makes humans who read the code happy.
  • One input byte encodes to four characters, two of which are =. The smallest possible nonempty payload is 25 percent padding - the most wasteful encoding in the format, and the one every test suite uses because it is so easy to write wrong.
  • Mbed TLS is the only encoder in this article that does its lookups in constant time, because the people who write embedded crypto do not trust variable-time table indexing even in a codec that is not a cipher. The paranoia transfers.
  • The canonical-encoding rule - unused pad bits must be zero - sounds trivial until you learn that violating it means two different strings can decode to the same bytes, which breaks every "is this string the encoding of that file?" check in existence. Your encoders all comply; that is why base64 is a hash-stable representation and can stand in for a filename in a content store.
  • OpenSSL's EVP_EncodeBlock is one of the rare C functions whose return value, its own output, and its NUL terminator all agree: it writes n characters, a NUL, and returns n. In a language famous for off-by-one, that is a moment of peace.
  • APR-Util is the only encoder here that asks what EBCDIC means, because Apache still runs on machines where the letters are in a different order than ASCII's. On those machines, "encoding" a string includes quietly re-sorting its alphabet first.
  • The empty input encodes to the empty string in every library, with no pads and no newlines. The identity element of the format, present and correct in all four, which makes it the cheapest unit test you will ever write.

Flipping To The Decoder Side

So that is the packing side: the math, the four encoders, the dialects, and the places the bytes go. It is the calm half of the job, because encoding has no invalid input and no decoder to disagree with you. The other direction - meeting the outside world's Base64 and getting the bytes back - is where the pain concentrates: zero-padded tails, silent truncation, strict versus lenient alphabets, and a command line that eats trailing newlines. Base64 decoding in C is covered in depth in the related article, linked from this page, and it is the natural companion to this one: the encoder writes the box, the decoder opens it, and between the two of them you have every Base64 job a C program will ever meet.

Last updated: 2026-08-30

Related article: Base64 Decoding in C: A Complete Guide