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 Encoding in Kotlin: A Complete Guide

Half of every Base64 conversation is about reading packed data back into bytes. The other half, the part you are on the right site for, is about producing that packed data in the first place. Somewhere in your application there are bytes that need to travel through a channel that only understands text: a JSON string, an HTTP header, an email, a URL, a config file. Base64 is the classic answer, and Kotlin has a first-class answer for it in the standard library: the Base64 class in kotlin.io.encoding, stable since Kotlin 2.2.

This guide walks through what you actually need when you are the one producing Base64: the four preset schemes, the padding dial, the line-wrapping rules that email and certificates live by, the URL-safe alphabet, where the bytes come from, and a set of real-world scenarios with Kotlin in each one. The format itself, how 3 bytes become 4 characters, where the = comes from, is covered on the home page, so here we get straight to the Kotlin.

One Class, Four Presets

The entire API is a single class, Base64, in the kotlin.io.encoding package. There is no encoder object you construct and no builder. Instead, the class ships with four preset instances, one per RFC scheme, and a companion object that quietly stands in for the most common one:

InstanceAlphabetLine wrapping on encodePadding on encodeUse it for
Base64.Default+ and /noneemits =general purpose, APIs, data URLs
Base64.UrlSafe- and _noneemits = (turn it off)URLs, tokens, JWTs
Base64.Mime+ and /CRLF every 76 charactersemits =email bodies and attachments
Base64.Pem+ and /CRLF every 64 charactersemits =certificates and private keys

The naming detail that trips people up: these are instances, not factories. Every instance is an immutable value, and changing its behavior, like the padding, returns a new instance instead of mutating the old one. That makes the presets safe to share across threads and store in objects, and it is why the whole class can be a simple value type with no internal state.

Your First Encode: Bytes In, String Out

Here is the smallest useful program on the site: five bytes in, an eight-character string out. The input is always a ByteArray (or a slice of one), and the result is a plain String you can put anywhere text is allowed:

import kotlin.io.encoding.Base64
fun main() {
  val bytes = "Hello".encodeToByteArray()
  val packed = Base64.encode(bytes)
  println(packed)  // SGVsbG8=
}

That one line does more work than it looks. Kotlin gives you several shapes of the same operation, and they all read like the function says:

  • encode(bytes) returns a String, the shape above.
  • encodeToByteArray(bytes) returns a ByteArray of ASCII characters, handy when the packed form itself is going into another buffer.
  • encodeIntoByteArray(bytes, destination) writes into a ByteArray you already allocated, which skips one allocation on hot paths.
  • encodeToAppendable(bytes, builder) appends to anything implementing Appendable, like a StringBuilder, which is the natural fit when you are assembling a larger document.

All four accept the same optional startIndex and endIndex range, so you can pack a slice of a big buffer without copying it first. Because Base64.Default is the companion object, you can also drop the instance and write Base64.encode(bytes) as sugar; both forms are the same call.

The 4/3 Rule: How Long Will It Get?

Before you ship an encoder, it is worth knowing exactly how much bigger the output will be, because Base64 spends characters on information it already had. The math is strict: every 3 input bytes become exactly 4 output characters, so any leftover 1 or 2 bytes still consume a full group of 4, padded with = to fill the group. The result for the first few sizes:

Input bytes12345678
Output characters4448881212

The formula behind the table is 4 * ceil(bytes / 3). In the worst case a single byte becomes 4 characters, a 300 percent surcharge; from three bytes up it converges on roughly a third more data on the wire. That is the entire cost model, there is no per-instance variation, and it is why you should be deliberate about Base64-ing large payloads instead of reaching for it by reflex.

Padding Is a Setting, Not a Fate

On the encode side, the = characters are a policy decision, and Kotlin makes it a first-class one. Every instance carries a PaddingOption, and withPadding hands you a new instance with the dial moved. All four presets start on PRESENT, which is why "Hello" comes out as SGVsbG8= rather than SGVsbG8:

import kotlin.io.encoding.Base64
fun main() {
  val bytes = "Hello".encodeToByteArray()
  val noPad = Base64.Default.withPadding(Base64.PaddingOption.ABSENT)
  println(Base64.encode(bytes))      // SGVsbG8=
  println(noPad.encode(bytes))       // SGVsbG8
}

There are four positions on the dial. The first word of the name decides what the encoder emits; the second half decides how strict the decoder of the same instance will be when you (or the other side) turn it around later:

