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

Base64 Decoding in C: A Complete Guide

It sits in an API response, a config file, an email attachment, or the middle of a URL: a long string of letters, digits, the occasional + or /, and maybe an = or two at the end. You recognize it instantly, and now you need the original bytes back - in C. That is the whole job of Base64 decoding: four alphabet characters go in, three raw bytes come out, over and over, until the = marks tell you where the real data ended. The home page of this site walks through the format step by step, so this article spends its energy where the real work is: on buffers, libraries, and the traps that live in between.

Two things to know before the first malloc. First, decoding is the shrinking direction: the output is three quarters the size of the input, so a decoder never needs more memory than the payload it already holds. Second - and this is the headline - C does not ship a Base64 decoder. The language's standard library froze long before Base64 existed, and no standard since has filled the gap. So every C program that decodes Base64 leans on a library, and the four that matter in practice are OpenSSL, Mbed TLS, APR-Util, and GLib. Each one has a different personality: what it forgives, how it reports errors, and what it quietly does to your output. Once you know the personality of your decoder, decoding Base64 in C stops being a source of mystery bugs and becomes a routine you can write in your sleep.

The Toolbox: Four Ways To Get Your Bytes Back

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

Library Header Error model Output quirk to remember
OpenSSL (libcrypto) <openssl/evp.h> Returns -1 on bad input The one-shot decoder zero-pads the tail
Mbed TLS <mbedtls/base64.h> Return codes (-0x002C, -0x002A) Strictest input rules of the four
APR-Util <apr-1.0/apr_base64.h> None: stops at the first odd character Int-length API, so 2 GB is the ceiling
GLib <glib.h> Returns NULL on hard failure only Silently ignores stray garbage

Installation is one package name per distro. For OpenSSL: libssl-dev on Debian and Ubuntu, openssl-devel on Fedora and RHEL, openssl on Arch, and brew install openssl on macOS. For Mbed TLS: libmbedtls-dev (or mbedtls). For APR-Util: libaprutil1-dev plus libapr1-dev. For GLib: glib2.0-dev. Then you link with -lcrypto, -lmbedcrypto, -laprutil-1, or -lglib-2.0 respectively. Which one do you pick? If you already link OpenSSL for TLS or hashing (most servers do), use OpenSSL. For embedded and resource-constrained builds, Mbed TLS is the small, strict citizen. If you are inside the Apache ecosystem, APR-Util is already there. If your codebase is GNOME or GTK based, GLib keeps everything in one runtime.

OpenSSL: The Decoder That Fills The Gaps With Zeros

OpenSSL carries Base64 in two flavors. The one-shot function is the star of most code:

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  unsigned char out[16];
  const char *payload = "TWFuZQ==";
  int n = EVP_DecodeBlock(out, (const unsigned char *)payload,
                          (int)strlen(payload));
  if (n < 0) {
    printf("not base64\n");
    return 1;
  }
  printf("%d bytes\n", n);
  return 0;
}

Feed it a buffer of Base64 characters and a length, and it writes the decoded bytes to out and returns how many. It trims leading whitespace, trims trailing whitespace and newlines, and it refuses input that is not a multiple of four characters after trimming, or that contains a character outside the alphabet. So far, a perfectly sensible contract. Except for one detail that has quietly corrupted more than one database import: the return value is not the real data length.

Run that program and you will get 4 bytes... no, wait. TWFuZQ== is two four-character groups, so the function returns 6, and the buffer holds 4d 61 6e 65 00 00: the word "Mane" plus two zero bytes. OpenSSL's one-shot decoder works in fixed quanta - every four input characters always produce exactly three output bytes - and when the last group only carried one real byte, the other two slots are filled with zeros. The manual mentions this in a single calm sentence ("the output will be padded with 0 bits if necessary"), and that one sentence is the most important one in the whole man page for this function.

The real length is recovered from the padding, and it is a two-line calculation:

size_t real_length(const char *b64) {
  size_t len = strlen(b64);
  while (len > 0 && b64[len - 1] == '=') len--;
  return len * 3 / 4;
}

Count the alphabet characters, drop the trailing pads, multiply by three, divide by four. For TQ== (the letter M, encoded), that gives (2 * 3) / 4 = 1 real byte - while EVP_DecodeBlock will report three. Always keep the (pointer, length) pair together, and never use strlen on decoded data, because the bytes you got back may be a JPEG and the first one of them may be a NUL.

The Streaming Decoder: A Decoder That Knows When To Stop

