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

Base64 Decoding in R: A Complete Guide

A string lands in your R session. It looks like a shuffled alphabet soup: letters, digits, the occasional plus or slash, and a pair of equals signs hanging off the end. The person who sent it swears it was once a perfectly ordinary sentence, a JPEG, or a JSON document. That string is Base64, and this page is the recipe for turning it back into what it was.

A quick refresher, because the home page of this site explains the format in full depth: Base64 writes three input bytes as four characters drawn from a 64-symbol alphabet, and one or two trailing = characters mark where the real data ended. That four-for-three trade is why encoded text runs about a third larger than the original, and decoding simply runs the trade in reverse. Keep the shape of the problem in mind and let us open some envelopes.

Here is the twist that makes R a little different from many other languages: base R has no Base64 at all. There is no base64_decode() hiding in a base package, and no one-line builtin you can reach for. You have to bring a package. The good news is that the ecosystem offers several, each with its own personality, and by the end of this article you will know exactly which one to reach for and which one to distrust.

The Decoders at a Glance

Five packages do the heavy lifting, and they split into two broad camps: the lenient ones, which shrug at dirty input, and the strict ones, which treat the RFC like a contract. Here is the cast, current as of 2026:

Package Version (2026) Decode entry points Personality
base64enc 0.1-6 base64decode() Lenient by default, gained a strict mode in February 2026
openssl 2.4.2 base64_decode() Skips whitespace, but fails silently in ways that deserve a stern look
b64 0.1.7 decode(), decode_as_string() Strict, vectorized, written in Rust, fast
base64 2.0.2 decode() File to file convenience wrapper around openssl
base64url 1.4 base64_urldecode() URL safe alphabet, no padding, quietly forgiving

Three runners up deserve a mention. The jsonlite package exports its own helpers, base64_enc, base64_dec and the URL safe pair, so if you already parse JSON you may already have a decoder on hand. The jose package ships base64url_decode() for JWT work. And the ancient RCurl package still carries a base64() function that wraps libcurl: it works, it is character oriented, and it has the feel of a faithful grandparent rather than a tool for new code.

Installing the Cast

If R is not on the machine yet, your operating system ships it: r-base on Debian and Ubuntu, R on Fedora, a package or installer on macOS and Windows. Then the packages, straight from CRAN:

install.packages("base64enc")
install.packages("openssl")
install.packages("b64")
install.packages("base64url")

Two build notes, because these are the places installs go sideways. The openssl package compiles against your system OpenSSL, so a bare Linux box may want the development headers first:

sudo apt install libssl-dev

The b64 package is a Rust engine wrapped with extendr, so building it from source wants the Rust toolchain (sudo apt install cargo pulls in rustc as well). On Windows and macOS you get prebuilt binaries from CRAN and none of this applies. If you prefer a package manager, pak::pkg("base64enc") or remotes::install_cran("b64") do the same job with fewer opinions about repositories.

Your First Decode: The Three Line Rite

Ninety percent of decoding life fits in three lines. Here is the canonical smoke test, using the famous TWFu string:

library(base64enc)
packed <- "TWFu"
bytes <- base64decode(packed)
bytes
#> [1] 4d
rawToChar(bytes)
#> [1] "Man"

Three things are worth noticing in that tiny ceremony. First, base64decode() always hands you a raw vector, never a string. That is a feature, not an accident: Base64 can carry a sentence, a JPEG, or a certificate, and none of them should be treated differently before you know what you have. Second, the jump from bytes back to text is a separate, deliberate step through rawToChar(), and that step is where the charset decision lives (more on that later). Third, TWFu decodes to the word "Man": three letters, zero drama. Keep that string in your back pocket. If a piece of decode code turns TWFu into Man, the machine is honest.

Because you will always eventually decode something you encoded yourself, here is the full 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

The Raw Vector Border

R crosses the byte/text border with two tiny functions, and once you know them the rest of this article feels obvious. charToRaw() turns a string into its bytes, rawToChar() does the reverse, and in between you have the whole raw toolkit: length() to count bytes, head() to peek, writeBin() and readBin() to move them to and from files. Every decoder in this article stops at that border on purpose.