PaddingOptionEncoder emits =Decoder accepts =
PRESENTyesrequired, anything else fails
ABSENTnoforbidden, a stray pad fails
PRESENT_OPTIONALyeseither way
ABSENT_OPTIONALnoeither way

The most common encode-time choice is ABSENT with the UrlSafe alphabet, which is exactly the shape that JSON Web Tokens and many URL schemes expect. You will meet it again in a moment.

Base64url: URLs, Tokens and JWTs

The classic alphabet contains + and /, and both are disasters in URLs: a + in a query string is routinely read as a space, and a / is the path separator. RFC 4648 section 5 defines the URL-safe variant, swapping in - and _, and Base64.UrlSafe is that scheme. Encoding the same bytes that produce a / in the classic alphabet shows the swap in action:

import kotlin.io.encoding.Base64
fun main() {
  val bytes = "Hello?".encodeToByteArray()
  println(Base64.encode(bytes))          // SGVsbG8/
  println(Base64.UrlSafe.encode(bytes))  // SGVsbG8_
}

The canonical real-world user is a JWT, whose header and payload are base64url without padding, joined by dots. Here is the encoding half of building one, which is a shape you should understand even if a library signs the final token:

import kotlin.io.encoding.Base64
fun main() {
  val header = """{"alg":"HS256","typ":"JWT"}"""
  val payload = """{"sub":"1234567890","name":"John Doe"}"""
  val noPad = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT)
  val h = noPad.encode(header.encodeToByteArray())
  val p = noPad.encode(payload.encodeToByteArray())
  val token = "$h.$p.Ym9nVXNlZlNpZ25hdHVyZUZvckRlbW8"
  println(token)
}

Prints:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.Ym9nVXNlZlNpZ25hdHVyZUZvckRlbW8

Two warnings belong here. First, the third segment is a signature, and producing a genuine one needs real cryptography (a JCA/JCE signer or a JWT library), never hand-rolled bytes; the snippet above is only demonstrating the encoding shape. Second, if you are on the JVM and reach for java.util.Base64.getUrlEncoder() out of habit, note that it pads by default, so JWT-style output needs .withoutPadding() there; the Kotlin preset pads just as loudly out of the box, and you opt out with a withPadding call instead.

Line Wrapping: The Mime and Pem Presets

Two of the four presets wrap their output into short lines, and the reason is historical. Old email transports mangled long lines, so RFC 2045 section 6.8 caps MIME base64 at 76 characters per line; PKI tools, following the older PEM tradition, use 64. Kotlin bakes both rules into the preset itself: the line separator is CRLF, the break lands exactly at the limit, and there is no trailing separator at the very end. A 200-byte payload through each wrapper looks like this:

import kotlin.io.encoding.Base64
fun main() {
  val data = ByteArray(200) { (it % 251).toByte() }
  println(Base64.Mime.encode(data).lines().maxOf { it.length })  // 76
  println(Base64.Pem.encode(data).lines().maxOf { it.length })   // 64
}

For that input, Mime produces 4 lines and Pem produces 5. The first Mime line is:

AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4

The trap to remember is the reverse direction of the one you are writing code for: wrapped output is not one line. If you feed Mime output to a strict single-line consumer, the CRLFs become a decoding error, so pick the wrapper by channel, not by convenience. For APIs, data URLs and anything modern, Default is the right default, and wrapping is an email-and-certificate story.

Where Do the Bytes Come From?

An encoder is only as honest as the bytes you hand it, and the interesting decisions happen one step before encode is called. The most common source is text, and the most common mistake is letting the charset decide silently:

  • text.encodeToByteArray() is always UTF-8, on every platform. It is the right choice for JSON, emails and web data, and the wrong one if the text is Latin-1 or UTF-16 and the other side decodes accordingly.
  • On the JVM you can choose explicitly with the inline extension text.toByteArray(charset), which has been in the standard library since Kotlin 1.0 and is the Kotlin-side answer to Java's getBytes(charset). There is no getBytes on kotlin.String, so if you write text.getBytes() on a Kotlin string, the compiler will tell you; the extension is the path.
import kotlin.io.encoding.Base64
fun main() {
  val text = "héllo"
  println(Base64.encode(text.encodeToByteArray()))               // aMOpbGxv
  println(Base64.encode(text.toByteArray(Charsets.ISO_8859_1)))  // aOlsbG8
}