For everything else, OpenSSL offers the streaming pair EVP_DecodeUpdate plus EVP_DecodeFinal. The context object is what moves state between calls: it holds one to three characters of an unfinished group so you can feed the payload in chunks. The behavior that matters is this: whitespace (spaces, tabs, carriage returns, line feeds) is skipped anywhere in the stream, any other non-alphabet character or a = in the middle of the data returns -1 immediately, and a return of 0 from an update means "the padding was seen, nothing more is expected". EVP_DecodeFinal then refuses with -1 if a partial group is still pending, because a length that is not a multiple of four (after whitespace) is not a valid payload.

One version note before the code, because old tutorials will trip you: in OpenSSL 3.x the context type EVP_ENCODE_CTX is opaque, so the stack pattern EVP_ENCODE_CTX ctx; found in a lot of internet code no longer compiles. Allocate and free explicitly:

static int decode_b64(const unsigned char *in, int in_len,
                      unsigned char *out, int *out_len) {
  EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
  if (ctx == NULL) {
    return -1;
  }
  *out_len = 0;
  EVP_DecodeInit(ctx);
  int r = EVP_DecodeUpdate(ctx, out, out_len, in, in_len);
  if (r < 0) {
    EVP_ENCODE_CTX_free(ctx);
    return -1;
  }
  int tail = 0;
  r = EVP_DecodeFinal(ctx, out + *out_len, &tail);
  EVP_ENCODE_CTX_free(ctx);
  if (r < 0) {
    return -1;
  }
  *out_len += tail;
  return 0;
}

Size the output buffer at in_len * 3 / 4 + 3 and the call is safe for any input. Watch it handle a MIME-wrapped payload where the line break lands in the middle of a group:

const char *wrapped = "TWFu\nZQ==";
unsigned char out[16];
int out_len = 0;
if (decode_b64((const unsigned char *)wrapped,
    (int)strlen(wrapped), out, &out_len) != 0) {
  printf("invalid base64\n");
  return 1;
}
printf("%.*s\n", out_len, out); /* Mane */

The break disappears, the four bytes come out, and nobody had to pre-clean the input. There is a bonus difference from the one-shot function: the streaming path counts bytes honestly. Feed it TQ== and it returns exactly one byte (4d), with no zero padding, because it understands that two pads mean two of the three output slots were never filled. If your payload ever needs a trustworthy length from OpenSSL, this is the path to use.

Mbed TLS: The Strict One