bytes <- base64decode("SGVsbG8=")
length(bytes)
#> [1] 6
head(bytes)
#> [1] 48 65 6c 6c 6f 2c
rawToChar(bytes)
#> [1] "Hello,"

So when you see a result like [1] 4d, read it as "one byte, value 77, the letter M", and stop there until you know which charset the bytes are wearing. That pause is the whole discipline of decoding.

Dirty Input: How the Decoders Disagree

This is the section that saves you at 2 a.m., because the decoders do not agree about what happens when the input is a little dirty. Real world Base64 arrives with spaces inside it, padding dropped by a nervous copy-paste, a stray symbol from a clipboard that ate it, or junk trailing at the end. Here is how each decoder answers, on the same family of offenders:

Input base64enc (default) base64enc (strict = TRUE) openssl b64
"SGVs bG8s IHdvcmxkIQ==" (a space inside) "Hello, world!" (space skipped) error: invalid character, position given "Hello, world!" (space skipped) error
"SGVsbG8sIHdvcmxkIQ" (padding dropped) "Hello, world!" (missing padding tolerated) error: missing padding error: failed to decode error: invalid padding
"SGVsbG8s!IHdvcmxkIQ==" (a "!" inside) "Hello, world!" (bad character skipped) error: invalid character empty raw vector, no error error
"SGVsbG8sIHdvcmxkIQ==xx" (trailing junk) "Hello, world!" (junk ignored) error: trailing content empty raw vector, no error error
"TQ==" (clean) M M M M

Read that table twice, because it contains the whole story. The default base64decode() is the friendly customs officer: it skips characters outside the alphabet, tolerates missing padding, and ignores trailing content, so email-shaped and clipboard-shaped strings just go through. The strict = TRUE mode, which arrived with release 0.1-5 in February 2026 after a long silence, is the forensic examiner: it validates the whole string, names the exact position of the offender, and refuses anything that is not textbook. b64 is strict by default and never half succeeds. And openssl sits in an uncomfortable middle: it happily skips whitespace, it correctly errors on missing padding, but when it meets a genuinely illegal character or trailing junk it returns an empty raw vector and says absolutely nothing. That silent empty result is the most dangerous behavior in this entire article, so let us watch it happen:

dirty <- "SGVsbG8s!IHdvcmxkIQ=="
openssl::base64_decode(dirty)
#> raw(0)   # empty. no error. no warning. no hint.

If you write code against openssl, check the length of what you got before you trust it. It is a small habit that prevents a whole class of "where did my data go" mysteries.

For the strict camp, here is base64enc being precise, with the exact error messages you will see in your own console:

base64enc::base64decode("TWF u", strict = TRUE)
#> v=10000, pad=3, org=''
#> Error: Invalid character (' ') at position 4 in base64 string (not allowed in strict mode)
base64enc::base64decode("TWF", strict = TRUE)
#> Error: Missing padding (1 characters) at the end of the base64 string (not allowed in strict mode)
base64enc::base64decode("TWF=xx", strict = TRUE)
#> Error: Trailing content 'xx' after padding at position 4 in base64 string (not allowed in strict mode)

One oddity to expect: every strict call, success or failure, chatters a C level status line to the console, something like v=30000, pad=0, org='u'. It is a direct write from the compiled code, not an R warning, so suppressWarnings() will not touch it. It is harmless, but if your tests compare console output, you now know why there is an extra line.

R-isms: NA, Vectors and Silent Concatenation

Base64 has its quirks; R adds its own. The first one bites through NA. When you pass NA_character_ to a decoder, R quietly coerces it to the string "NA" before the decoder ever sees it, and "NA" is a perfectly valid Base64 group. The result is not an error and not an empty vector. It is the byte 0x34, the letter "4":

base64decode(NA_character_)
#> [1] 34
rawToChar(base64decode(NA_character_))
#> [1] "4"

So a column of missing values does not decode to missing values. It decodes to a column of the letter 4, and your downstream code merrily processes it. If your input can contain NA, filter first:

values <- c("TWFu", NA_character_, "TQ==")
clean <- values[!is.na(values)]
out <- vapply(clean, function(x) rawToChar(base64decode(x)), character(1))
out
#> [1] "Man" "M"