Same five letters, two different packed forms, because the bytes were different before any Base64 was involved. If the decoder later assumes UTF-8, the Latin-1 version decodes into mojibake, and no Base64 trick on either end can repair a charset mismatch.

Other sources of bytes follow the same shape. A file is file.readBytes() or path.readBytes() then encode. A pre-allocated buffer uses encodeIntoByteArray(bytes, destination). A document under construction uses encodeToAppendable(bytes, builder), which returns the destination so calls chain like builder methods:

import kotlin.io.encoding.Base64
fun main() {
  val sb = StringBuilder("prefix-")
  Base64.encodeToAppendable("Hello".encodeToByteArray(), sb)
  println(sb)  // prefix-SGVsbG8=
}

And on the JVM there is a streaming form for inputs that do not fit in memory, still marked experimental and importable under its own name. The twist in the naming: encodingWith wraps an output stream, so writes made through it come out as base64, and the plain bytes land in the underlying stream:

import java.io.ByteArrayOutputStream
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.io.encoding.encodingWith
@OptIn(ExperimentalEncodingApi::class)
fun main() {
  val raw = ByteArray(10_000) { (it % 251).toByte() }
  val packed = ByteArrayOutputStream()
  packed.encodingWith(Base64.Default).use { encoded ->
    encoded.write(raw)
  }
  println(packed.size())  // 13336
}

The rule of thumb: in-memory encode for everything that fits, encodingWith for the streams that do not, and an explicit charset whenever the bytes are actually text.

Field Notes: HTTP Basic Auth

HTTP Basic authentication is the oldest Base64 use case on the internet, and it is still everywhere in service-to-service traffic. RFC 7617 defines the scheme: take the user and password, join with a single colon, base64 the result, and ship it as Basic plus a space plus the packed string in the Authorization header. In Kotlin:

import kotlin.io.encoding.Base64
fun main() {
  val credentials = "alice:s3cr3t"
  val header = "Basic " + Base64.encode(credentials.encodeToByteArray())
  println(header)  // Basic YWxpY2U6czNjcjN0
}

Why Base64 here and not something stronger? Because a header value must be a single printable token, and Base64 guarantees that. The honest warning: Base64 is encoding, not encryption. Any client can reverse YWxpY2U6czNjcjN0 to alice:s3cr3t in one step, which is why Basic auth belongs only on TLS connections, ideally with token credentials rather than human passwords. When you parse such a header, split on the colon exactly once, because the password may legally contain colons.

Field Notes: Images and Data URLs

Data URLs embed binary assets directly into HTML, CSS and JSON so the browser does not make a second request. The shape is a media type, a comma, the word base64, another comma, and the packed bytes:

import kotlin.io.encoding.Base64
fun main() {
  val png = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D.toByte(), 0x0A.toByte(), 0x1A.toByte(), 0x0A.toByte())
  val dataUrl = "data:image/png;base64," + Base64.encode(png)
  println(dataUrl)  // data:image/png;base64,iVBORw0KGgo=
}

The bytes above are the first eight of a PNG file, the magic number every decoder checks. Why Base64 fits: the payload must be a URL-safe text token inside markup, and Base64 is the only widely supported binary-to-text with a stable grammar. The pitfall is size. A 300 kilobyte logo becomes about 400 kilobytes of markup, and every extra kilobyte is paid on every page load that includes it. Data URLs are a great tool for icons, avatars and small sprites; they are a terrible tool for video, and even a mediocre tool for a large photograph. Measure before you inline.

Field Notes: Email Attachments

SMTP is a text protocol that predates any notion of binary, so every attachment in every email you have ever received is Base64, wrapped at 76 characters, declared with a Content-Transfer-Encoding: base64 header. A minimal MIME part with a small binary attachment looks like this, with the Kotlin-generated body slot filled in for a 5-byte %PDF- header:

From: sender@example.com
To: receiver@example.com
Subject: report
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="cut-here"

--cut-here
Content-Type: text/plain; charset="utf-8"

The quarterly report follows as an attachment.

--cut-here
Content-Type: application/pdf
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="report.pdf"

JVBERi0=
--cut-here--

(The body JVBERi0= is the base64 of the five bytes %PDF-; a real report would wrap over many 76-character lines.) The Kotlin side is one line when you have the file bytes:

import kotlin.io.encoding.Base64
fun main() {
  val pdf = byteArrayOf(0x25, 0x50, 0x44, 0x46, 0x2D)  // "%PDF-"
  println(Base64.Mime.encode(pdf))  // JVBERi0=
}

The pitfalls are channel discipline. Use Mime, not Default, for the body, because a strict MIME parser expects the wrapping, and a plain Default line of 10,000 characters will be rejected or mangled by some transports. Keep the header case exactly base64 in the Content-Transfer-Encoding line, and remember that the wrapping is part of the format: unwrapped output and wrapped output are different representations of the same bytes, and the parser on the other side must know which one it is eating.

Field Notes: JSON APIs and Uploads

When an API wants binary in a JSON document, the convention is a string field holding base64, and it is one of the most convenient patterns in the ecosystem because JSON already has a home for text. With kotlinx.serialization the round trip is straightforward:

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.io.encoding.Base64
@Serializable
data class UploadRequest(val name: String, val payload: String)
fun main() {
  val icon = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47)
  val request = UploadRequest("icon.png", Base64.encode(icon))
  val json = Json.encodeToString(UploadRequest.serializer(), request)
  println(json)  // {"name":"icon.png","payload":"iVBORw=="}
}

Why Base64 here: JSON has no binary type, so the payload must be text, and Base64 is the least-surprising binary grammar an API consumer will recognize without documentation. The pitfall is scale. The 4/3 surcharge is paid on every request and every response, and a 10 megabyte upload becomes a 13.3 megabyte JSON string that your parser must hold, escape and validate in memory. For large files, multipart/form-data or a binary body is almost always the better wire format; reserve base64-in-JSON for thumbnails, icons, signatures and small blobs where the convenience outweighs the tax.

Field Notes: Config and the Command Line

The last two patterns are the small ones that appear in every codebase. Configuration values, tokens, license keys, sometimes small secrets, often travel through environment variables and property files as base64 because the transport is text-only and the value may contain quotes or newlines. Reading them back is the same two-step dance in reverse: System.getenv or a property lookup, then decode. On the command line, encoding a file for transport or inspection is a ten-line program:

import java.io.File
import kotlin.io.encoding.Base64
fun main(args: Array<String>) {
  require(args.isNotEmpty()) { "usage: b64encode <file>" }
  val bytes = File(args[0]).readBytes()
  val encoded = Base64.encode(bytes)
  File(args[0] + ".b64").writeText(encoded)
  println("Wrote ${encoded.length} characters to ${args[0]}.b64")
}

Run on a file containing the ten bytes hello file, it writes 16 characters, aGVsbG8gZmlsZQ==. The pitfalls in both cases are the same two: Base64 in config is not a vault, the value is one step from plaintext and should be treated as a secret on the wire either way, and a hand-rolled CLI tool should decide its alphabet deliberately, because a user who pipes your output into a URL will need UrlSafe, not Default.

What Can Go Wrong at Encode Time

Encoding is forgiving about content: any byte sequence is valid input, so there is no "invalid symbol" failure the way decoders have. What does throw is geometry, and the messages are precise enough to be useful:

SituationExceptionMessage
endIndex past the end of the arrayIndexOutOfBoundsExceptionstartIndex: 0, endIndex: 100, size: 5
startIndex beyond endIndexIllegalArgumentExceptionstartIndex: 3 > endIndex: 2
destination array too small for encodeIntoByteArrayIndexOutOfBoundsExceptionThe destination array does not have enough capacity, destination offset: 0, destination size: 2, capacity needed: 8

Two Kotlin-specific traps sit alongside these. The first is the classic int + String mistake: bytes.size + " bytes" does not compile, because plus on an Int does not concatenate strings; the interpolation form "${bytes.size} bytes" is the Kotlin way. The second is the charset lookup, which throws UnsupportedCharsetException for a name the JVM does not recognize, like Charset.forName("utf-9"), so a typo in a charset name is a runtime exception, not a compile error, and it surfaces where the encoder runs rather than where the name was typed.

Pitfalls, Kotlin Style