Mbed TLS (the crypto library that started life as PolarSSL and now ships inside ARM's embedded stacks) gives you two functions with a very clean contract:

int mbedtls_base64_encode(unsigned char *dst, size_t dlen, size_t *olen,
                          const unsigned char *src, size_t slen);
int mbedtls_base64_decode(unsigned char *dst, size_t dlen, size_t *olen,
                          const unsigned char *src, size_t slen);

Decode the way a careful person would. Call it with dst set to NULL (or dlen to zero) and it tells you the required size in *olen without doing any work; call it for real and you get 0 on success, MBEDTLS_ERR_BASE64_INVALID_CHARACTER (that is -0x002C) if anything in the input is wrong, or PSA_ERROR_BUFFER_TOO_SMALL (that is -0x002A) if the destination is too small. The decoded length lands in *olen, and unlike OpenSSL's one-shot function it is always the honest number: decoding TQ== gives you one byte, 4d, nothing more.

The input rules are the strictest of the four libraries, and worth memorizing because they define what "valid" means for Mbed TLS:

  • CRLF and LF line breaks may appear between groups - email payloads work as-is.
  • Spaces are allowed at the start of a line and at the very end of the buffer, but a space in the middle of a line is an error.
  • At most two = characters, and only at the end; any data after a pad is an error.
  • Any byte above 127 (accents, UTF-8 fragments, binary garbage) is an error.

That last rule is the one that bites: if a payload arrives from a source that mangled the character encoding, Mbed TLS will reject it where a lazier decoder would have shrug-decoded it. For anything that touches untrusted input, strict is a feature. A complete decode looks like this:

#include <stdio.h>
#include <string.h>
#include <mbedtls/base64.h>
int main(void) {
  const char *payload = "TWFuZQ==";
  size_t need = 0;
  int rc = mbedtls_base64_decode(NULL, 0, &need,
      (const unsigned char *)payload,
      strlen(payload));
  if (rc != PSA_ERROR_BUFFER_TOO_SMALL) {
    printf("size query failed: %d\n", rc);
    return 1;
  }
  unsigned char *out = malloc(need);
  size_t olen = 0;
  rc = mbedtls_base64_decode(out, need, &olen,
      (const unsigned char *)payload,
      strlen(payload));
  if (rc != 0) {
    printf("decode failed: %d\n", rc);
    free(out);
    return 1;
  }
  printf("%.*s\n", (int)olen, out);
  free(out);
  return 0;
}

(The size query returning the "too small" code is by design: it is how the function reports what it would have written. The PSA status constants come from the same package; <psa/crypto.h> pulls them in.)

APR-Util and GLib: Two More Chairs

APR-Util - the utility library of the Apache Portable Runtime, the foundation that Apache HTTP Server is built on - has carried Base64 for as long as the server has needed to decode Basic auth headers. The API is a small family of int-based functions:

#include <apr-1.0/apr_base64.h>
int apr_base64_encode_len(int len);
int apr_base64_encode(char *coded_dst, const char *plain_src,
                      int len_plain_src);
int apr_base64_decode_len(const char *coded_src);
int apr_base64_decode(char *plain_dst, const char *coded_src);

Two things to know before reaching for it. First, the lengths are int: 32-bit, so the practical ceiling is 2 GB per call, which is fine for headers and config values and not fine for decoding a 4 GB file. Second - and this is the big one - the decode function has no error return at all. The header documents it plainly: the decoder takes any invalid character, including whitespace and NUL, as a terminal. It decodes until the first thing it does not recognize, returns how far it got, and says nothing. A truncated payload, a paste with a trailing comment, a corrupted byte in the middle - all of it produces a silently short output. If you use APR's decoder, you must compare the returned length against what the payload promised; the function will not do it for you. Newer apr-util releases also add pool-based shortcuts (apr_pbase64_encode and apr_pbase64_decode) that allocate from an APR pool, which is the idiomatic pattern if you are already pool-driven - but check your installed header, because older releases do not have them. There is also an EBCDIC angle you will not find anywhere else in this article: on EBCDIC machines the functions convert input to ASCII before encoding and back after decoding, so the same code runs on the mainframes that still run httpd.

GLib, the runtime behind GTK and most GNOME applications, takes the opposite personality. Its decoder accepts a string and hands back a freshly allocated buffer, or NULL if the input is structurally hopeless:

#include <glib.h>
gsize out_len = 0;
guchar *bytes = g_base64_decode(payload, &out_len);
if (bytes == NULL) {
  printf("not base64\n");
} else {
  printf("%u bytes\n", (unsigned)out_len);
  g_free(bytes);
}

The trap is in the word "structurally". GLib's decoder is in the lenient school: characters outside the alphabet are skipped, not fatal. Feed it TWFuZ@== and it will hand back the three bytes of "Man" without raising a finger. There is also a handy in-place variant, g_base64_decode_inplace(), that rewrites the buffer from the back and returns a pointer to the first byte it did not consume - a nice trick for memory-tight code, and it happily eats CRLF-wrapped input. The takeaway for C developers: if your data is untrusted, GLib will not save you from a corrupted payload. The _step variants (g_base64_decode_step with a state integer) are available when you need incremental decoding, and g_base64_calculate_encoded_length friends live on the encoding side.

URL-Safe Base64: The Other Alphabet

Somewhere between the standard alphabet and your URLs, someone got hurt. Standard Base64 uses + and / as its two highest symbols, and both are trouble in URLs: a + in a query string is routinely interpreted as a space by the time your server sees it, and / is a path separator. RFC 4648, section 5, defines the fix, called base64url: the same encoding with + replaced by -, / replaced by _, and the trailing = padding dropped when the length is known some other way. JSON Web Tokens, OAuth state parameters, and a great many API session IDs live in this dialect.

None of the four C libraries decodes base64url natively, so the conversion is a small helper you write once and reuse: map the two special characters back, re-add any missing padding, then hand the result to your standard decoder. Length check first, because a length of one more than a multiple of four is impossible in any Base64 dialect:

int base64url_decode(const char *url_safe, unsigned char *out,
    size_t out_cap, size_t *out_len) {
  size_t len = strlen(url_safe);
  if (len % 4 == 1) {
    return -1;
  }
  size_t needed = (len * 3) / 4;
  if (needed > out_cap) {
    return -2;
  }
  char *std = malloc(len + 4);
  if (std == NULL) {
    return -3;
  }
  for (size_t i = 0; i < len; i++) {
    char c = url_safe[i];
    if (c == '-') c = '+';
    if (c == '_') c = '/';
    std[i] = c;
  }
  size_t pad = (4 - len % 4) % 4;
  for (size_t i = 0; i < pad; i++) {
    std[len + i] = '=';
  }
  int n = EVP_DecodeBlock(out, (const unsigned char *)std,
                          (int)(len + pad));
  free(std);
  if (n < 0) {
    return -1;
  }
  *out_len = needed;
  return 0;
}

Two pitfalls guard this road. The first is direction: if you feed a URL-safe payload into the standard decoder without the character swap, OpenSSL and Mbed TLS refuse it (those characters are not in their alphabet), while GLib will silently skip the - and _ and hand back a string that is shorter than it should be - with no error. Always go through the helper. The second is the RFC's own warning, which is worth taking seriously: base64url "should not be regarded as the same as the base64 encoding". If a payload happens to contain no - or _ characters, the two dialects are byte-identical for that data, and a mix-up is invisible - which is exactly why the mix-up survives until it hits a payload that contains one.

Files: Restoring The Original

The most common file-shaped job is the reverse of what some export routine did: a .b64 text file arrives, and you need the original file back. Read the whole text, decode it, and then let the bytes announce themselves before you trust any label. C has no finfo, so the practical test is a magic-number sniff over the first few bytes:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <openssl/evp.h>
int main(void) {
  FILE *f = fopen("upload.b64", "rb");
  if (f == NULL) {
    return 1;
  }
  fseek(f, 0, SEEK_END);
  long size = ftell(f);
  fseek(f, 0, SEEK_SET);
  char *text = malloc((size_t)size + 1);
  size_t got = fread(text, 1, (size_t)size, f);
  fclose(f);
  text[got] = '\0';
  unsigned char *out = malloc((got * 3) / 4 + 3);
  int out_len = 0;
  if (decode_b64((const unsigned char *)text, (int)got,
      out, &out_len) != 0) {
    printf("not valid base64\n");
    free(text);
    free(out);
    return 1;
  }
  free(text);
  const char *kind = "unknown binary";
  if (out_len >= 4 && memcmp(out, "\x89PNG", 4) == 0) kind = "png";
  else if (out_len >= 5 && memcmp(out, "%PDF-", 5) == 0) kind = "pdf";
  else if (out_len >= 4 && memcmp(out, "PK\x03\x04", 4) == 0) kind = "zip";
  else if (out_len >= 3 && memcmp(out, "\xff\xd8\xff", 3) == 0) kind = "jpeg";
  printf("looks like a %s, %d real bytes\n", kind, out_len);
  free(out);
  return 0;
}

Notes on the edges: open the file in binary mode (rb/wb) even for the text half, because text mode will translate line endings on some platforms and corrupt your character count; and never printf("%s") the decoded buffer to "see what it is". The magic sniff is the honest way to ask that question, and if you later serve the restored file to a browser, the Content-Type should come from the same sniff, not from the file name.

Data URIs: The Image Inside The URL

A favorite arrival from the web world: someone pastes an image into a form, and the front end hands your server a complete data URI like data:image/png;base64,iVBORw0KGgo.... RFC 2397 defines the shape: data:, an optional media type, an optional ;base64 flag, a comma, and then the payload. When the flag is present, the payload is Base64; when it is absent, the payload is percent-encoded plain text - rarer, but legal. If the media type is omitted, the default is text/plain;charset=US-ASCII. Parsing it in C is a matter of finding the comma and looking at what sits just before it:

int split_data_uri(const char *uri, char *mime, size_t mime_cap,
    int *is_b64, const char **payload) {
  if (strncmp(uri, "data:", 5) != 0) {
    return -1;
  }
  const char *comma = strchr(uri, ',');
  if (comma == NULL) {
    return -1;
  }
  *is_b64 = 0;
  const char *meta = uri + 5;
  size_t meta_len = (size_t)(comma - meta);
  if (meta_len >= 7 && strcmp(comma - 7, ";base64") == 0) {
    *is_b64 = 1;
    meta_len -= 7;
  }
  if (meta_len == 0) {
    snprintf(mime, mime_cap, "text/plain;charset=US-ASCII");
  } else {
    snprintf(mime, mime_cap, "%.*s", (int)meta_len, meta);
  }
  *payload = comma + 1;
  return 0;
}

And the caller reads like a sentence:

char mime[256];
int is_b64 = 0;
const char *payload = NULL;
const char *uri = "data:image/png;base64,iVBORw0KGgo...";
if (split_data_uri(uri, mime, sizeof(mime), &is_b64, &payload) == 0) {
  printf("mime=%s base64=%d\n", mime, is_b64);
  /* now decode payload with your library of choice */
}

Three pitfalls live in this format. The missing ;base64 flag is the first: a legal data URI without it carries a percent-encoded payload, and running that through a Base64 decoder produces garbage - check the flag, then choose your decoder. The claimed media type is the second: it is a hint from the sender, not a fact; the magic-number sniff from the file section is your fact. The third is size: the RFC's own advice is that data URIs are for short values, so a multi-megabyte image riding inside a URL is a smell in your architecture, not a pattern to celebrate.

JWTs: Reading The Unsecret Parts

The most famous Base64 payload on the web is the JSON Web Token, and the least scary one once you know its shape. Per RFC 7519, a compact JWT is three base64url parts joined by dots: a header, a payload, and a signature - each encoded without padding, without line breaks. The first two parts are plain JSON, which is why everyone can read them, and why everyone should read the next paragraph before touching a token.

Reading the first two parts is a few lines with the base64url helper from above, and it is the fastest way to demystify a token:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <openssl/evp.h>
int base64url_decode(const char *url_safe, unsigned char *out,
    size_t out_cap, size_t *out_len);
int main(void) {
  const char *token =
    "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
    "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0."
    "TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ";
  const char *dot1 = strchr(token, '.');
  const char *part2 = dot1 + 1;
  const char *dot2 = strchr(part2, '.');
  if (dot1 == NULL || dot2 == NULL) {
    return 1;
  }
  char buf[1024];
  size_t n = 0;
  if (base64url_decode(token, (unsigned char *)buf,
      sizeof(buf), &n) == 0) {
    printf("header:  %.*s\n", (int)n, buf);
  }
  if (base64url_decode(part2, (unsigned char *)buf,
      sizeof(buf), &n) == 0) {
    printf("payload: %.*s\n", (int)n, buf);
  }
  printf("signature: %.*s (encoded, verify before trusting!)\n",
      (int)(dot2 - part2), part2);
  return 0;
}

Printed, the header is {"alg":"HS256","typ":"JWT"} and the payload is {"sub":"1234567890","name":"John Doe"}. Now the part that matters: the third part is a signature, and the two parts you just decoded are neither secret nor authenticated. Anyone with a packet capture can read them, and anyone with a text editor can rewrite them. Trusting a JWT payload in C before verifying the signature is the classic authentication bug, and Base64 makes it easy to not notice - the token looks like an unbreakable blob while being a postcard. To verify an HS256 token you recompute the HMAC-SHA256 over header.part with your secret using HMAC() from <openssl/hmac.h> and compare in constant time with CRYPTO_memcmp(); if the digests disagree, the token is rejected, whatever its claims. There is no de-facto standard JWT library in C, so for production you will either build that small verification step yourself or adopt one of the community libraries - but the Base64 side of the job is the five lines above, and you should understand all of them.

Basic Auth: The Header That Never Learned Privacy

The oldest authentication header on the web still rides on Base64: Authorization: Basic followed by the standard-alphabet encoding of username:password (RFC 7617, now folded into RFC 9110). The RFC is explicit that this is encoding, not protection - anyone with a packet capture can decode both halves in one command - so the decode-side job in C is to parse the header, decode strictly, split at the first colon (passwords may legally contain colons), and compare with a timing-safe function:

#include <string.h>
#include <openssl/evp.h>
#include <openssl/crypto.h>
static size_t real_length(const char *b64);
int basic_auth_ok(const char *header, const char *expected_user,
                  const char *expected_pass) {
  if (strncmp(header, "Basic ", 6) != 0) {
    return 0;
  }
  const char *b64 = header + 6;
  unsigned char out[256];
  int n = EVP_DecodeBlock(out, (const unsigned char *)b64,
                          (int)strlen(b64));
  if (n < 0) {
    return 0;
  }
  size_t real = real_length(b64);
  size_t u_len = strlen(expected_user);
  size_t p_len = strlen(expected_pass);
  if (real != u_len + 1 + p_len) {
    return 0;
  }
  if (memcmp(out, expected_user, u_len) != 0) {
    return 0;
  }
  if (out[u_len] != ':') {
    return 0;
  }
  return CRYPTO_memcmp(out + u_len + 1, expected_pass, p_len) == 0;
}

The length check is doing real work: it stops a payload that decodes to "alice:secret" with trailing garbage, or "alice:secre" truncated, from matching. And CRYPTO_memcmp (or memcmp only if you understand the timing implications) is what keeps an attacker from timing their way through your user list. Serve this header over HTTPS or not at all - on a plain connection the Base64 layer is window dressing.

Email And PEM: The Original Home

Base64 was born for a very specific problem: mail transport only carried 7-bit ASCII, and people wanted to send binaries through it. MIME (RFC 2045) made Base64 one of the standard transfer encodings and added two house rules: encoded lines must not exceed 76 characters, and decoding software must ignore characters outside the alphabet - line breaks included. That second rule is why the streaming decoders above chew through a wrapped attachment with zero preprocessing, and it is why the 76-character habit is still baked into every mail library on earth. The ancestor was PEM (Privacy Enhanced Mail, RFC 1421), which used 64-character lines instead - the 64/76 split you see in tools is that history, both limits ultimately imposed by SMTP.

PEM armor - the format keys and certificates travel in - is just labeled Base64: a -----BEGIN ... ----- line, the body in 64-character lines, and a matching END line. Stripping the armor in C is a line scan, and then the decoder does the rest:

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  FILE *f = fopen("server.key", "r");
  if (f == NULL) {
    return 1;
  }
  char line[256];
  char b64[8192];
  size_t pos = 0;
  int in_body = 0;
  while (fgets(line, sizeof(line), f) != NULL) {
    if (strncmp(line, "-----BEGIN", 10) == 0) {
      in_body = 1;
      continue;
    }
    if (strncmp(line, "-----END", 8) == 0) {
      in_body = 0;
      break;
    }
    if (in_body) {
      size_t l = strlen(line);
      while (l > 0 && (line[l - 1] == '\n' || line[l - 1] == '\r')) {
        l--;
      }
      memcpy(b64 + pos, line, l);
      pos += l;
    }
  }
  fclose(f);
  unsigned char der[8192];
  int out_len = 0;
  if (decode_b64((const unsigned char *)b64, (int)pos,
      der, &out_len) != 0) {
    printf("armor contained no valid base64\n");
    return 1;
  }
  printf("DER payload decoded\n");
  return 0;
}