The second quirk is vector input, and the decoders take completely different turns. base64decode() treats a character vector as several pieces of one string and concatenates them, so two elements come back as one raw vector. openssl::base64_decode() does not even get that far: it collapses its argument and then runs into a two-element logical test, which produces one of R's most honest error messages. And b64::decode() is the only one that is truly vectorized, returning one decoded blob per input:

base64decode(c("TWFu", "TQ=="))
#> [1] 4d 61 6e 4d
openssl::base64_decode(c("TWFu", "TQ=="))
#> Error in if (is.na(text)) : the condition has length > 1
out <- b64::decode(c("TWFu", "TQ=="))
rawToChar(out[[1]])
#> [1] "Man"
rawToChar(out[[2]])
#> [1] "M"

Takeaway: loop or vectorize deliberately, and never assume a vector of inputs gives you a vector of outputs.

The URL Safe Alphabet

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 padding becomes %3D, so a token that should be paste-anywhere starts wearing percent signs. The URL safe variant, defined in RFC 4648 section 5, swaps those two letters for - and _ and usually drops the padding too. R has three doors into that world.

Door one is the b64 engines. An engine is just a configured alphabet and padding policy, and the package ships the four you need:

library(b64)
std <- encode(as.raw(c(0xfb, 0xef, 0xbe)))
std
#> [1] "++++"
url <- encode(as.raw(c(0xfb, 0xef, 0xbe)), engine("url_safe"))
url
#> [1] "----"
decoded <- decode(url, engine("url_safe"))[[1]]
toString(decoded)
#> [1] "fb ef be"

The engines are "standard" (the default), "standard_no_pad", "url_safe", and "url_safe_no_pad", and the same engine object works in both directions, which keeps your code symmetric. They are also strict about their alphabet: feed "----" to the standard engine and it answers "Invalid byte 45, offset 0.", which is a relief.

Door two is the dedicated base64url package, small and single purpose: the URL safe alphabet, no padding, always. One caveat: its decoder is lenient, so it will happily decode the valid prefix of a corrupt string and stop there without complaining. If a URL safe value came from an untrusted source, prefer b64.

base64url::base64_urlencode("hello world")
#> [1] "aGVsbG8gd29ybGQ"
base64url::base64_urldecode("aGVsbG8g!!!")
#> [1] "hello "   # the rest was quietly dropped

Door three is jose, which exports base64url_encode() and base64url_decode() for JWT work, and returns raw vectors the way you would hope.

And when you only need the occasional one-off and want to stay inside base64enc, string surgery plus a padding fix is enough:

url <- "----"
fixed <- gsub("_", "/", gsub("-", "+", url), fixed = TRUE)
padded <- switch(as.character(nchar(fixed) %% 4L),
  "0" = fixed,
  "2" = paste0(fixed, "=="),
  "3" = paste0(fixed, "="))
rawToChar(base64decode(padded))
#> [1] "\xfb\xef\xbe"

JWTs: Peeking and Trusting

The reason most people meet URL safe Base64 is the JSON Web Token. A JWT is three Base64url parts joined by dots: a header describing how it was signed, a payload of claims, and a signature that makes the whole thing trustworthy. Decoding the first two parts in R is a five line affair. Note the fixed = TRUE in strsplit(): the dot is a regex wildcard, and without that flag R cheerfully splits your token into single characters, which is the single most common Base64 bug in R code in the wild.

jwt <- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
parts <- strsplit(jwt, ".", fixed = TRUE)[[1]]
header <- base64url::base64_urldecode(parts[1])
header
#> [1] "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"
payload <- jsonlite::fromJSON(base64url::base64_urldecode(parts[2]))
payload$name
#> [1] "John Doe"

Two honest disclaimers. First, decoding a JWT is peeking, not trusting: the third part is the signature, and it only means anything when checked against the issuer's key. For that, the jose package handles the whole family. Its 2.0 API (April 2026) is built around jwt_* functions, so older tutorials showing jwk_hs256() and jws_sign() are describing the retired 1.x API:

library(jose)
claims <- jwt_decode_hmac(my_token, "0123456789abcdef")
claims$name
#> [1] "John Doe"
claims$sub
#> [1] "1234567890"
sp <- jwt_split(my_token)
sp$header
#> $alg
#> [1] "HS256"
#>
#> $typ
#> [1] "JWT"
sp$payload$name
#> [1] "John Doe"

