Base64 Encoding in Ruby: A Complete Guide
Your data has a destination that does not accept the way it looks. An image that has to live inside a JSON document. A token that has to cross a URL. An attachment that has to survive a protocol designed for seven-bit text. A secret that has to sit in an environment variable without breaking the quoting. In each of these places, something between here and there is about to destroy your binary - and the fix has a name: Base64.
In Ruby, the entire job lives in one module that ships with the language. Three encoders, nothing to install, and output you can predict to the exact character before you ever run the code. That predictability is the half of the story most guides skip, because encoding is where the surprises charge their fee: a trailing newline creeps into your JSON, a line break you did not ask for splits a token, and one wrong alphabet choice ruins a URL. This guide walks through all three encoders, the math of the output, and every payload a Ruby developer actually encodes, so the surprises stop being surprises.
A quick refresher before we start: Base64 rewrites data three bytes at a time, emitting four characters from a 64 symbol alphabet, with one or two = characters of padding when the input does not divide evenly by three - which is also why the output ends up roughly a third larger than the input. The home page of this site covers the format thoroughly, so this article keeps the format talk to one breath and goes straight to work.
Which Encoder Do You Need?
Ruby gives you three encoders, and the choice between them is a three-question quiz: may the output contain line breaks? May it contain + or /? May it contain padding? Here is the whole lineup:
| Encoder | Output shape | Line breaks | Padding | Reach for it when |
|---|---|---|---|---|
Base64.strict_encode64(bin) |
one line, standard alphabet | never | always present | JSON, tokens, APIs, files - the safe default |
Base64.encode64(bin) |
several lines, standard alphabet | after every 60 characters, plus a trailing one | always present | email bodies and other line-oriented text protocols |
Base64.urlsafe_encode64(bin, padding: true) |
one line, hyphen-underscore alphabet | never | your choice, on by default | anything that lands in a URL, cookie or identifier |
If you are deciding under time pressure, the short answer is: strict_encode64 by default, urlsafe_encode64 when the result will travel inside a URL, and encode64 only when the receiving side is a text protocol that wants short lines. Everything below explains why, and where each choice quietly costs you.
strict_encode64: The Workhorse
Base64.strict_encode64 is the encoder you will actually use in the great majority of your code. It produces exactly one line of output, always with the correct padding, from the standard alphabet:
require "base64"
Base64.strict_encode64("hello world")
# => "aGVsbG8gd29ybGQ="
Base64.strict_encode64("s")
# => "cw=="
And because the algorithm is deterministic, you can predict the exact length of the output from the input - no guessing, no off-by-one bugs in your database columns. The table below is the whole arithmetic:
| Input length | Output length | Padding at the end |
|---|---|---|
| 3n bytes (divides evenly) | 4n characters | none |
| 3n + 1 bytes | 4n + 4 characters | two = |
| 3n + 2 bytes | 4n + 4 characters | one = |
So 11 bytes become 16 characters, 100 bytes become 136, and a 1 megabyte file becomes about 1.33 megabytes of text. That one-third growth is the price of admission for every Base64 payload you ship, and it is the number to keep in your back pocket whenever a column, a cache, or an API rate limit starts to feel tight.
Base64.strict_encode64("123")
# => "MDEy" 3 bytes in, 4 characters out
Base64.strict_encode64("1234")
# => "MDEyMw==" 4 bytes in, 8 characters out, two pad characters
Base64.strict_encode64("12345")
# => "MDEyMzQ=" 5 bytes in, 8 characters out, one pad character
encode64: The One That Adds Newlines
Base64.encode64 is the classic, and it has one behavior that has ended more than one afternoon: it wraps its output. Every 60 characters, it starts a new line, and it always finishes with a trailing line break:
Base64.encode64("hello world")
# => "aGVsbG8gd29ybGQ=\n"
Base64.encode64("*" * 46)
# => "KioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioq\nKg==\n"
The wrapping is not a bug, it is a feature inherited from the method's original home in the MIME world, where long lines were a protocol violation. Ruby's mail gem leans on it deliberately - its Base64 encoder even carries a comment to the effect that Ruby's line wrapping keeps the output within SMTP line length limits. If you are encoding email bodies, encode64 is doing you a favor.
But in every other context, the wrapping is a tax. The most common accident is a JSON document where a Base64 value suddenly spans two lines:
payload = { "logo" => Base64.encode64(File.binread("logo.png")) }
puts payload.to_json
# the logo value carries line breaks nobody asked for
And the smaller twin of the same bug is the trailing newline on short strings: Base64.encode64("s") returns "cw==\n", so a token you paste into a URL or compare against an expected value fails for reasons you will spend twenty minutes chasing. The cure is a strip - but the better cure is strict_encode64, which never adds a single character you did not earn. There is also one charming asymmetry worth knowing: an empty input produces an empty string with no trailing newline, so Base64.encode64("") is just "".
urlsafe_encode64: The Link-Safe Alphabet
Two characters in the standard alphabet cause trouble anywhere a URL parser is watching: + (a space, in query strings) and / (a path separator). RFC 4648 solved this with a swap - - takes the place of +, _ takes the place of / - and Ruby implements it in Base64.urlsafe_encode64:
Base64.urlsafe_encode64("\xfb\xef\xbe".b)
# => "----"
Base64.urlsafe_encode64("\xff\xff\xff".b)
# => "____"
Those two examples are the alphabet on display: the same bytes that the standard encoder renders as ++++ or //// come out as ---- and ____, characters that survive URLs, paths, filenames and form fields without any percent-encoding at all. The output is one line, just like strict_encode64.
The method's one option is the padding: keyword, added in Ruby 2.3, and it is the one to know. The JSON Web Token specification demands Base64url without padding, and so do many other token schemes:
Base64.urlsafe_encode64("*")
# => "Kg=="
Base64.urlsafe_encode64("*", padding: false)
# => "Kg"
With padding off, the length math shifts: 3n + 1 bytes now yield 4n + 2 characters and 3n + 2 bytes yield 4n + 3. The decoder side copes - Ruby's urlsafe_decode64 adds the missing padding itself - so unpadded output is safe to emit, but padded output is the friendlier default when the other side is a strict RFC 2045 reader. One caution: turn padding off only when a specification asks for it. It saves one or two characters and buys you a class of decoder complaints.
What Ruby Actually Encodes: Strings Are Bytes
Before the use cases, one Ruby-specific fact that shapes everything: a Ruby string is a sequence of bytes wearing an encoding tag, and the encoders look only at the bytes. The tag tells Ruby how to display and compare the string; it does not change what gets encoded:
require "base64"
s = "h\u{e9}llo"
puts s.encoding
# => UTF-8
puts s.bytes.length
# => 6 the accented e is two bytes
Base64.strict_encode64(s)
# => "aMOpbGxv"
That is the trap behind "why is my output longer than I expected": the string you typed is usually shorter in characters than it is in bytes, and Base64 charges per byte. The reverse direction is just as quiet - an invalid UTF-8 string is encoded without any complaint, because the encoder has nothing to validate:
broken = "h\u{e9}llo".b.force_encoding("UTF-8")
broken.setbyte(1, 0xFF)
puts broken.valid_encoding?
# => false
Base64.strict_encode64(broken)
# => some base64, no error, bytes are bytes
For genuine binary, skip the text machinery entirely and build bytes with pack or read them with File.binread. A satisfying example is the PNG signature - the eight bytes that open every PNG file on Earth:
png_magic = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A].pack("C*")
Base64.strict_encode64(png_magic)
# => "iVBORw0KGgo="
JWTs: Signing Data That Is Also Readable
JSON Web Tokens are the highest-profile consumer of Ruby's URL-safe encoder. A token is three Base64url segments joined by dots - header, payload, signature - and the specification is explicit: the alphabet must be the URL-safe one, and the padding must be off. The jwt gem handles all of it:
# Gemfile: gem "jwt"
require "jwt"
token = JWT.encode(
{ sub: "1234567890", name: "Alice", exp: Time.now.to_i + 3600 },
"my-secret-key",
"HS256"
)
puts token
# => eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIi...
payload, header = JWT.decode(token, "my-secret-key", true, algorithm: "HS256")
puts header
# => {"alg"=>"HS256"}
You can also watch the Base64 layer doing its job inside the token, because the segments are just Base64url of JSON:
require "base64"
require "json"
payload_json = JSON.generate({ "sub" => "1234567890", "name" => "Alice" })
segment = Base64.urlsafe_encode64(payload_json, padding: false)
puts segment
# => eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIn0
Two rules belong with this use case. Never roll your own JWT by hand in production - the signature is what makes a token anything other than a confession - and when you decode with the gem, pin the algorithm in the options hash as shown above, so the token's own header cannot choose the verification method for you.
HTTP Basic Auth: Building the Header
The oldest way to say "who am I" in HTTP is still the simplest: Base64 the credentials, put them after the word Basic, and send the header. Building it in Ruby is one line:
require "base64"
credentials = Base64.strict_encode64("alice:s3cr3t!")
puts "Basic #{credentials}"
# => Basic YWxpY2U6czNjcjN0IQ==
Ruby's standard library does exactly this for you in Net::HTTP, calling the core pack template directly - "user:pass".pack("m0") is what basic_auth reduces to under the hood:
require "net/http"
request = Net::HTTP::Get.new("https://example.org/api")
request.basic_auth("alice", "s3cr3t!")
puts request["Authorization"]
# => Basic YWxpY2U6czNjcjN0IQ==
And the security caveat, stated once so it is on the record: Base64 is a translator, not a lock. The credentials in a Basic auth header are readable by anyone who can read the packet. This header is only acceptable over HTTPS, where the transport does the actual protecting.
Data URIs: Inline Images and Fonts
A data URI is the web's answer to "I want this image without a separate file": a media type, the word base64, a comma, and the bytes. It is how single-file HTML demos ship their logos, how favicons hide inside CSS, and how a generated image can live entirely in a template string:
require "base64"
png = File.binread("logo.png")
data_uri = "data:image/png;base64,#{Base64.strict_encode64(png)}"
css = "background-image: url(#{data_uri});"
puts css.length
# => your stylesheet, minus one HTTP request
Use strict_encode64 here - the payload is a single clean line, no wrapping, no newline. And watch the size: the image you inline grows by about a third, so data URIs shine for small assets (favicons, logos, icon fonts) and bloat for large ones. A two-megabyte hero photo becomes 2.7 megabytes of your HTML document, and your users will feel it on their first 4G scroll.
Email: Where Base64 Came From
Every other use case in this article is a descendant of this one. SMTP was designed in the 1980s for short lines of seven-bit text, which is to say it could not carry a JPEG. The fix - Privacy-Enhanced Mail in 1987, then MIME in 1998 - was to rewrite binary as text with a 64 symbol alphabet, which is exactly the format you are using today. The scars are still visible in Ruby's output: encode64 wraps at 60 characters because short lines are how email stays polite.
In practice you will let the mail gem do the MIME work. Attach a binary file and the gem picks the Base64 encoder, wraps the lines, and writes the headers:
# Gemfile: gem "mail"
require "mail"
message = Mail.new do |m|
m.from = "dev@example.org"
m.to = "ops@example.org"
m.subject = "Binary report"
m.add_file("report.bin")
end
puts message.encoded
# the attachment part carries Content-Transfer-Encoding: base64
Non-ASCII text in headers gets the same treatment in a slightly different costume: RFC 2047 encoded words, which wrap Base64 in a charset tag between question marks, like =?UTF-8?B?w7wgc2VjcmV0cw==?=. If you ever build or parse those by hand, the Base64 inside is the ordinary kind, decoded with decode64 and then re-tagged with the charset the word declares.
PEM Armor for Keys and Certificates
Keys and certificates wear PEM armor, and the armor is Base64 with a frame: a BEGIN line, the encoded bytes in lines of 64 characters, and an END line. If you ever need to produce a PEM file from raw DER bytes, the construction is a two-step wrap:
require "base64"
der_bytes = File.binread("server.der")
body_lines = Base64.strict_encode64(der_bytes).scan(/.{1,64}/)
pem = (["-----BEGIN PRIVATE KEY-----"] + body_lines +
["-----END PRIVATE KEY-----"]).join("\n") + "\n"
File.write("server.key", pem)
Two notes. First, you will almost never need this, because the openssl gem writes PEM for you (key.to_pem), and the label between the BEGIN and END lines has to match the thing inside - getting it wrong produces a file that every tool on the internet refuses. Second, the line length here is 64, the classic PEM width; Ruby's encode64 wraps at 60 instead, and every decent PEM parser ignores line lengths entirely, so either width decodes fine.
Files: The .b64 Convention
The most common file format in the Base64 world is a plain text file with a .b64 (or .base64) extension holding one encoded payload - think of it as "the file, but safe to paste anywhere". Producing one from Ruby is a one-liner:
require "base64"
File.write("payload.b64", Base64.strict_encode64(File.binread("payload.bin")))
puts File.size("payload.b64")
# => roughly 1.33 times the original size
Use strict_encode64 so the file holds a single clean line - the convention most decoding tools (and Ruby's strict decoder) expect. Reading it back is the mirror image: read, decode, and write the bytes in binary mode so nothing mutates them on the way out:
encoded = File.read("payload.b64")
bytes = Base64.strict_decode64(encoded)
File.binwrite("restored.bin", bytes)
If your .b64 files come from tools that wrap lines - some base64 CLI variants do - strip the line breaks before a strict decode, or use the lenient decoder, which skips them for free.
Config Files, Environment Variables and Databases
Whenever binary data has to live inside a text document, Base64 is the bridge. The pattern repeats in three places with small variations.
Environment variables and .env files cannot hold raw bytes, so the bytes get encoded before they leave the machine that has them:
require "base64"
# somewhere you provision the app
ENV["APP_LOGO"] = Base64.strict_encode64(File.binread("logo.png"))
# somewhere the app starts up
b64 = ENV.fetch("APP_LOGO")
File.binwrite("logo.png", Base64.decode64(b64))
YAML has a native binary type, and Psych handles the Base64 for you - a BINARY string dumped to YAML comes out as a !binary scalar, and loads back byte-identical:
require "yaml"
yaml_text = YAML.dump({ "logo" => File.binread("logo.png") })
puts yaml_text.lines.first(2)
# => "---"
# => "logo: !binary |-"
data = YAML.load(yaml_text)
puts data["logo"].encoding
# => ASCII-8BIT
In databases the question is storage type, not encoding. If your database has a real binary column - BLOB, BYTEA, VARBINARY - use it, and let the driver carry the bytes. Base64-in-a-TEXT-column is the pattern for when the storage layer only speaks strings: some document stores, JSON-shaped APIs, or a legacy schema you cannot change. The price is the one-third size tax on the column, and the discipline to encode on the way in and decode on the way out at every boundary, without exception.
Checksums That Travel as Text
Hashes are binary, but checksums mostly travel in text: file integrity lists, cache keys, fingerprints, log lines. Ruby's digest classes each have a base64digest method that does the encode in one call:
require "digest"
Digest::SHA256.base64digest("hello")
# => "LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ="
The output is padded standard Base64 - the same thing you would get from Base64.strict_encode64(Digest::SHA256.digest("hello")) - so it is safe to store, compare and paste. The one decision is consistency: a checksum list generated with Base64 has to be checked against Base64 output, and hex and Base64 representations of the same hash are different strings, so pick one and stick with it.
Encoding Big Things in Small Chunks
Like the decoders, the encoders are buffer-based: they read the whole input and emit the whole output. There is no streaming encoder in the standard library, so for large payloads the plan is memory, and there is a pleasant symmetry to the math. Encoding grows your data by a third, so the output - not the input - is your biggest allocation, and for a 1 gigabyte file you should expect roughly 1.33 gigabytes of text in front of you.
If that is too much to hold at once, you can encode in chunks, because the Base64 alphabet is self-synchronizing on three-byte boundaries: encode each 3-byte slice independently and the concatenation is identical to encoding the whole:
require "base64"
require "securerandom"
bin = SecureRandom.random_bytes(10_001)
whole = Base64.strict_encode64(bin)
chunked = bin.scan(/.{1,3}/m).map { |slice| Base64.strict_encode64(slice) }.join
puts chunked == whole
# => true
The same trick gives you a hand-rolled line wrapper that matches encode64 exactly: 45 bytes always encode to exactly 60 characters, so slicing the input at 45 bytes and joining the pieces with line breaks reproduces the classic MIME output, one line at a time, with only one slice in memory at a stretch:
def wrap_like_encode64(bin)
lines = bin.scan(/.{1,45}/m).map { |slice| Base64.strict_encode64(slice) }
lines.join("\n") + "\n"
end
bin = SecureRandom.random_bytes(10_001)
puts wrap_like_encode64(bin) == Base64.encode64(bin)
# => true
From the Command Line
Encoding does not need a script file either. The one-liner form reads a file and writes its Base64 to stdout:
ruby -rbase64 -e 'print Base64.strict_encode64(File.binread(ARGV[0]))' payload.bin > payload.b64
And the pipe form reads stdin, which is how you would wrap a stream of bytes from any other command:
some_command | ruby -rbase64 -e 'print Base64.strict_encode64(STDIN.read)'
Keep print in both - a stray puts would append a newline to your Base64, and for strict_encode64 output that turns a clean token into a broken one. The same rule of thumb as on the decoding side: if the next consumer of your output is strict, nothing but the Base64 itself may ride along.
The Traps That Cost Ruby Developers Extra Bytes
- The trailing newline in JSON.
Base64.encode64ends every non-empty result with a line break, so a value that should be a clean token arrives in your JSON with a surprise\nat the end. Usestrict_encode64for anything that will be stored, compared or sent in a single line. - The 60-character wrap in tokens and URLs. The same method wraps long output into multiple lines. A wrapped string in a URL is two URLs, and a wrapped token is a broken one. Again:
strict_encode64, orstrip/deletethe line breaks if you are stuck withencode64output. - Plus and slash in URLs. Standard Base64 in a query string means percent-encoding
%2B,%2Fand%3Don the way out and hoping the other side decodes them.urlsafe_encode64removes the problem at the source. - Padding in the wrong place. JWTs and other token schemes want padding off; MIME readers may not cope with missing padding. Emit
padding: falseonly where a specification asks for it, and know which side of that fence each of your consumers sits on. - Characters are not bytes. A five-character string with one accented letter is six bytes in UTF-8, and the output length math runs on bytes. When the encoded result is "too long", count bytes, not characters.
- The one-third tax in schema design. A 16 KB BLOB becomes a roughly 22 KB Base64 string in a TEXT column. Size your columns, caches and API payloads for the encoded form, not the binary form.
- Two alphabets, two different strings. The same bytes encode differently in the standard and URL-safe alphabets, so an encoded value is only comparable against another value from the same alphabet. Never compare or mix them.
- Base64 is not a lock. Encoding a secret does not make it secret. Anyone with the string has your data; Base64 only controls how the bytes look, not who can read them.
Habits That Save Bytes and Bugs
- Make
strict_encode64your default. Switch tourlsafe_encode64the moment the output will live in a URL, cookie or identifier, and toencode64only when the destination is a line-oriented text protocol like email. - Keep the alphabet consistent between the encoder and the decoder on both ends of the wire. The single most common "Base64 broken" bug is a standard-alphabet producer meeting a URL-safe consumer, or vice versa.
- Feed the encoders bytes you mean to encode:
File.binreadfor files,packfor constructed binary, and a UTF-8 string when the string is the data. The encoder will not question your choices - it just counts bytes. - Budget the growth. Whenever a Base64 string crosses a boundary into a sized container, multiply by 4/3 and add a little slack for padding.
- Use Base64 for portability, never for secrecy. If the goal is keeping the data private, the tool is encryption, and Base64 is only what you do with the ciphertext afterwards.
How Base64 Became a Gem
For most of its life, the Base64 module was just a file in the standard library, the way a lot of Ruby's oldest helpers are. The strict and URL-safe methods joined the original pair during the 1.9 development line, around 2010 to 2011, and the padding: keyword arrived with Ruby 2.3 in 2015. Everything about the API you see today had settled by then - the rest of the story is about how the module ships.
In 2020, with Ruby 3.0, the core team began extracting standard libraries into their own gems, and base64 became one of them: version 0.1.0, maintained in the ruby/base64 repository by the core contributors. It shipped as a default gem - distributed with Ruby and always available, so require "base64" kept working with zero ceremony. Version 0.2.0 followed with Ruby 3.3 in 2023, adding the Base64::VERSION constant and a much richer documentation set.
Then Ruby 3.4 in December 2024 redrew the line: base64 moved from the default gem list to the bundled gem list, the same shelf as csv and drb. Bundled gems still ship with the language, but Bundler-based projects are expected to declare them, so if you are on Ruby 3.4 or later and your app is Bundler-driven, add gem "base64" to your Gemfile (or run gem install base64) and you are covered. Ruby 4.0 in 2025 brought version 0.3.0, with RBS type signatures so static checkers can see the module properly.
Throughout the whole journey the implementation stayed what it always was: a few dozen lines of pure Ruby wrapped around the core pack and unpack templates. No C extension, no dependencies, and - with a download count in the hundreds of millions on rubygems.org - one of the most installed gems on the platform.
Fun Ruby Facts
- The entire module, encoders included, is short enough to read in one coffee break.
encode64is literally[bin].pack("m"),strict_encode64is[bin].pack("m0"), andurlsafe_encode64is a strip of padding plus a character translation on top of the strict encoder. - The 60-character wrap of
encode64matches neither MIME's 76-character maximum nor PEM's classic 64. It is simply what thempack template has always done, and themailgem's Base64 encoder comments on it approvingly: Ruby's automatic line wrapping keeps the output within SMTP limits. - Ruby's
Net::HTTPdoes not bother with theBase64module for Basic auth - it calls thepacktemplate directly, which is a nice reminder that the module is a convenience layer over the core, not the other way around. - Every digest class carries a
base64digestmethod, soDigest::SHA256.base64digestis a first-class citizen next tohexdigest- checksums in text without a second call. - YAML's
!binarytag is Base64 in disguise. Psych does the encoding the moment you dump a BINARY string, which is why config files full of binaries look the way they do. - The module you are using was not always the module you remember. Old Ruby had
b64encode(wrapping at a chosen width) anddecode_b(RFC 2047 header decoding); both vanished in the 1.9 line, so any pre-2010 code you inherit that calls them dies with aNoMethodError. - YouTube's video IDs are Base64url without padding - the RFC that invented the URL-safe alphabet literally cites YouTube as the use case - which is why those eleven-character IDs never contain a plus, a slash or an equals sign.
The Flip Side
You now have the complete encoding picture: a default workhorse that never surprises you, a classic that wraps lines for the protocols that demand it, a link-safe alphabet with a padding switch, and the byte-level rules that decide exactly what your output will look like. The reverse direction - taking a Base64 string apart, choosing between Ruby's three decoders, and turning the resulting bytes into something you can use - has its own quiet traps, starting with a decoder that never says no. That side of the street is covered in depth in the Base64 decoding article, linked below.
Last updated: 2026-08-29
Related article: Base64 Decoding in Ruby: A Complete Guide