The decoded bytes are DER, a compact binary serialization, and that is what OpenSSL's certificate and key functions ultimately consume. Two notes: collect the body without its line breaks (as the loop does) so your length is a multiple of four, and if a file carries several blocks, match the END label to the BEGIN label you opened - a simple flag works when you only want the first block, as here.

Secrets, Configs And Database Columns

Base64 is a text container, which is why it keeps showing up in places you would not expect it. In configuration files and environment variables it is the trick for smuggling values that would otherwise break the format: a database DSN with semicolons, a password with quotes, a value with a newline. In databases, a binary blob can live in a text column as Base64 and survive every tool that assumes text - at a cost, though, of about a third extra size, so size your columns accordingly (or ask why the value is not in a BLOB column at all).

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  const char *b64 = getenv("API_KEY_B64");
  if (b64 == NULL) {
    printf("API_KEY_B64 is not set\n");
    return 1;
  }
  size_t cap = strlen(b64);
  unsigned char *out = malloc(cap);
  int n = EVP_DecodeBlock(out, (const unsigned char *)b64, (int)cap);
  if (n < 0) {
    printf("API_KEY_B64 is not valid base64\n");
    free(out);
    return 1;
  }
  size_t real = real_length(b64);
  printf("key is %zu bytes\n", real);
  free(out);
  return 0;
}