jwt_decode_hmac() verifies the signature and also enforces the exp and nbf claims, raising an error if the token is expired. And second: base64url::base64_urldecode() and friends decode the header and payload fine, but the signature part is binary, so decode it with a function that returns raw (like b64::decode() with the "url_safe_no_pad" engine) rather than into a string.

Charsets: What Text Are Those Bytes?

Every decoder in this article stops at the raw vector border on purpose, because the answer to "what text was that?" depends on the charset the bytes were packed in. The good news up front: if the sender used UTF-8, which is most of the modern web, your life is short and happy. base64enc even ships a gate for it:

packed <- base64encode(charToRaw("caf\u00e9"))
bytes <- base64decode(packed)
checkUTF8(bytes, quiet = TRUE)
#> [1] TRUE
rawToChar(bytes)
#> [1] "café"

checkUTF8() asks "can these bytes be read as UTF-8?" without committing. With quiet = TRUE it returns TRUE or FALSE; by default it is even more direct and raises an error on invalid bytes, naming the offender, as in INVALID byte 0xff at 0x0. It also reports FALSE for strings that contain NUL bytes, which can never be part of a valid UTF-8 R string. Use it as a gate, and the rest follows.

Here is why the gate matters. In a UTF-8 locale, rawToChar() does not throw an error when the bytes are not valid UTF-8. It hands you a string R marks with Encoding() == "unknown", and depending on locale and R build the same call can instead die with an "invalid multibyte sequence" error, which is at least honest. Either way, unguarded rawToChar() on stranger bytes is how mojibake gets born:

broken <- as.raw(c(0xc3, 0x28))   # a truncated UTF-8 pair
checkUTF8(broken, quiet = TRUE)
#> [1] FALSE
rawToChar(broken)                  # no error. that is the problem.
#> [1] "\xc3("

And when the sender used something else entirely, Latin-1 or Windows-1252 or Shift-JIS, the recipe is to decode to raw and then translate with iconv(), which understands the named charsets:

latin1_bytes <- as.raw(c(0x63, 0x61, 0x66, 0xe9))   # "café" in Latin-1
iconv(rawToChar(latin1_bytes), from = "latin1", to = "UTF-8")
#> [1] "café"

Emoji deserve a word, because R has a story here. Modern R stores code points above U+FFFF, where emoji live, as true UTF-8, so a round trip works and the byte count matches what every other language expects. Older R releases stored those same characters as surrogate pairs, a scheme often called CESU-8, so a single emoji crossed the border as six bytes of invalid UTF-8. If you inherit old R code whose emoji arrive on the wire broken, that is the prime suspect: check nchar(x, type = "bytes") and compare with what the sender's language would produce.

emoji <- "\U0001F600"
nchar(emoji, type = "bytes")
#> [1] 4
round_trip <- base64decode(base64encode(charToRaw(emoji)))
identical(emoji, rawToChar(round_trip))
#> [1] TRUE

The rule of thumb: assume UTF-8, verify with checkUTF8(), and only reach for iconv() when you have positive knowledge of another charset. Never guess.

Files: From Wrapped Text to Real Bytes

Strings are easy; files are where Base64 earns its keep. Start with the manual pipeline that works with any package: read the encoded text, decode, write the bytes.

cat("SGVsbG8sIHdvcmxkIQ==", file = "hello.b64")
bytes <- base64decode(paste(readLines("hello.b64"), collapse = ""))
writeBin(bytes, "hello.txt")
readLines("hello.txt")
#> [1] "Hello, world!"

Then the convenience layer. base64enc can read and write for you: the file argument points at a file holding Base64 text, and output points at where the decoded bytes should land. The return value is the number of bytes written, a lovely free sanity check:

n <- base64decode(file = "hello.b64", output = "hello.txt")
n
#> [1] 13
readLines("hello.txt")
#> [1] "Hello, world!"

Wrapped input, the kind that has survived email or PEM armor, is no problem for the lenient decoders: they look straight through line breaks wherever they fall, as long as what is left is valid Base64. MIME wraps at 76 characters with CRLF between lines; PEM blocks wrap at 64. The strict engine, of course, rejects the wrapping outright.

