Base64 Encoding in R: A Complete Guide
You have bytes that need to travel, and the road only allows text. A JPEG that has to live inside a JSON field. A certificate that has to sit in an environment variable. A plot that has to travel inside a self-contained HTML report. Base64 is the packing tape for all of it: any sequence of bytes becomes a string of 64 harmless characters that survives every text channel you can throw at it. The home page of this site covers the format in full, so here is the short version: three bytes go in, four characters come out, drawn from A to Z, a to z, 0 to 9, plus + and /, with a little = padding at the end when the cargo does not divide evenly.
The R-specific twist: base R ships no Base64 encoder at all. There is no base64_encode() waiting in a base package, and no one-line builtin you can reach for. You pick a package, and the ecosystem genuinely gives you a choice, with different speeds, different wrapping habits, and different opinions about padding. By the end of this article you will know which encoder to reach for in each situation, and which of them will quietly do something other than encode.
The Encoder Landscape
Five packages do the encoding, and they split into the everyday workhorses, the crypto-adjacent ones, and the small specialists. Here is the cast, current as of 2026:
| Package | Version (2026) | Encode entry points | Wrapping habits | Reach for it when |
|---|---|---|---|---|
base64enc |
0.1-6 | base64encode() |
linewidth and newline, entirely yours to set |
everyday strings, MIME wrapping |
openssl |
2.4.2 | base64_encode() |
64 character lines, LF breaks, trailing newline | PEM files, existing crypto stacks |
b64 |
0.1.7 | encode(), encode_file() |
never wraps; b64_chunk() and b64_wrap() on demand |
speed, vectors, URL safe engines |
base64 |
2.0.2 | encode() |
64 character lines plus a trailing newline by default | file to file chores, report images |
base64url |
1.4 | base64_urlencode() |
never wraps, no padding, character in | URL safe strings |
Three more encoders hide inside packages you may already load. jsonlite exports base64_enc() and base64url_enc(), so if you already parse JSON you may already have an encoder on hand. jose exports base64url_encode() for JWT work. And the veteran RCurl package still carries a base64() wrapper around libcurl that works fine and belongs to a previous era. The base64 package, finally, now describes itself on the tin as a compatibility wrapper and points new applications at base64enc, openssl or jsonlite.
Setting Up
If R is not on the machine yet, your operating system ships it: r-base on Debian and Ubuntu, R on Fedora, Homebrew or the official installer on macOS, an installer on Windows. Then the encoders, straight from CRAN:
install.packages("base64enc")
install.packages("openssl")
install.packages("b64")
install.packages("base64url")
The two places installs go sideways are both build time. openssl compiles against your system OpenSSL, so a bare Linux box wants the development headers first (sudo apt install libssl-dev), or you can skip the compilation entirely on Debian and Ubuntu with sudo apt install r-cran-openssl. And b64 is a Rust engine wrapped with extendr, so a source build wants the Rust toolchain (sudo apt install cargo pulls in rustc as well). Windows and macOS get prebuilt binaries from CRAN and none of this applies.
The First Encode
Ninety percent of encoding life fits in three lines, using the same famous string the decode side uses as its smoke test:
library(base64enc)
packed <- base64encode(charToRaw("Man"))
packed
#> [1] "TWFu"
identical(packed, "TWFu")
#> [1] TRUE
Three things to notice in that ceremony. First, the input is a raw vector: charToRaw() is the bridge from your R string to the bytes that get packed, and every encoder in this table takes raw. Second, the output is the opposite direction from the decoders: a single character string, because encoding ends on the text side of the border. Third, watch the length math in action: three bytes in, four characters out, no padding needed because the cargo divides evenly. When the cargo does not divide, one or two = characters land at the end.
And because an encoder you do not trust is worse than none, here is the round trip that proves the two directions agree:
text <- "Hello, world!"
packed <- base64encode(charToRaw(text))
packed
#> [1] "SGVsbG8sIHdvcmxkIQ=="
identical(text, rawToChar(base64decode(packed)))
#> [1] TRUE
Strings, Bytes and the Filename Trap
Now the trap, because every R developer falls in it once. base64encode() treats a character argument as a filename, not as text to encode. Pass it a string and it goes looking for that file:
base64encode("Man")
#> Warning in file(what, "rb") :
#> cannot open file 'Man': No such file or directory
#> Error: cannot open the connection
base64encode(charToRaw("Man"))
#> [1] "TWFu"
The warning is the tell: it tried to file("Man", "rb"), meaning "open a file called Man for raw reading". So the discipline is one reflex: charToRaw() first, always. If a file is genuinely what you mean, that is what the function is doing, and the output is the file's bytes packed, which is sometimes exactly what you want.
b64 takes a different position on the same question: its encode() accepts a character vector directly, treats each element as UTF-8 text, and is vectorized to boot:
b64::encode("Man")
#> [1] "TWFu"
b64::encode(c("Man", "M"))
#> [1] "TWFu" "TQ=="
Two more edges of this border are worth knowing. The empty input is encoded three different ways, depending on who you ask:
base64encode(raw(0))
#> character(0)
b64::encode("")
#> [1] ""
base64url::base64_urlencode("")
#> [1] ""
base64enc answers with a zero-length character vector, not an empty string, so downstream code that expects a string and gets character(0) fails in surprising places. And the charset decision lives on the encode side too: charToRaw() packs the string in the encoding it is currently wearing, so UTF-8 text travels as UTF-8 bytes, which is what the other end of the wire expects. Emoji included, since modern R stores code points above U+FFFF as true UTF-8:
emoji <- "\U0001F600"
nchar(emoji, type = "bytes")
#> [1] 4
round_trip <- base64decode(base64encode(charToRaw(emoji)))
identical(emoji, rawToChar(round_trip))
#> [1] TRUE
Line Wrapping: MIME, PEM and Your Own Width
Long Base64 strings get broken into lines, because the oldest text channels in the world had column limits, and MIME never got around to forgetting about them. The two historical wraps you will meet are MIME, which breaks at 76 characters with CRLF between lines, and PEM, which breaks at 64. Every encoder has its own idea of which one, if any, to use, so this is the section where you pick the contract before you encode.
The size math first, because wrapping is about knowing how long the output gets. Four characters come out for every three bytes, which makes the length of the encoded form a simple ceiling:
nchar(base64encode(charToRaw(strrep("a", 100))))
#> [1] 136
4 * ceiling(100 / 3)
#> [1] 136
With that in your pocket, the encoders. base64enc is the most flexible: by default it emits one unbroken line, and the linewidth argument hands you a vector of lines instead:
long <- charToRaw(strrep("R", 100))
base64encode(long, linewidth = 76)
#> [1] "UlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJS"
#> [2] "UlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUg=="
base64encode(long, linewidth = 76, newline = "\r\n")
#> [1] "UlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJS\r\nUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUg=="
Two lines for 100 bytes at width 76, a vector by default, a single CRLF-joined string when you add newline. There is no trailing empty element: 114 bytes, which encode to exactly two lines of 76, comes back as two lines, not three.
openssl takes the other pole. linebreaks = TRUE wraps at 64 characters with plain LF breaks, and adds one more newline at the very end:
wrapped <- openssl::base64_encode(charToRaw(strrep("a", 100)), linebreaks = TRUE)
nchar(wrapped)
#> [1] 139 # 136 data characters plus 3 line breaks
nchar(strsplit(wrapped, "\n", fixed = TRUE)[[1]])
#> [1] 64 64 8
Count the arithmetic: 136 data characters, two internal breaks, one trailing break, 139 total. And that trailing break is invisible to the most natural line count you can write, because strsplit() drops a trailing empty piece, so the vector says three lines while the string carries four. If you ever diff an OpenSSL wrapped output against a MIME wrapped one and the character counts do not add up, this is the ghost.
b64 does not wrap at all; it gives you the two operations separately, and the chunk width has one rule, a multiple of four, because the engine refuses to cut a Base64 group in half:
enc <- b64::encode(strrep("a", 100))
ch <- b64::b64_chunk(enc, 76)
b64::b64_wrap(ch, "\r\n")
#> [1] "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFh\r\nYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYQ=="
b64::b64_chunk(enc, 75)
#> Error: Chunk size must be a multiple of 4.
And the file-oriented base64 package follows OpenSSL's lead: 64 character lines, a trailing newline, on by default:
writeBin(charToRaw(strrep("b", 200)), "big.bin")
base64::encode("big.bin", "big.b64")
con <- file("big.b64")
lines <- readLines(con)
close(con)
nchar(lines)
#> [1] 64 64 64 64 12 0
That last zero is the trailing newline, caught by readLines() as an empty final line. Here is the whole field at once:
| Encoder | Width | Line break | Trailing newline |
|---|---|---|---|
base64encode(x) |
one line | none | none |
base64encode(x, linewidth = 76, newline = "\r\n") |
76 | CRLF | none |
openssl::base64_encode(x, linebreaks = TRUE) |
64 | LF | yes |
b64::encode(x) |
one line | none (wrap with b64_chunk() and b64_wrap()) |
none |
base64::encode(in, out) |
64 | LF | yes |
base64url::base64_urlencode(x) |
one line | none | none |
URL Safe Base64
Standard Base64 spends the last two slots of its alphabet on + and /, and those are exactly the characters URLs do not love: plus becomes %2B, slash becomes %2F, and each = of padding becomes %3D. The URL safe variant, defined in RFC 4648 section 5, swaps those two letters for - and _ and usually drops the padding, so a token that should be paste-anywhere stays paste-anywhere. R has three doors into it.
The b64 engines are the most complete: an engine is a configured alphabet and padding policy, and the package ships the four you need. Feed the same three bytes to the standard engine and to the URL safe one and watch the alphabet do its job:
bytes <- as.raw(c(0xfb, 0xef, 0xbe))
b64::encode(bytes)
#> [1] "++++"
b64::encode(bytes, engine("url_safe"))
#> [1] "----"
b64::encode(as.raw(0x4d), engine("url_safe"))
#> [1] "TQ=="
b64::encode(as.raw(0x4d), engine("url_safe_no_pad"))
#> [1] "TQ"
Four engines, "standard", "standard_no_pad", "url_safe", and "url_safe_no_pad", and the same engine object works in both directions, which keeps your code symmetric. The no padding variants only differ when padding would actually appear, as in the one byte example above.
The dedicated base64url package is the single purpose door: character in, URL safe string out, never wraps, never pads:
base64url::base64_urlencode("hello world")
#> [1] "aGVsbG8gd29ybGQ"
And jose exports its own base64url_encode() for JWT work, returning raw vectors on the decode side the way you would hope. One rule binds all three doors: the alphabet you encode with is the alphabet you must decode with. Hand a standard decoder the string "----" and it fails on the first dash; the sister article on decoding covers how each decoder meets dirty or mismatched input.
JWTs: Making the Token
The JSON Web Token is where URL safe Base64 became a daily driver. A JWT is three Base64url parts joined by dots: a header describing how it was signed, a payload of JSON claims, and a signature that binds the two. On the encode side you are building all three, and the jose package does the whole ceremony. Its 2.0 API (April 2026) is built around jwt_* functions, so tutorials showing jwk_hs256() and jws_sign() are describing the retired 1.x API:
library(jose)
claim <- jwt_claim(iss = "app", sub = "1234567890", exp = Sys.time() + 3600)
token <- jwt_encode_hmac(claim, "0123456789abcdef")
sp <- jwt_split(token)
sp$header
#> $typ
#> [1] "JWT"
#>
#> $alg
#> [1] "HS256"
names(sp$payload)
#> [1] "iss" "sub" "exp" "iat"
Note what jwt_claim() did behind the scenes: iat (issued at) defaults to the current time, so it appeared in the payload without being asked, while exp defaults to nothing, which means a token with no expiry, which is usually not what you want. Set exp deliberately, and the signature takes care of the rest. jwt_split() is the inspection tool: the header, the payload as a named list, and the raw signature, no verification involved, exactly the peeking step the decode side of this article pair describes.
The verification side is where jose earns its keep. jwt_decode_hmac() checks the signature and enforces the time claims, and its refusal style is specific:
round <- jwt_decode_hmac(token, "0123456789abcdef")
round$sub
#> [1] "1234567890"
jwt_decode_hmac(token, "ffffffffffffffff")
#> Error: HMAC signature verification failed!
old <- jwt_claim(sub = "1234567890", exp = Sys.time() - 60)
expired <- jwt_encode_hmac(old, "0123456789abcdef")
jwt_decode_hmac(expired, "0123456789abcdef")
#> Error: Token has expired on 2026-08-30 06:09:36
On success you get the claims back as an ordinary list, so round$sub and friends just work. A future nbf (not before) claim earns its own refusal, Token is not valid before ..., and an HMAC token will not be decoded by the asymmetric jwt_decode_sig(), which answers Unsupported algorithm: HMAC and wants a public key instead. Two final notes. First, the payload is encoded, not encrypted: anyone can read every claim, so nothing secret belongs in one. Second, if you load httr2 after jose, watch the masking message: httr2 exports its own jwt_claim(), jwt_encode_hmac() and jwt_encode_sig(), built for OAuth client credentials with exp defaulting to five minutes out, and they shadow the jose versions for the rest of the session.
Files: Binary In, Text Out
Encoding a file is the mirror of the decode side's file work, and the b64 package has the clearest entry point, which is also the fastest because it never builds one enormous intermediate string:
writeBin(charToRaw("file payload bytes"), "payload.bin")
enc <- b64::encode_file("payload.bin")
enc
#> [1] "ZmlsZSBwYXlsb2FkIGJ5dGVz"
cat(enc, file = "payload.b64")
readLines("payload.b64")
#> Warning in readLines("payload.b64") :
#> incomplete final line found on 'payload.b64'
#> [1] "ZmlsZSBwYXlsb2FkIGJ5dGVz"
The warning is a feature in disguise: cat() does not add a trailing newline, so the file ends mid-line, and readLines() tells you so. Remember which side of this fact you are on when the file will be consumed by b64::decode_file(), which panics on a trailing newline; the decode article has the full story. Write with cat() or writeBin(charToRaw(enc), path) and the edge stays dull.
The base64 package is the pure file to file option, with the matching pair of functions and OpenSSL style wrapping as the default:
base64::encode("payload.bin", "payload2.b64")
readLines("payload2.b64")
#> [1] "ZmlsZSBwYXlsb2FkIGJ5dGVz"
It returns the output path, which is convenient for logging. And when you want to see the machinery, the manual pipeline works with any encoder in the table: read the file as raw, encode, write the text, done:
bytes <- readBin("payload.bin", what = "raw", n = file.size("payload.bin"))
enc <- base64encode(bytes)
writeLines(enc, "payload3.b64")
identical(enc, readLines("payload3.b64"))
#> [1] TRUE
Data URIs and Self-Contained Documents
The data: URI scheme (RFC 2397) is the encode side's most visible consumer: a document that carries its own content, a MIME type, the word "base64", and the payload, all in one attribute. The PNG magic bytes make the pattern recognizable: every Base64 PNG in the wild starts with the same characters, because the file header 89 50 4E 47 always encodes the same way:
png_head <- as.raw(c(0x89, 0x50, 0x4e, 0x47))
uri <- paste0("data:image/png;base64,", base64encode(png_head))
uri
#> [1] "data:image/png;base64,iVBORw=="
tag <- paste0("<img src=\"", uri, "\" />")
If you have ever grepped an HTML file for iVBOR to find embedded images, that is why the fingerprint works. R Markdown reports, single file dashboards, and scraped pages all use the same shape, and building one is the three lines above: read the file as raw, encode, and paste it behind the MIME prefix. The cost is the size math from earlier, a third larger than the original, sitting in your HTML forever, so keep the embedded images lean.
APIs and Web Requests
The decode side of this article pair meets APIs that hand you Base64; this side meets APIs that want it. The pattern is the same every time: encode the bytes, put the string in the JSON body, send it. The modern HTTP client is httr2:
library(httr2)
req <- request("https://api.example.com/upload")
req <- req_body_json(req, list(file = packed, name = "man.txt"))
req
#> <httr2_request>
#> POST https://api.example.com/upload
#> Body: JSON data
res <- req_perform(req)
res$status_code
#> [1] 200
Three notes for the road. In httr2 1.3 and later the request constructor is request(); older tutorials show req(), which was the name before the rename. The response body, when you read responses, arrives as a raw vector, so rawToChar() before parsing. And the API may speak a dialect: some want the URL safe alphabet, some want the padding stripped, and a JSON API has no problem with = in a string value, so the padding question only becomes a URL question when the Base64 travels in the path or the query. When in doubt, read the API's examples rather than the spec in your head.
Databases, Configuration and Environment
Base64 in a database is binary smuggled through a text column, and the encode side is packing a blob before it goes in. Here is the round trip against SQLite through DBI and RSQLite:
library(DBI)
library(RSQLite)
db <- dbConnect(SQLite(), ":memory:")
dbExecute(db, "CREATE TABLE files (name TEXT, payload TEXT)")
stored <- base64encode(charToRaw("stored in a database"))
dbExecute(db, paste0("INSERT INTO files VALUES ('note.txt', '", stored, "')"))
row <- dbGetQuery(db, "SELECT * FROM files")
rawToChar(base64decode(row$payload))
#> [1] "stored in a database"
The alternative is to store the bytes natively as a BLOB, in which case no Base64 is needed at all and the column comes back to R as a raw vector. The Base64-in-TEXT variant exists for portability: you can inspect it with a text editor, diff it, and every other language can read it without binary drivers. That same argument carries it into configuration, where a certificate or a secret is stored as a string in YAML, JSON, or an environment variable:
Sys.setenv("API_CERT" = base64encode(charToRaw("LTSSECRET")))
Sys.getenv("API_CERT")
#> [1] "TFRTU0VDUkVU"
One caution belongs here, because configuration files are where secrets live: Base64 is encoding, not encryption. A Base64 value in a config file is readable by anyone who can read the file. It survives transport and text editors; it does not protect anything.
Email is where the 76 character wrap was born, and MIME attachments still wear it: Base64 content, broken at 76 characters with CRLF between the lines, inside a part that declares Content-Transfer-Encoding: base64. The encoder that produces exactly that shape is base64encode() with its wrapping arguments set to the MIME contract:
body <- "The quick brown fox jumps over the lazy dog, and then it came back down again."
mime_part <- base64encode(charToRaw(body), linewidth = 76, newline = "\r\n")
strsplit(mime_part, "\r\n", fixed = TRUE)[[1]]
#> [1] "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZywgYW5kIHRoZW4gaXQg"
#> [2] "Y2FtZSBiYWNrIGRvd24gYWdhaW4u"
R has no first class mail client, but the point stands whenever you build or inspect MIME parts by hand, generate .eml fixtures, or parse attachments out of one: this is the shape the Base64 has to have, and the decode side of this article pair shows the lenient decoders unwrapping it on the way back.
Big Payloads and the String Ceiling
R strings have a hard ceiling of 2^31 - 1 bytes, and because the encoded form is about a third larger than the original, a file of roughly 1.5 GB of raw data would push its one-line encoding past the wall. The practical move is the same one base64enc has offered since its 2022 long vector release: keep the output as lines, not one string:
big <- raw(10 * 1024 * 1024)
packed <- base64encode(big, linewidth = 76)
length(packed)
#> [1] 183961
sum(nchar(packed))
#> [1] 13981016
Ten megabytes of zeros become 183961 lines of at most 76 characters, a perfectly ordinary vector you can write out line by line or stream through a pipe without ever holding one enormous string. For the data frame case, a column of many binary values, b64 is the speed champion: its Rust engine encodes the whole column in one vectorized call, which is a dramatic difference from looping per row. If you have a column of encoded values to produce, run a quick system.time() comparison yourself; the gap between a per-row loop and one vectorized call is usually large enough to matter.
The Command Line
Not everything needs a full R session. The classic Unix tool speaks Base64 natively, encoding with no flags on every platform, and decoding with -d on Linux and -D on macOS and the BSDs:
echo -n "Hello, world!" | base64
#> SGVsbG8sIHdvcmxkIQ==
echo -n "SGVsbG8sIHdvcmxkIQ==" | base64 -d
#> Hello, world!
Watch the -n on the first line: without it, echo contributes a newline, and the output encodes 14 bytes instead of 13, ending in o= instead of IQ==. A GNU base64 will also wrap for you (-w 76), which is handy when piping into something that expects MIME shaped input. And a one line Rscript does the same job with the same packages you use in your scripts:
Rscript -e 'library(base64enc); writeLines(base64encode(charToRaw("Man")))'
#> TWFu
Use the shell for quick checks and pipes; use R when the result needs to live in a data frame, a file, or a report. And do not paste multi-megabyte strings into the terminal: command line arguments hit ARG_MAX long before Base64 does, so pipe through a file instead.
Pitfalls Worth Knowing
Here is the short list of the ways the encode side bites R developers, all of them native to the ecosystem rather than to Base64 in general:
- A string is a filename.
base64encode("Man")tries to open a file called Man. The warning names the file; the error says the connection failed.charToRaw()first, always. - Empty input is encoded three ways.
base64encode(raw(0))returnscharacter(0), whileb64::encode("")andbase64url::base64_urlencode("")return"". Downstream string code does not expect a zero-length vector. - openssl wraps and then adds a ghost line.
linebreaks = TRUEbreaks at 64 with LF and appends a trailing newline thatstrsplit()silently drops, so naive line counts and character counts disagree by one line. - Wrapping is a contract. MIME is 76 with CRLF, PEM is 64, JSON APIs usually want nothing at all. Pick the shape the other side expects, because a decoder that tolerates one wrap style will reject another.
- The URL safe alphabet must match on both ends.
"----"encoded URL safe fails in a standard decoder on the first dash, and padding becomes%3Dthe moment the string lives in a URL. - b64_chunk demands multiples of four. Any other width earns
Chunk size must be a multiple of 4., because a Base64 group cannot be cut in half. - A trailing newline can panic the decoder. Files you write for
b64::decode_file()to read must end without a newline;cat(), notwriteLines(). - JWT time claims are enforced.
jwt_decode_hmac()refuses expired tokens and futurenbfclaims, andhttr2masksjose'sjwt_*functions if you load it second. - The payload is visible. Base64 claims in a JWT, a data URI, or a config file are readable by anyone. Encoding is not encryption, and the ceiling of about 1.5 GB of raw data per R string is a wall, not a guideline.
Best Practices
- Convert with
charToRaw()before you encode, every time. If you need character input directly and vectorization with it,b64::encode()is the package that treats strings as strings. - Default to
base64enc::base64encode()for everyday work, reach forb64when you want speed, true vectorization, or the URL safe engines, and useopensslwhen it is already in the project and you want PEM shaped output. - Pick the wrapping per channel, not per package: nothing for JSON and URLs, 76 with CRLF for MIME, 64 for PEM style blocks, and stay consistent so the decoder side of the pipe knows what to expect.
- Use the URL safe alphabet without padding for anything that will live in a URL, a JWT, or a filename, and the standard alphabet for email.
- For JWTs, set
exp(andiat) explicitly, verify withjwt_decode_hmac()before trusting a token, and remember that every claim is public text. - Test your encode paths with a round trip,
identical(text, rawToChar(base64decode(base64encode(charToRaw(text))))); it is one line and it catches alphabet, padding, and charset mistakes at once. - For files, prefer
b64::encode_file()orbase64::encode()over reading the whole file into one string, and write your output withcat()when a strict decoder will read it. - Keep the packing tape honest: Base64 makes bytes travel, it does not make them secret and it does not make them smaller. Encrypt first if secrecy is the goal, compress first if size is.
How Base64 Got into R
The format arrived long before R did anything with it. It was standardized for the Privacy Enhanced Mail protocol in 1987 (RFC 989), adopted by MIME in the mid 1990s (RFC 1521, then the final RFC 2045, which still defines the 76 character wrap), tidied in RFC 3548 in 2003, and given its modern shape in RFC 4648 in 2006, which added the URL safe alphabet, the no padding option, and its smaller sibling Base32. The R story started in September 2012, when Simon Urbanek's base64enc landed on CRAN and quietly became the default answer to "how do I Base64 this" for over a decade, gaining checkUTF8() in 2015 and long vector support in 2022. The crypto world came in through openssl, Jeroen Ooms' long running wrapper around the system OpenSSL, whose base64_encode() has been the PEM shaped option ever since. The old base64 package, also by Ooms, was reissued in October 2024 explicitly as a compatibility wrapper, its own description now pointing new applications elsewhere. Then b64 arrived in 2025, a Rust engine built with extendr that brought vectorization and a stable of alphabets, and in April 2026 jose released its 2.0 redesign around jwt_* functions, making signed tokens a first class citizen. The result is a toolbox with one encoder per job: everyday strings, PEM blocks, speed, URLs, files, and tokens.
Fun Corner
Because a complete guide should end on a smile:
- Every Base64 PNG on the internet starts with the same characters: the magic header 89 50 4E 47 encodes to
iVBOR, so grepping an HTML file for that fingerprint finds every embedded image. Formats have fingerprints, and this one is a prefix. base64encode("Man")does not encode the word Man. It goes looking for a file named Man, warns that it cannot open it, and gives up. The single most R-specific trap in the Base64 ecosystem, hidden in plain sight in the argument list.- An OpenSSL wrapped string always ends with a trailing newline, so the last line of a PEM block is never the last line of the file. The wrap has a period at the end of the sentence, whether you want it or not.
- The empty string is encoded three different ways:
base64encreturns a vector of zero strings,b64andbase64urlreturn the empty string. R meets nothing, and R gives three answers. - jose writes the JWT header with
typbeforealg, while most hand written examples putalgfirst. JSON does not care about key order, and JWT verification knows it, but your string diff will not. - MIME's 76 character limit is a 1982 decision about message line length, carried through four RFCs into every email attachment you have ever sent. The wrap you are setting today was argued over before R had a color plot.
b64will decode alphabets you have never seen, BinHex, IMAP modified UTF-7, bcrypt, crypt, and the URL safe pair, with one engine each. The same Rust code that packs your JSON field can unpack a 1980s Macintosh attachment.
Wrap Up
Pick your encoder by the job: base64enc for everyday strings, with linewidth and newline when the channel has a shape; openssl when you want PEM style 64 character output or it is already in the project; b64 when you want speed, vectors, or the URL safe engines; and the small specialists base64 and base64url for file chores and URL safe strings. Convert with charToRaw() before you encode, choose the wrapping per channel rather than per package, keep the URL safe alphabet for anything that will live in a URL or a JWT, sign and verify tokens with jose, and test every path with a round trip. The format itself is unforgiving in exactly two places, the alphabet and the line breaks, and the encoders differ mostly in how honestly they tell you when you got one of those wrong. And when the other direction calls, when a string arrives and you need to take it apart, check what the bytes mean, and survive the decoders that fail silently, the sister article covers Base64 decoding in R in detail.
Last updated: 2026-08-30
Related article: Base64 Decoding in R: A Complete Guide