The caution applies twice. First, this is format safety, not secrecy: the moment a developer can read the config file, they can decode the value in one call, and the RFC's security section records real incidents where people reported a protocol exchange to support and "accidentally revealed the password" because Base64 is visually disguising, not computationally protecting. Never store a secret as Base64 and call it encrypted. Second, validate at startup: a half-pasted environment value is a -1 from the strict call, and a one-line check turns a cryptic failure three hours later into an actionable message at boot.

Decoding From The Shell

Not all decoding happens inside your program. CLI scripts, cron jobs, and one-liners decode Base64 constantly, and C developers should know the two tools that already exist on every Linux box. The coreutils tool is the general one: base64 -d decodes, -i makes it ignore garbage characters instead of failing, and -w sets the wrapping column (which only affects encoding, not decoding):

base64 -d < blob.b64 > blob.bin
base64 -d -i < messy.b64 > blob.bin

OpenSSL ships its own, reachable as openssl base64 (a friendlier alias of openssl enc -base64):

openssl base64 -d < blob.b64 > blob.bin
openssl base64 -d -A < blob.b64 > blob.bin

The -A flag means "one line": encode without the 64-character wrapping, and expect the input to be a single line too. And here is a CLI trap that will cost you an evening if you do not read it: OpenSSL's base64 decode expects a line break near the start of the input. Without one, it decodes to nothing at all, silently:

printf 'TQ=='  | openssl base64 -d | wc -c   # 0
printf 'TQ==\n' | openssl base64 -d | wc -c  # 1

The coreutils decoder does not have that expectation, which is one reason it is the safer default for glue work. One more dialect note: BSD-derived systems (older macOS in particular) historically spelled the decode flag -D; modern releases follow the GNU convention of -d, so check the man page on the machine you are actually on.

Big Payloads, Small Memory

Decoding is the direction that helps you: the output is three quarters the size of the input, so memory pressure from Base64 is rare. Still, when a multi-hundred-megabyte .b64 file lands on disk, the streaming path from earlier is your tool, and it is simpler than it looks. Read the encoded file in chunks, feed each chunk to EVP_DecodeUpdate, and write the decoded bytes as they arrive. The context holds the one-to-three characters of any unfinished group between calls, so chunk boundaries can fall anywhere - you do not need to align them:

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
  EVP_DecodeInit(ctx);
  FILE *in = fopen("huge.b64", "rb");
  FILE *outf = fopen("huge.bin", "wb");
  char inbuf[65536];
  char outbuf[49152 + 4];
  size_t got;
  int ok = 1;
  while (ok && (got = fread(inbuf, 1, sizeof(inbuf), in)) > 0) {
    int outl = 0;
    int r = EVP_DecodeUpdate(ctx, outbuf, &outl,
        (const unsigned char *)inbuf, (int)got);
    if (r < 0) {
      ok = 0;
    } else if (outl > 0) {
      fwrite(outbuf, 1, (size_t)outl, outf);
    }
  }
  int tail = 0;
  if (ok && EVP_DecodeFinal(ctx, outbuf, &tail) == 1 && tail > 0) {
    fwrite(outbuf, 1, (size_t)tail, outf);
  }
  EVP_ENCODE_CTX_free(ctx);
  fclose(in);
  fclose(outf);
  return ok ? 0 : 1;
}