wrapped <- paste0("VGhlIHF1aWNrIGJ", "\r\n",
  "yb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZw==")
rawToChar(base64decode(wrapped))
#> [1] "The quick brown fox jumps over the lazy dog"
rawToChar(openssl::base64_decode(wrapped))
#> [1] "The quick brown fox jumps over the lazy dog"
b64::decode(wrapped)
#> Error: Invalid byte 13, offset 15.

The b64 package has the file pair with the clearest names, and because it is Rust-fast it is the one to reach for when the file is big. There is one sharp edge, though: decode_file() reads the file byte for byte and is unforgiving. A trailing newline from writeLines(), or the one that cat() never adds, makes the Rust engine panic, surfacing as a catchable error of the form User function panicked: decode_file_. Write the encoded text with cat() or writeBin(charToRaw(enc), path) and the edge stays dull.

writeBin(charToRaw("file payload bytes"), "payload.bin")
enc <- b64::encode_file("payload.bin")
cat(enc, file = "payload.b64")
bytes <- b64::decode_file("payload.b64")
rawToChar(bytes)
#> [1] "file payload bytes"

The base64 package is the third way in: purely file oriented, with a matching pair of functions:

base64::encode("payload.bin", "payload.b64")
base64::decode("payload.b64", "payload.out")
readLines("payload.out")
#> [1] "file payload bytes"

One more file shape you will meet in practice: a data frame column full of encoded blobs, very common in API dumps. For a few hundred rows, a simple vectorized call is plenty:

df <- data.frame(content = c(b64::encode("alpha"), b64::encode("beta"),
  b64::encode("gamma")))
decoded <- b64::decode(df$content)
df$decoded <- vapply(decoded, function(x) rawToChar(x), character(1))
df$decoded
#> [1] "alpha" "beta"  "gamma"

APIs and Web Responses

JSON APIs love to stuff binary into text, and Base64 is their favorite suitcase. The pattern in R is the same every time: fetch, parse, decode, and decide what the bytes are. The modern HTTP client is httr2, and the JSON side is jsonlite:

library(httr2)
library(jsonlite)
res <- req_perform(request("https://api.example.com/files/1"))
data <- fromJSON(rawToChar(res$body))
bytes <- base64decode(data$attachment)
writeBin(bytes, data$name)
length(bytes)
#> [1] 13

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 arrives as a raw vector, so rawToChar() before you parse it as JSON. And the API may speak a dialect: check whether its Base64 is URL safe, or stripped of padding, before you feed it to a decoder that expects the textbook version. Some APIs even double-encode, Base64 of Base64, which you will recognize when one decode gives you more Base64 instead of the expected bytes.

Data URIs and Self-Contained Documents

The data: URI scheme (RFC 2397) lets a document carry its own content: a MIME type, the word "base64" when the payload is encoded, and the payload itself. You will meet these constantly in HTML you scrape, and decoding one is a two step string operation followed by an ordinary decode:

img_tag <- "<img src=\"data:image/png;base64,iVBORw0KGgoAAA\" />"
uri <- regmatches(img_tag, regexec("src=\"([^\"]*)\"", img_tag))[[1]][2]
uri
#> [1] "data:image/png;base64,iVBORw0KGgoAAA"
payload <- sub("^data:[^,]*,", "", uri)
bytes <- base64decode(payload)
length(bytes)
#> [1] 6

The same shape appears in CSS, in PDF annotations, and in self-contained R Markdown reports, where a plot has been flattened into an <img> tag so the HTML travels with no side files. If you are rendering such documents and want to extract the embedded images, this is the whole trick: find the data: string, cut at the first comma, decode.

Databases, Email and Configuration

Base64 shows up in databases whenever someone wanted binary inside a text column, and the decode side is a column of strings back to bytes. Here is the round trip against SQLite through DBI and RSQLite: store the encoded value, query it back, decode it, and you have your bytes:

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"

SQLite can also store binary natively as BLOB, in which case no Base64 is needed at all and the column comes back to R as a raw vector, as in class(dbGetQuery(db, "SELECT payload FROM blobs")$payload[[1]]) == "raw". The Base64-in-TEXT variant exists for portability: you can inspect it with a text editor, and every other language can read it without binary drivers.