The traps below are the ones that specifically bite Kotlin developers reaching for the standard library for the first time:

  • Letting encodeToByteArray() pick the charset for you. It is always UTF-8, silently, and a Latin-1 or UTF-16 source will pack into bytes the decoder cannot read back. Decide the charset on purpose, with toByteArray(charset) on the JVM when it is not UTF-8.
  • Reaching for java.util.Base64 out of muscle memory. Its getUrlEncoder() pads by default, which is the wrong shape for JWTs unless you remember .withoutPadding(); the Kotlin preset makes the choice explicit on both sides.
  • Feeding Mime or Pem wrapped output to a single-line consumer. The CRLFs are part of the representation and will fail a strict decoder that expects one line; wrap only when the channel expects wrapping.
  • Writing text.getBytes() on a Kotlin string. Java's method is not visible on kotlin.String; the inline toByteArray(charset) extension, present since Kotlin 1.0, is the replacement.
  • Running old toolchains. The system Kotlin on some distributions is still 1.3, which predates the standard library Base64 entirely; the class needs 1.8.20 to exist and 2.2 to be stable and to offer padding control.
  • Treating Base64 as a security layer. It is a transport encoding with a public, one-step inverse. Anything secret should be encrypted before it is packed, never just packed.

Choosing Well: A Quick Decision Guide

When in doubt, the decision is almost always made by the channel, not the content. The short version:

  • Base64.Default for APIs, JSON, data URLs and anything that is effectively one line of text. Padded output is the most compatible shape on the wire.
  • Base64.UrlSafe with ABSENT padding for tokens, JWTs and anything that lands in a URL segment or a query parameter.
  • Base64.Mime for email bodies and attachments, where 76-character lines are a hard requirement of the format.
  • Base64.Pem for certificates and private keys, where 64-character lines are what every PKI tool expects.

Then two cross-cutting habits: make the charset explicit whenever the input is text, and keep an eye on the 4/3 surcharge so that large payloads get a binary channel instead of a base64 one.

The Road to Standard

The standard library path to Base64 is recent enough that you will meet older Kotlin without it. The class first appeared in Kotlin 1.8.20 in April 2023, marked experimental, with three instances and a simpler surface: encoding always padded, and there was no way to ask for less. If you have seen 1.8-era code that strips = from the end of a string with removeSuffix, that was the era's only tool for unpadded output, and it is a habit worth dropping now. Kotlin 2.2, released in June 2025, stabilized the API and added the pieces that make the class complete: the Pem instance, the PaddingOption dial and withPadding, which is the first first-class way to control padding in either direction. The streaming helpers encodingWith and decodingWith remain experimental and JVM-only, which is how the standard library flags APIs it wants more field experience on before freezing them. Since the 2.4.0 line, the language has also moved to an 18-month support window for the standard library, so a project pinned to a 2.4.x compiler, like the 2.4.10 stable release that is current as of this writing, gets the full Base64 API for the life of that support cycle.

Small Wonders

A few details that make the class more interesting once you know them:

  • Base64.encode(bytes) without an instance works because Base64.Default is defined on the companion object; the companion is the default scheme, so the sugar and the named form are literally the same object.
  • The standard library marks encodeToAppendable with its own @IgnorableReturnValue annotation, a builder-style API where the documented pattern is to ignore the return value and keep using your builder.
  • Padding never fills a whole group: a base64 string ends with zero, one or two = characters, and counting the pad tells you exactly how many bytes the original had left over in the final triple.
  • Pem is the newest of the four presets, joining in 2.2 alongside the padding dial; the 64-character wrap is a PKI convention older than the RFC 2045 rule it sits next to.
  • On the JVM the standard library deliberately does not delegate to java.util.Base64; the two implementations are separate, which keeps behavior identical across platforms at the cost of a commented-out optimization that the Kotlin team has kept in the tree for a future where the Java API allows it.
  • The same 2.2 release that stabilized Base64 also stabilized HexFormat, the hex formatting class in kotlin.text that has been experimental since Kotlin 1.9, so byte-level textual encodings now have a settled home in the standard library.

Wrap-Up and Where to Go Next

Producing Base64 in Kotlin is a short list of deliberate choices: pick the preset by channel, decide padding on purpose, keep the charset explicit when the input is text, and respect the 4/3 surcharge when the payload is large. Everything else, files, buffers, appendables, streams, is a thin wrapper around the same four instances. The other direction, taking that packed text back into bytes, has its own strictness rules, its own failure modes and its own traps, and the related article on the sister site covers Base64 decoding in Kotlin in depth.

Last updated: 2026-08-30

Related article: Base64 Decoding in Kotlin: A Complete Guide