Peak memory is two buffers on the order of a few tens of kilobytes regardless of file size, and a corrupted file fails fast - EVP_DecodeUpdate returns -1 at the chunk where the damage is, so you can report an offset instead of a shrug. One library caveat for this path: APR-Util's decoder takes a NUL-terminated string and an int length, so it is out of the running for multi-gigabyte files. If you need progress reporting, count the bytes you have written - that is your position in the output, and the input position is roughly four thirds of it.

The Traps, All C-Specific

Collected in one place, the traps that are specific to doing this in C:

  • The zero-padded one-shot. EVP_DecodeBlock returns the quantum length, not the data length. TQ== reports three bytes but carries one. Always recompute the real length from the trailing pads, or use the streaming pair.
  • Decoded bytes are not a string. The result may contain NUL bytes and may not be UTF-8. No strlen, no printf("%s"), no passing it to functions that assume text. Carry (pointer, length) everywhere.
  • Buffer size is your job. C will not grow your output buffer, and neither will the decoders - OpenSSL's update writes what it decodes into the space you gave it. Size it at in_len * 3 / 4 + 3 (plus wrap overhead if the input is wrapped and you decode with a helper that does not strip) and keep a cap check in every wrapper.
  • Signed char lookups. If you ever write a decoder yourself, the classic bug is using the input byte as an index into a 256-entry table with a plain char on a platform where char is signed: byte 0xFF becomes -1 and you index backwards through memory. Index with unsigned char or unsigned values, always.
  • The silent ones are the dangerous ones. APR-Util stops at the first invalid character and says nothing; GLib skips garbage and says nothing. OpenSSL and Mbed TLS fail loudly. If your input is untrusted, the library's silence is a bug in your program, not in the library.
  • The command line eats newlines. openssl base64 -d decodes zero bytes if the input has no line break. Shell pipelines that strip trailing newlines (tr -d '\n', xargs, editor saves without final newline) will produce empty output with no error.
  • int versus size_t. OpenSSL's one-shot API takes an int length, APR-Util uses int throughout, and the Mbed TLS and GLib APIs use size_t. Mixed-length arithmetic between them is where signed/unsigned warnings hide real bugs - and where APR's 2 GB ceiling lives.
  • Whitespace is not uniform. OpenSSL skips all whitespace anywhere; Mbed TLS allows CRLF/LF between groups and spaces at line starts but not mid-line; the CLI tools vary. A payload that is valid for one decoder can be invalid for another, and "it worked on my machine" usually means "my decoder was lazier".