Email is where wrapped Base64 comes from. MIME parts wrap at 76 characters with CRLF between lines, and any mail system you have ever read has carried attachments exactly like that. R has no first class mail client, but when you do receive a .eml file, the base64 part of an attachment is just wrapped text: the lenient decoders will unwrap it for you while they decode. The mime package helps you identify what you are holding:

mime::guess_type("photo.jpg")
#> [1] "image/jpeg"
part <- "SGVsbG8s\r\nCnRoaXMgaXMgYW4g\r\nZW1haWwgYXR0YWNobWVudA=="
rawToChar(base64decode(part))
#> [1] "Hello,\nthis is an email attachment"

Configuration files complete the tour. When a certificate or a blob is stored Base64-encoded in a YAML or JSON config, or in an environment variable, R reads it as an ordinary string and you decode on demand:

config_line <- "api_cert: TExUU0VDUkU="
cert_b64 <- sub("^api_cert: ", "", config_line)
raws <- base64decode(cert_b64)
rawToChar(raws)
#> [1] "LTSSECRET"

Large Payloads

R strings have a hard ceiling of 2^31 - 1 bytes, and a big enough Base64 string can bump into it, because the encoded form is about a third larger than the original. The base64enc package has handled this since its 2022 release: give base64encode() a line width and it hands back a vector of lines instead of one enormous string, and on the decode side the file = argument reads the file as lines and decodes without forcing you to hold one giant string in a variable.

For files in the hundreds of megabytes, the practical pattern is to read in aligned chunks. Base64 groups are independent, so any chunk whose length is a multiple of four characters decodes on its own; you only need a small buffer to carry the ragged end:

chunk <- 65536L
con <- file("huge.b64", "r")
on.exit(close(con))
leftover <- ""
out <- raw(0)
while (TRUE) {
  piece <- readChar(con, chunk, useBytes = TRUE)
  if (piece == "") break
  piece <- gsub("[\r\n]", "", piece)
  ready <- paste0(leftover, piece)
  take <- floor(nchar(ready) / 4) * 4
  if (take > 0) {
    out <- c(out, base64decode(substr(ready, 1, take)))
    leftover <- substr(ready, take + 1, nchar(ready))
  } else {
    leftover <- ready
  }
}
if (nchar(leftover) > 0) out <- c(out, base64decode(leftover))
length(out)

The b64 package is the speed champion in this territory: its Rust engine decodes a 50 megabyte file in a fraction of a second, and its vectorized decode() handles a column of many encoded values in one call, which is a dramatic difference from looping per row. If you have many strings, 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 tools speak Base64 natively, and R can hand them work or take work from them. On Linux, base64 -d decodes (macOS and other BSD systems use -D):

echo "SGVsbG8sIHdvcmxkIQ==" | base64 -d
#> Hello, world!

And a one line Rscript does the same job with the same packages you use in your scripts:

Rscript -e 'library(base64enc); writeLines(rawToChar(base64decode("TWFu")))'
#> Man

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. One caution: command line arguments have a size limit (ARG_MAX), so do not paste multi-megabyte strings into the terminal. Pipe them through a file instead.

Pitfalls Worth Knowing

Here is the short list of the ways this bites R developers, all of them native to the ecosystem rather than to Base64 in general:

  • openssl fails silently. base64_decode() returns an empty raw vector, without error or warning, when it meets a character outside the alphabet or trailing junk. Always check length() of the result before you believe it.
  • NA is not a missing value to a decoder. NA_character_ coerces to the string "NA", which decodes to the byte 0x34. Filter NA before you decode.
  • Vectors do not behave the way you expect. base64decode() concatenates its input into one raw vector; openssl::base64_decode() dies with the condition has length > 1 on multi element input; only b64::decode() is genuinely vectorized.
  • rawToChar is a silent corrupter. Invalid UTF-8 bytes do not raise an error in a UTF-8 locale; they become a string marked "unknown". Gate with checkUTF8() first.
  • The dot in a JWT is a regex wildcard. strsplit(jwt, ".") without fixed = TRUE splits on every character. This is the most common Base64 bug in R code.
  • b64 files are unforgiving. A trailing newline in the encoded file makes b64::decode_file() panic. Write with cat(), not writeLines().
  • Lenient is not lenient enough for trust. base64decode()'s default mode skips bad characters anywhere, so a corrupted string can decode to plausible-looking garbage with no error. Use strict = TRUE at trust boundaries.
  • b64 error messages are Rust speaking. Expect sentences like Both cases of Either errored when you pass it a type it does not want. The message is unhelpful on purpose; it is a Rust type system shrugging.

