Base64 Encoding in C# (CSharp): A Complete Guide
You have the bytes. A PNG that needs to travel inside a JSON response, a token that has to fit in a URL, a line of text that is about to enter a system that only accepts letters and digits. Somewhere between the byte[] in your hand and the channel it must cross, C# offers a menu of Base64 encoders, and choosing among them is the real skill of this subject. The classic one-liner has been in the framework since 2003, the span-based and URL-safe options arrived with the modern runtimes, and each one makes different promises about size, line breaks and alphabet. This article walks the whole menu, with working examples for every real job an encoder gets asked to do.
The house rules first, in one breath, since the main page of this site explains the format in depth: the encoder takes every three bytes and writes four characters from a 64-symbol alphabet, padding the tail with one or two = characters, so your data leaves about 33 percent fatter than it arrived. That number, not any code, is the most important fact in this article, and everything below is about paying it sensibly.
The Encoder Menu: Pick Your Tool
Here is the complete family of encoding APIs in the .NET world, with the situation each one is built for. Everything is in the runtime itself, except the URL-safe class on older frameworks, which rides in on a small NuGet package:
| API | Available since | What it is for |
|---|---|---|
Convert.ToBase64String(byte[]) |
.NET Framework 1.1 (2003) | The classic. Whole array in, padded string out. No options, no surprises. |
Convert.ToBase64String(byte[], int, int) |
.NET Framework 1.1 (2003) | Encode a slice of a bigger array, without copying it out first. |
Convert.ToBase64String(byte[], Base64FormattingOptions) |
.NET 2.0 (2005) | The classic with a dial: optionally insert a line break every 76 characters, the MIME way. |
Convert.ToBase64String(ReadOnlySpan<byte>, Base64FormattingOptions) |
.NET Core 2.1 (2018) | The span version: encode a view into a buffer, no array copy, no slice allocation. |
Convert.ToBase64CharArray(byte[], int, int, char[], int) |
.NET Framework 1.1 (2003) | Write into a character buffer you own, and get back how many characters were used. |
Convert.TryToBase64Chars(ReadOnlySpan<byte>, Span<char>, out int, ...) |
.NET Core 2.1 (2018) | Boolean instead of exceptions: encode if the buffer fits, report false if it does not. |
System.Buffers.Text.Base64.EncodeToUtf8, EncodeToUtf8InPlace |
.NET Core 2.1 (2018) | The strict span family: status codes instead of exceptions, and in-place inflation for buffers you already own. |
System.Buffers.Text.Base64Url.EncodeToString and siblings |
.NET 9 (2024) | The URL-safe alphabet, emitted without padding. On .NET Framework 4.6.2+ and .NET Standard 2.0: the Microsoft.Bcl.Memory NuGet package. |
ToBase64Transform + CryptoStream |
.NET Framework 1.1 (2003) | Streaming: encode a file as it flows, never holding the whole payload in memory. |
If your project targets a .NET version from 2018 onward, the first seven rows are in the box. Base64Url needs .NET 9 or newer, or the Microsoft.Bcl.Memory package on anything older. And a forward note: the .NET 11 libraries, in preview at the time of writing with a general release expected in late 2026, add further Base64 convenience APIs and overloads to the existing types, so the menu keeps growing. No other package in this article is required by anyone.
The Standard Call: Convert.ToBase64String
Ninety percent of encoding life in C# is a single call. Hand it bytes, and it hands you back the string that carries them:
using System;
using System.Text;
string text = "Man";
byte[] bytes = Encoding.UTF8.GetBytes(text);
string packed = Convert.ToBase64String(bytes);
Console.WriteLine(packed);
// TWFu
Notice the two-step shape, because it is the most common "why does my Base64 not match" question in C#. There is no overload that takes a string directly, and that is by design: a C# string is UTF-16, and the framework refuses to guess which bytes you meant when you said "encode this text". You choose the byte representation first, with Encoding.UTF8.GetBytes (or whatever charset the data really is), and only then does the Base64 step happen. The rest of the classic family is the same call with a tighter waist: the (byte[], int, int) overload encodes a slice of a buffer without copying the slice out, and the span overload does the same from a ReadOnlySpan<byte>, which is the right tool when the data is a window into a larger read buffer. One more property of the classic encoder is worth stating plainly: it never fails and never asks. It always emits the standard alphabet, always includes padding, and always gives you the same string for the same input, so a Base64 string is a reliable fingerprint of the bytes that produced it.
The 76-Character Question: Line Breaks and Base64FormattingOptions
There is one dial on the classic encoder, and it has been there since .NET 2.0: Base64FormattingOptions. Set it to InsertLineBreaks and the encoder inserts a line break after every 76 characters of output, the line length that the MIME specification uses for email attachments. Set it to None, or use the overloads without the option, and you get one long, unbroken string:
using System;
byte[] bytes = new byte[90];
string plain = Convert.ToBase64String(bytes);
string wrapped = Convert.ToBase64String(bytes,
Base64FormattingOptions.InsertLineBreaks);
Console.WriteLine(plain.Length); // 120
Console.WriteLine(wrapped.Length); // 122, one line break added after character 76
Two details about that dial matter in practice. First, the line break it inserts is the Windows pair, carriage return plus line feed, not a bare line feed. So the wrapped output contains \r\n sequences, and any code that later "cleans up" the string by removing only \n will be left with stray carriage returns hiding in the data. Second, the wrap happens at 76 characters of encoded output, which is why the MIME standard could guarantee that email transport, with its 76-or-78 character line limits, would never split a group of four Base64 characters across lines: 76 is a multiple of four, so every line ends on a group boundary. You want the wrapped form when you are producing email bodies, PEM-style text blocks, or anything a legacy mail pipeline will carry. You want the unwrapped form everywhere else: JSON payloads, URL tokens, API responses, and files that will be decoded by a strict parser that dislikes surprises. And you never want the wrapped form inside a JWT, where the specification explicitly forbids line breaks, whitespace, and even padding.
Owning the Output: Char Buffers and the Try APIs
Sometimes the string is not the goal, the buffer is. You are appending into a fixed-size character array, you are writing into a protocol frame, or you simply do not want the runtime to allocate the output for you. For those moments the encoder has a char-buffer mode since 1.1 days, and a Try mode since the span era. The char-buffer method writes into an array you provide and tells you how many characters it used, so sizing the buffer is your job, and the standard library even hands you the sizing formula:
using System.Buffers.Text;
using System.Text;
byte[] bytes = Encoding.ASCII.GetBytes("Man");
char[] buffer = new char[Base64.GetMaxEncodedToUtf8Length(bytes.Length)];
int written = Convert.ToBase64CharArray(bytes, 0, bytes.Length, buffer, 0);
string packed = new string(buffer, 0, written);
Console.WriteLine(packed);
// TWFu
The Try sibling does the same job from spans and answers with a boolean. It encodes as much as fits in your destination span, reports the character count in the out-parameter, and returns false if the destination was too small, without writing anything. That last property makes it safe to use with untrusted input sizes: you never get a half-filled buffer from a failed call:
using System;
byte[] bytes = { 1, 2, 3 };
char[] buffer = new char[4];
if (Convert.TryToBase64Chars(bytes, buffer, out int written,
Base64FormattingOptions.None))
{
Console.WriteLine(new string(buffer, 0, written));
// AQID
}
else
{
Console.WriteLine("Buffer too small, nothing was written.");
}
For the strict span family in System.Buffers.Text.Base64, the same shape exists with the OperationStatus contract instead of a boolean: EncodeToUtf8 fills a byte span you own and tells you, by status, whether it finished, ran out of room, or needs more input, and EncodeToUtf8InPlace is the one you reach for when the binary data already sits in a buffer you are willing to grow into: encoding inflates the data, so the method writes the Base64 text over the end of the same buffer and reports how long the result is. All of these share one rule about sizing: the output for n input bytes is always 4 * ceil(n / 3) characters including padding, and the GetMaxEncodedToUtf8Length and Base64Url.GetEncodedLength helpers implement exactly that arithmetic, so size from the helpers and never from a remembered constant.
The URL-Safe Encoder: Base64Url
The standard alphabet has two characters that URLs do not love. The + in a query string is routinely decoded as a space by form-parsing rules, and both / and = want percent-encoding before they can ride in a path or a parameter. The URL-safe variant of Base64, defined in section 5 of RFC 4648, swaps + and / for - and _, which need no escaping anywhere, and makes the trailing = padding optional. Since .NET 9 the runtime has a dedicated class for it, System.Buffers.Text.Base64Url, and it has one behavior that surprises people the first time: it does not emit padding at all:
using System.Buffers.Text;
byte[] bytes = { 1, 2 };
string classic = Convert.ToBase64String(bytes);
string urlSafe = Base64Url.EncodeToString(bytes);
Console.WriteLine(classic); // AQI=
Console.WriteLine(urlSafe); // AQI
That difference is the entire point. A JWT segment, an upload identifier, a token in a query string, a value in a URL path: all of them want the unpadded URL-safe form, and Base64Url.EncodeToString gives it directly, with the alphabet and the padding both handled the way those formats specify. The class has the full family, encode to string, to char span, and to UTF-8 byte span, plus GetEncodedLength for buffer sizing and IsValid for validating input on the way in. If your project runs on an older runtime, add the Microsoft.Bcl.Memory package, which Microsoft publishes to backport the class to .NET Framework 4.6.2 and up:
dotnet add package Microsoft.Bcl.Memory
And if you cannot use the package, the hand-rolled version is the classic encoder plus two replaces and a trim, which you will meet in a great many C# codebases:
using System;
using System.Text;
byte[] bytes = Encoding.UTF8.GetBytes("Hello World!");
string packed = Convert.ToBase64String(bytes)
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('=');
Console.WriteLine(packed);
// SGVsbG8gV29ybGQh, URL-safe and unpadded
The order of operations in that chain is worth noting: the character swaps happen on the standard output, and the padding is trimmed last, because trimming first would change nothing but make the code harder to read, and swapping after trimming would still work but is how subtle bugs get born. Use this shape for tokens, identifiers, and anything that will live in a URL, and reserve the standard alphabet for email bodies, JSON payloads and files, where +, / and = are perfectly at home.
Feeding the Encoder: Strings, Charsets and the Encoding Choice
Every encoding job that starts from text begins with the same quiet decision: which bytes does this text become? The Base64 step is deterministic and innocent, but the Encoding step before it is where outputs diverge, and the divergence can be silent. UTF-8 is the default assumption on the modern web, and it is the right default here: it round-trips every language, it is what every other platform will assume when it decodes your payload, and it is what Encoding.UTF8 gives you in one call:
using System;
using System.Text;
string original = "h\u00e9llo \u4e16\u754c";
byte[] utf8 = Encoding.UTF8.GetBytes(original);
string packed = Convert.ToBase64String(utf8);
Console.WriteLine(packed);
// aMOpbGxvIOS4lueVjA==
Now watch the same character encoded through a different charset, and see why "the same text" is not a well-defined thing without a charset attached:
using System;
using System.Text;
string euro = "\u20ac";
string asUtf8 = Convert.ToBase64String(Encoding.UTF8.GetBytes(euro));
string asLatin1 = Convert.ToBase64String(
Encoding.GetEncoding("ISO-8859-1").GetBytes(euro));
Console.WriteLine(asUtf8); // 4oKs
Console.WriteLine(asLatin1); // Pw==
Two different Base64 strings for the same euro sign, both perfectly valid, and only one of them will decode back to a euro sign on the other side. The trap with the widest blast radius is Encoding.Default: on .NET Framework on Windows it is the system's ANSI code page, while on .NET (Core) it is UTF-8, so a program that encodes with Encoding.Default produces different Base64 on a 2010 machine than on a 2025 one, and both outputs will decode "correctly" on their home platform. If a decoded payload arrives full of accented mojibake, the original encoding used a different charset than the decode assumed, and the fix is on this side of the pipe: pin the encoding explicitly, in both directions, in code that will outlive the team that wrote it. And a final note on the type system itself: a C# string is UTF-16, so if you ever pass raw UTF-16 code units into the encoder (by calling Encoding.Unicode.GetBytes), every ASCII character costs two bytes and your output doubles in size with no benefit, because the decoder on the other side will read it as UTF-16 text, not as your original string's bytes. Base64 carries the bytes you give it, and it does not care what they mean.
Files: From Disk to a String
Files are the most common encoding payload and the most forgiving, because there is no charset question: the bytes on disk are the data, and the encoder does not care whether they spell a word or a waveform. The round trip is a read, an encode, and a write, and the only real decision is where the result goes:
using System.IO;
byte[] bytes = File.ReadAllBytes("photo.png");
string packed = Convert.ToBase64String(bytes);
File.WriteAllText("photo.b64", packed);
Console.WriteLine(packed.Length + " characters for "
+ bytes.Length + " bytes of image.");
The size math is the whole story, and it is worth doing before you pick a transport. One megabyte of file becomes 1,333,336 Base64 characters, and because a C# string stores two bytes per character, that encoded result occupies about 2.7 megabytes in memory as a string. A ten megabyte file becomes a thirteen megabyte string sitting in twenty-six megabytes of managed memory. None of that is a problem for a photo or a config blob, and it is a very good reason to use the streaming encoder, below, when the payload is a video. The pattern above is the one to reach for anything that comfortably fits in memory, and it is also the pattern that every "upload a file as Base64 in a JSON body" feature quietly uses: read the file, encode it, put the string in the JSON, and let the API layer do its job.
Images on the Web: Building Data URIs
The most visible consumer of encoded images is the web, and the web's format for "an image that lives inside the document" is the data URI: a data: scheme followed by the MIME type, a ;base64 flag, a comma, and the encoded bytes. Building one in C# is string concatenation, and the encoder is doing all the real work:
using System.IO;
byte[] png = File.ReadAllBytes("logo.png");
string packed = Convert.ToBase64String(png);
string dataUri = "data:image/png;base64," + packed;
Console.WriteLine(dataUri.Substring(0, 30));
// data:image/png;base64,iVBORw0K
The iVBORw0KGgo prefix in that output is a useful checkpoint: it is the Base64 form of the eight-byte PNG signature, so any PNG you encode will start that way, and any PNG data URI that does not is not a PNG. Three practical notes belong with this pattern. First, the data URI is a full copy of the image, inflated by a third, embedded in your HTML or CSS, so it trades a network request for permanent page weight, a bargain for a 4 KB favicon and a rip-off for a 4 MB hero image, and the encoder will not negotiate on the 33 percent. Second, if the image is large, resize or recompress it before you encode, because every byte of the original shows up in the page. Third, be careful with SVG data URIs in user-facing HTML: an SVG can carry script, and a <img> tag with a data URI is a classic XSS surface when the SVG comes from a user. PNG, JPEG, GIF and WebP in data URIs are inert; SVG is the one that is not.
Assembling a JWT by Hand
Building a JSON Web Token from scratch is a rite of passage, and it is a better rite in C# than in most languages, because the pieces are short. A JWT is three Base64url segments joined by dots: the encoded header, the encoded payload, and the signature. The first two are UTF-8 JSON documents, and the signature is computed over the first two segments joined by a dot. Here is the whole assembly, with a stand-in signature, because the cryptographic step belongs to your signing key and not to the Base64 story:
using System;
using System.Buffers.Text;
using System.Text;
string headerJson = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}";
string payloadJson = "{\"sub\":\"42\",\"name\":\"Ada\"}";
string header = Base64Url.EncodeToString(Encoding.UTF8.GetBytes(headerJson));
string payload = Base64Url.EncodeToString(Encoding.UTF8.GetBytes(payloadJson));
string signature = "c2lnbmF0dXJl"; // stand-in for the real HMAC or ECDSA value
string jwt = header + "." + payload + "." + signature;
Console.WriteLine(jwt);
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsIm5hbWUiOiJBZGEifQ.c2lnbmF0dXJl
Two properties of Base64Url.EncodeToString do quiet work in that example. It emits the URL-safe alphabet, so neither + nor / can appear in the token, and it omits padding, so no = ever appears either, which is exactly what the JWS specification requires, and exactly what Convert.ToBase64String would not do without help. If you are on a pre-.NET 9 runtime, the same job runs through the standard encoder plus the fix-up chain from the URL-safe section: encode, swap the two characters, trim the padding. The order of the segments matters for the signature, which is computed over header plus a dot plus payload as plain ASCII bytes, so assemble the two segments first and sign their exact concatenation, not a reformatted version of the JSON. And a boundary to keep sharp: for anything a user can reach, do not assemble JWTs by hand at all. The System.IdentityModel.Tokens.Jwt package builds, signs, validates and expires tokens in one, and its Base64url handling is precisely this alphabet and padding rule. Hand assembly is for tests, demos, and the day you need to understand exactly what the library is doing.
HTTP Headers: Basic Auth
Base64 appears in plain HTTP in the Basic authentication scheme, and the encoding side is one of the shortest header builders in the protocol: join the username and password with a colon, encode the result as UTF-8, Base64 it, and prefix it with the scheme name:
using System;
using System.Text;
string user = "ada";
string password = "s3cret";
string credentials = user + ":" + password;
string header = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes(credentials));
Console.WriteLine(header);
// Basic YWRhOnMzY3JldA==
The UTF-8 step is specified by the protocol, so an accented username must go through Encoding.UTF8, not whatever the platform default is, or the server will decode a different byte string and reject the login. The Base64 step is the only encoding in the header: do not percent-encode the result, do not URL-encode it, do not double-Base64 it. Each of those "helpful" extra steps is a known bug, and the double-encoding one is the most common, because the credentials sometimes arrive pre-encoded from a layer that already Base64'd them, and a second encode produces a header that looks plausible and fails silently at the server. Two cautions about the scheme itself, so they land here rather than in the security section where they would be diluted: Basic auth transmits the password in a form that is one command away from readable, so it is only acceptable over TLS, and even then it is the wrong tool for most API work, which is why bearer tokens and JWTs took over. The encoder's job in all of this is the small, honest one: turn the colon-joined credentials into a header-safe string, and nothing more.
Email: MIME and Why ToBase64Transform Does Not Wrap
Email is the historical home of Base64, and it is still the place where the 76-character line rule comes from: the MIME specification wraps encoded bodies at 76 characters with CRLF between lines, so that no SMTP hop has a reason to re-wrap them. C# gives you two encoders for this job, and they make different promises, which is worth understanding before you pick one. The first is the classic Convert.ToBase64String with InsertLineBreaks, which you saw in the line-break section, and it is exactly the MIME shape, wrapped at 76 with CRLF, ready to paste under a Content-Transfer-Encoding: base64 header. The second is ToBase64Transform, the streaming cousin, and here is the surprise: it does not insert line breaks. It has no mode for them, no option, no constructor flag, and its output is one long unwrapped stream:
using System.IO;
using System.Security.Cryptography;
using FileStream source = File.OpenRead("photo.png");
using MemoryStream destination = new MemoryStream();
using ToBase64Transform transform = new ToBase64Transform();
using CryptoStream encoder = new CryptoStream(source, transform, CryptoStreamMode.Read);
encoder.CopyTo(destination);
Console.WriteLine(destination.Length + " characters, no line breaks");
So the practical rule is: for small-to-medium email payloads, read the bytes and use the wrapping classic encoder, because you get the MIME shape directly. For large attachments, stream with ToBase64Transform to keep memory flat, and wrap the result yourself if the transport really needs 76-character lines, splitting the output on group boundaries (every 76 characters, which is always a group boundary, as the line-break section explained). The transform is doing the right thing by staying unwrapped: it processes the input in groups of three bytes, and line breaks are a formatting decision that belongs to the layer that knows the transport, not to the layer that is converting bytes to characters in a pipe.
Streaming: Encoding Big Files Without Reading Them Twice
When the payload is a video, a backup, or anything you would be embarrassed to hold in a string, the streaming encoder is the whole solution. The pattern is the mirror of the decode-side streaming: a CryptoStream over the source file, with ToBase64Transform in read mode, and a CopyTo into the target. The file flows in, the Base64 flows out, and the only memory the process holds is the buffer the stream uses internally:
using System.IO;
using System.Security.Cryptography;
using FileStream source = File.OpenRead("video.mp4");
using FileStream target = File.Create("video.b64");
using ToBase64Transform transform = new ToBase64Transform();
using CryptoStream encoder = new CryptoStream(source, transform, CryptoStreamMode.Read);
encoder.CopyTo(target);
Console.WriteLine("Wrote " + target.Length + " characters.");
Two facts about this pattern are worth keeping. First, the size of the output is fully determined by the size of the input, 4 characters per 3 bytes, so you can reserve the target space, precompute the length for a content-length header, or budget a disk quota before a single byte flows. Second, the transform expects its input in groups of three bytes, and CryptoStream handles that alignment for you, feeding the transform exactly what it wants as the file streams by. If you ever drive the transform by hand with TransformBlock, feed it multiples of three, and let TransformFinalBlock drain the tail, the one or two leftover bytes that become the final partial group with its one or two padding characters. For most applications the CopyTo form is all you will ever write, and it is the form that behaves well under a memory limit, which is exactly where big files like to live.
Configuration, Environment Variables and Databases
The other common encoding job in C# applications is the storage job: taking a secret or a binary blob and putting it into a place that only accepts text. Environment variables are the visible example, because an env var is, by definition, a string:
using System;
using System.Text;
string secret = "p@ssw0rd+and/symbols";
string packed = Convert.ToBase64String(Encoding.UTF8.GetBytes(secret));
Environment.SetEnvironmentVariable("SECRET_B64", packed);
string back = Encoding.UTF8.GetString(
Convert.FromBase64String(Environment.GetEnvironmentVariable("SECRET_B64")));
Console.WriteLine(back == secret);
// True
In databases the same idea usually appears as a byte[] property that a text column must hold, and Entity Framework Core has a built-in mechanism for exactly this, a value converter that runs your encode and decode functions on every read and write:
using Microsoft.EntityFrameworkCore;
modelBuilder.Entity<Avatar>()
.Property(a => a.ImageData)
.HasConversion(
v => Convert.ToBase64String(v),
v => Convert.FromBase64String(v));
That converter is the entire database integration: the C# code sees a byte[], the column sees a Base64 string, and the round trip is invisible at the call site. Two cautions belong with this section. First, the column is paying the 33 percent tax: a text column sized for the encoded length holds a third less data than the same width as binary, so if you have a fixed-width column, size it for the Base64 length, and if you have a varchar(max) or equivalent, the tax is only a billing issue. Second, and this is the one that keeps coming back, Base64 in a config file is a shape, not a shield. It keeps the value on one line, keeps it out of text editors' way, and is one command away from readable by anyone who can read the file. Secrets need real protection, a secret store, a key vault, at minimum file permissions, and the Base64 is just the transport format the secret wears while it sits in the config.
From the Command Line
Every encoder deserves a 15-line console life, and the C# one is pleasant because the output is a plain string that standard output was made for. Here is the whole tool: it takes a file path or a string argument, encodes it, and writes the Base64 to the terminal where any shell pipeline can take it:
using System;
using System.IO;
using System.Text;
string input = args.Length > 0
? File.ReadAllText(args[0])
: Console.In.ReadToEnd();
byte[] bytes = Encoding.UTF8.GetBytes(input);
Console.WriteLine(Convert.ToBase64String(bytes));
Build it once and it sits next to the shell's own base64 utility for the days when you specifically want the .NET encoder's behavior: the same alphabet, the same padding, and the C# runtime's UTF-8 handling of whatever the pipe hands it. For binary files the same skeleton with File.ReadAllBytes instead of File.ReadAllText is the whole change, and the output then describes the file's exact bytes rather than its text interpretation. The tool is also a good probe: pipe a file through it, pipe the output back through the decoding article's decoder, and diff the two files, which is a satisfying end-to-end check that both sides of the pipe agree on every byte.
Padding, or the Trailing Equals
The last = characters of a Base64 string are the format's bookkeeping, and C#'s encoders disagree about them, which is the source of a specific and common interop bug. The classic Convert.ToBase64String always pads, because the classic decoder it pairs with always expects to. Base64Url.EncodeToString never pads, because the URL-safe consumers it targets, JWTs and token APIs, always expect the compact form. When your output crosses into a world with the opposite expectation, the fix is arithmetic, and it is the same arithmetic the decoding article showed for the reverse direction:
using System;
string padded = Convert.ToBase64String(new byte[] { 1, 2 });
Console.WriteLine(padded); // AQI=
Console.WriteLine(padded.TrimEnd('=')); // AQI, what a URL-safe consumer wants
string compact = "AQI";
string restored = compact + new string('=', (4 - compact.Length % 4) % 4);
Console.WriteLine(restored); // AQI=, what a classic decoder wants
The (4 - length % 4) % 4 formula is the entire padding universe: it adds zero, one, or two characters to land the length on a multiple of four, and the outer modulo keeps already-padded input from gaining extra. Two warnings about padding, because it is where well-meaning code goes wrong. Never treat the = as data: it carries no information, so encoding a string that already contains padding as if it were payload, or URL-encoding the = into %3D inside a query string, are both ways to produce output that looks right and decodes wrong. And beware the small family of legacy payloads where the padding was written as a different character, a dot in some older systems, instead of the standard =: if a value you receive uses a dot where you expect padding, normalize it back to = before decoding, or run it through the URL-safe path unpadded.
How Fast Does It Run
Base64 encoding in modern .NET is fast, and the interesting part is the memory story, not the CPU story. The runtime's implementations are optimized with SIMD vector instructions where the hardware supports them, and multi-megabyte inputs encode in single-digit to low-double-digit milliseconds on an ordinary desktop machine, fast enough that the encoder is effectively free in any application you will write. The performance advice that actually changes code has to do with shape. The output is a C# string, and a C# string stores two bytes per character, so the in-memory cost of an encoded result is roughly 2.7 bytes per input byte (4 characters per 3 input bytes, at 2 bytes per character), which is a number worth knowing when the payload is in the megabytes. If you are encoding thousands of small payloads in a loop, prefer the span and char-buffer APIs, which write into buffers you reuse, over the string APIs, which allocate a fresh managed string per call. If you are encoding one big file, skip the string entirely and use the streaming transform, because the 2-bytes-per-character cost of holding a 13 megabyte string is pure waste when a CopyTo would have kept the working set in stream buffers. And if you are producing MIME-wrapped output, remember that the wrapping pass is a second trip over the data, so wrap only when the transport needs it, not as a default.
The Security Conversation
The encoder side of Base64 has one security lesson, and it is the inverse of the decoder's: you are the one making the choice to expose readable data, and the format will not stop you. Base64 is encoding, not encryption. It has no key, no algorithm, and no secrecy of any kind, and the output of your ToBase64String call is one command away from the input, on any machine, in any language, by anyone. So the first rule is about what you choose to encode: never put a password, a token, or a secret into a config file "protected" by Base64, because the protection is exactly one decode call deep, and the person reading the config has the command. If the value must be secret, it needs real protection, and the Base64 is just the shape it wears while it sits in the text field.
The second lesson is about the channel, and it is specific to the things this article builds. A Basic auth header carries the password in a form that any proxy, any log, and any middlebox can read, which is why the scheme is only acceptable over TLS and mostly obsolete outside of legacy integrations. A data URI in HTML carries the image, and if the image is a user-supplied SVG, it carries whatever the SVG carries, which is why the SVG-in-data-URI case needs the same care as any user content. And a Base64 value in a URL is, literally, in the URL, which means it is in the browser history, the server access log, the referrer header, and the proxy cache, so tokens that must stay private do not belong in query strings, padded or not. The encoder is doing its honest job in all three cases, turning bytes into a safe-to-carry string. The security is in what you carry, and where, and the format is a better messenger than most, but it is a messenger, not a vault.
Pitfalls C# Encoders Fall Into
These are the traps that keep showing up on the encoding side of C# code, and every one has a concrete cause in how the framework works:
- The charset you did not choose. Encoding a string with
Encoding.Defaultproduces different Base64 on .NET Framework (the Windows ANSI code page) and on .NET (UTF-8). The outputs are both valid, both will decode "correctly" on their home platform, and they are not the same bytes. Pin the encoding explicitly. - Double encoding. The input was already Base64 (a config that encoded an encoded value, an API that re-encodes its input), and the encoder, doing exactly what it was told, produced Base64-of-Base64. The result looks plausible, and it decodes one layer at a time, which is how a bug that takes two decodes to fix gets discovered in production.
- Line breaks in the wrong place. The MIME-wrapped form, with its CRLF pairs, lands in a JSON string, a JWT segment, or a URL parameter, where the strict consumer chokes on the whitespace it was never told to expect. Wrap for mail, leave it alone everywhere else, and if you strip someone else's wrapping, strip the
\ras well as the\n. - Standard alphabet in a URL. A
+in a query string is decoded as a space by form-parsing rules, so a standard Base64 value put in a URL comes back with letters where the plus signs were. Use the URL-safe alphabet, or percent-encode the whole value, and never both. - The padding mismatch. Your output is padded, the consumer wants compact, or the reverse, and neither side is wrong, they just disagree. The fix is the arithmetic from the padding section, applied on the side that knows the consumer's expectation, which is usually the side writing the token.
- Memory that was not budgeted. The encoded string is two bytes per character in memory, so a 10 MB file becomes a 13 million character string weighing roughly 27 MB in managed memory, and a loop that builds such strings one at a time will show up in the profiler as allocation churn with no visible cause. Size buffers with the length helpers, stream the big ones, reuse buffers in the hot loops.
- The transform that does not wrap.
ToBase64Transformemits one long line. Code that streams a "MIME-ready" attachment through it and then mails it produces a 120,000-character line that some transport will re-wrap in the middle of a group, which is exactly the corruption the 76-character rule was designed to prevent. - Encoding the encoding. Passing a Base64 string into the encoder because "the data is already text" produces a second layer. The encoder does not know, and does not care, that its input looks like Base64; it encodes the 65 characters, and the decoder on the other side gets a Base64 string where it expected your data.
How the Encoder Grew: A Version Tour
The encoding side of the API has its own timeline, and it runs from the second .NET release to the one currently in preview:
- .NET Framework 1.1, February 2003.
Convert.ToBase64StringandToBase64CharArrayarrive, the whole classic family in one release, with the slice overloads already included, which is a small miracle of foresight for a 2003 API. - .NET 2.0, 2005.
Base64FormattingOptionsand theInsertLineBreaksvalue join the family, bringing the MIME line-wrapping into the framework and ending an era of hand-rolledSubstringloops in email code. - .NET Core 2.1, 2018. The span era.
Convertgains the span-based encode andTryToBase64Chars, and the newSystem.Buffers.Text.Base64class arrives with itsOperationStatuscontract and the in-place inflate, built for the zero-allocation world. - .NET 5, 2020. The hex siblings (
Convert.ToHexStringand friends) ship, the same design pattern applied to a 16-symbol alphabet, and the conversion-class pattern becomes a house style. - .NET 6, 2021.
X509Certificate2.ExportCertificatePemmakes the framework produce PEM for you, armor markers, 64-character wrapping and Base64 body included, which quietly retires a class of manual certificate-formatting code. - .NET 9, November 2024.
System.Buffers.Text.Base64Urllands in the box after years of community requests, with theMicrosoft.Bcl.Memorypackage backporting it to .NET Framework 4.6.2 and up, and the unpadding behavior that JWT code had been hand-rolling ever since. - .NET 11, in preview at the time of writing. The next release, expected in late 2026, adds further Base64 convenience APIs and overloads to the existing types, continuing the march toward a more ergonomic surface.
The format itself has an older biography, and it is the reason the C# API looks the way it does. The first standardized use of what we now call MIME Base64 was the Privacy-Enhanced Mail protocol in 1987 (RFC 989), the MIME specification fixed the 76-character line-wrapped form in 1995, and RFC 4648 in 2006 gave the format its modern alphabet-aware specification, including the URL-safe variant that C# only got a first-class encoder for in 2024. Three decades of email and web conventions are why the line breaks, the padding, and the two alphabets all exist, and the C# encoder is the place where all three meet.
Little Wonders
- The four-character minimum. The smallest possible non-empty Base64 output is four characters, because the format thinks in groups of four even when you give it one byte. One byte of anything encodes to two letters and two
=signs, and that shape, two data characters wearing a padding hat, is a fingerprint you will start to recognize in configs and tokens. - Nulls are welcome. The encoder has no opinion about what the bytes mean, so a buffer full of zeros encodes happily into a wall of
Acharacters, and a binary file with its NUL bytes intact round-trips without losing a single one. The "strings cannot hold binary" anxiety belongs to the string side of the type system, not to the encoder, which never sees a string. - Determinism as a feature. The same bytes, the same options, always the same string. There is no timestamp, no random salt, no variation, which is why a Base64 string makes a serviceable quick-and-dirty fingerprint of a file's contents: two files with the same Base64 are the same file, and the check is a string comparison.
- Two bytes per character, free of charge. A C# string is UTF-16, so every character in your Base64 output occupies two bytes in managed memory. The encoder does not announce this, the length property does not report it, and a 13 million character string simply weighs 26 MB, which is the number to have in your head when the payload is big.
- CRLF by heritage. The MIME wrapping inserts carriage-return-line-feed pairs even when your code runs on Linux, because the rule comes from the email specification, not from the platform. The encoder is a historian as much as a converter, and it preserves the line endings of 1995 on a 2026 machine.
- A slice overload from day one.
ToBase64String(byte[], int, int)has encoded a window into a bigger array since 2003, twenty years before spans made the idea fashionable. The 1.1-era API designers looked at real buffers and added the offset-and-length form, and it is still the right call when the data is a section of a larger read. - The 64-character certificate line. PEM wraps at 64 characters, not 76, and
ExportCertificatePemknows this and wraps accordingly, which is one of the quiet details that makes "let the framework do it" the right advice for certificate work. Two wrapping widths, one format family, and the framework keeps them straight. - One alphabet, two names. The same 64 values are called "standard" in one part of the API and "URL-safe" in another, and the difference is exactly two characters: the 62nd and 63rd slots.
+and/on one side,-and_on the other, and every interop bug in the encoding article lives in the moment someone assumed the two sides were the same.
Coming Full Circle
That is the encoder side, and it is where you make the decisions: the alphabet, the padding, the line breaks, the charset, the buffer. The other direction, receiving Base64 from other people, with their padding choices, their line breaks, their alphabets and their tokens, is where most of the pain lives, because you cannot negotiate with a payload. Decoding Base64 in C#, from the classic one-liner to the span and URL-safe families, is covered in depth in the companion article linked below, and between the two of them the whole subject fits in your working memory, which is the point of a format this old and this small.
Last updated: 2026-08-30
Related article: Base64 Decoding in C# (CSharp): A Complete Guide