Good Habits, Collected

Validate before you trust: a shape check (alphabet characters, at most two trailing pads) catches obvious garbage before any decode, but only a real decode understands Base64 semantics, so the strict decoder gets the final word. Use the streaming pair from OpenSSL when you need honest lengths or chunked input, and the one-shot when the payload is small and you immediately correct its length. Keep (pointer, length) pairs together and never let a decoded buffer meet a string function. Compare authentication material with CRYPTO_memcmp. Sniff the magic bytes before you believe a file name or a claimed MIME type. And treat Base64 as what it is - a packaging format, a little box for bytes - not as a lock: nothing about these four letters makes your data private.

A Short History Of Base64 In C

The story starts with mail. In 1990 and 1991 a group of cryptographers sketched Privacy Enhanced Mail, a system for signed and encrypted email, and they needed a way to carry binary through a 7-bit network. Their answer, standardized as RFC 1421 in 1993, encoded data six bits per character - "base 64" - in 64-character lines, and the implementation was, of course, C. Two years later the web arrived with its own MIME, RFC 1521 (1993) and then RFC 2045 (1996), which kept the same alphabet, relaxed the line length to 76, and turned Base64 into the attachment format of the young internet.

C's standard library missed the whole boat. The C89 standard was published in 1990, three years before MIME, and the language's committee has never added a Base64 function since - not in C99, not in C11, not in C23 (the 2024 revision). So the ecosystem grew around the libraries: OpenSSL has carried the EVP encode/decode routines in libcrypto for as long as anyone has linked OpenSSL for TLS, Mbed TLS (renamed from PolarSSL in 2015) kept a small strict pair for embedded systems, APR-Util shipped with Apache when the server needed to decode its own auth headers, and GLib added its trio for the desktop. The standards chased the implementations: RFC 3548 in 2003 tidied the old definitions, and RFC 4648 in 2006 (Base-N Encodings) formalized the alphabets, the URL-safe variant, and the security rules this article leans on. Fittingly, section 11 of that RFC points to an ISO C99 reference implementation - the standard's own example decoder is written in C, which tells you everything about where this format lives.

Fun Facts, C Edition

A few C-flavored facts that are simply fun to know:

  • The name is math, not marketing: each output character carries exactly six bits, and 2 to the 6 is 64. "Base64" is the radix, read aloud.
  • The alphabet is 65 characters, not 64: the 64 symbols plus =, which RFC 4648 calls "the extra 65th character" used for a special processing function. The pad is a worker, not a letter.
  • OpenSSL wraps encoded output at 64 characters (the PEM habit) while coreutils wraps at 76 (the MIME habit). The 12-character difference is two decades of mail history you can see in the output of two commands on the same machine.
  • The author of the GNU coreutils base64 command is Simon Josefsson - the same person who wrote RFC 4648. The standard and one of its most-used implementations share an author, which is how the two ended up agreeing about every edge case.
  • Mbed TLS does its table lookups through constant-time helpers (mbedtls_ct_base64_*), so the decode speed does not leak which characters it saw. A detail you will never notice and are glad exists.
  • TQ== is the smallest nontrivial payload: one real byte, two pads. It is the perfect test vector - OpenSSL's one-shot decoder returns three bytes for it, its streaming decoder returns one, Mbed TLS returns one, and GLib returns one. Four libraries, two answers, and the difference is the zero padding.
  • APR's base64 functions are the only ones in this article that care about EBCDIC, because httpd still runs on machines where letters are not ASCII. The C standard library never met a mainframe; APR did.
  • The empty payload is the universal identity: every library encodes and decodes zero-length input to zero-length output, with no error. If your decoder chokes on an empty string, you have a bug, not a format.

Flipping To The Encoder Side

That is the decoder side, and it is where most of the pain lives, because decoding is where you meet other people's data: their padding choices, their line breaks, their corrupted bytes, their tokens. The opposite direction - turning bytes into a Base64 string - is a calmer animal with its own cast of traps: exact buffer math, the question of line wrapping, and the size bill that lands on every sender. Base64 encoding in C is covered in depth in the related article, linked from this page, and it pairs with this one the way a decoder pairs with an encoder: read both and you will never be surprised by either direction again.

Last updated: 2026-08-30

Related article: Base64 Encoding in C: A Complete Guide