Best Practices

  • Default to base64enc::base64decode() for everyday work, and switch on strict = TRUE wherever the input crosses a trust boundary: untrusted APIs, user uploads, anything signed.
  • Reach for b64 when you need speed, true vectorization, or the URL safe and no padding engines, and accept that it is strict by design.
  • If openssl is already in your project, use it, but treat every result as suspect until you have checked its length.
  • Decide the charset deliberately: assume UTF-8, verify with checkUTF8(), and only use iconv() when the sender told you otherwise.
  • For JWTs, peek with strsplit() plus fixed = TRUE, but trust only after jose verifies the signature.
  • Keep TWFu in your tests: it is three bytes of smoke test for any decode path you write.
  • Remember what Base64 is not: it is not encryption, and it is not compression. It is a packing tape, and anyone with this article can reverse everything it does. Decode freely, trust selectively.

A Short History of Base64 in R

The format is old. 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), tidied in RFC 3548 in 2003, and given its modern shape, including the URL safe alphabet, in RFC 4648 in 2006. The R story is much shorter and faster moving. The base64enc package, by Simon Urbanek, first landed on CRAN in September 2012 and has been the workhorse ever since, gaining checkUTF8() in 2015 and long vector support in 2022. In October 2024 the old base64 package was reissued explicitly as a compatibility wrapper, with its own description now pointing new applications to base64enc, openssl or jsonlite. Then came b64 in 2025, a Rust engine built with extendr that brought true vectorization and a stable of alphabets. In February 2026, base64enc released its long awaited strict mode, and in April 2026 the jose package redesigned itself around jwt_* functions. Ten years for what other languages shipped in one library, but the result is a toolbox where every decoder has a clear job and a clear personality.

Fun Facts

Because a complete guide should end on a smile:

  • base64decode(NA_character_) returns the letter "4", because NA_character_ travels to the decoder as the string "NA", and "NA" is a valid Base64 group. The most R-specific one byte surprise in the language.
  • Feed openssl::base64_decode() a string with one illegal character and it returns raw(0) with total silence. The most dangerous quiet in the ecosystem.
  • Every strict call to base64decode() chatters a C level status line like v=30000, pad=0, org='u'. It is not a warning. It is the C code clearing its throat.
  • b64 can decode alphabets you have never met: BinHex, IMAP modified UTF-7, and the custom alphabets of bcrypt and crypt. R can read a 1980s Macintosh attachment and a modern password hash with the same engine.
  • R strings stop at 2^31 - 1 bytes, which is why base64enc learned to return a vector of lines for long input. The format hit the language's wall, and the package grew a ladder.
  • The word "base64" encodes to YmFzZTY0. A format that can describe itself is the technical equivalent of a mirror that talks in Morse.
  • The empty string still costs four characters: "AA==". In Base64, nothing is always something.
  • Your R build has a nickname. R 4.5.x is called "[Not] Part in a Rumble", because R versions have been named after song titles since the 3.x days. Even the version you are decoding with has a sense of humor.

Wrap Up

Pick your decoder by the company you keep: base64enc for everyday work, with strict = TRUE at the edges; openssl if it is already in your project, provided you check your results; b64 when you want speed, vectors, and the URL safe engines; and the small specialists base64 and base64url for file chores and URL safe strings. Guard your bytes with checkUTF8() before you call them text, use iconv() when the charset is known to be something else, and remember that the raw vector in the middle is a feature: it forces you to decide what the bytes mean instead of letting a library guess. Decode everything, trust only what verifies. And when you need to go the other direction, packing your own bytes into a string for the road instead of unpacking one, the sister article covers Base64 encoding in R in detail.

Last updated: 2026-08-30

Related article: Base64 Encoding in R: A Complete Guide