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 Swift: A Complete Guide

Somewhere in your pipeline, data is wearing a disguise: a token tucked into an HTTP header, an avatar hiding inside a JSON field, a .b64 file you promised to look at last week, an e-mail attachment that arrived as a wall of letters. Taking those disguises off in Swift is one of the most pleasant jobs in the language: one framework, one initializer, and a rulebook short enough to fit on a sticky note.

The home page above already covers the format itself (64 printable characters, six bits per character, up to two = characters of padding on the final group), so we will not retell that story here. Just keep two facts in your pocket. First, base64 is a way of dressing bytes as text, not a lock. Second, every base64 trip in Swift runs through a single type, Data, and the decoder lives on it as a failable initializer. That one fact shapes the rest of this article, because a failable initializer changes how you write every line that follows it.

One Type Owns the Whole Job

Swift does not scatter base64 helpers across a dozen modules, and it does not make you install anything. The decoder is Data(base64Encoded:options:) in Foundation, and it has been part of the platform since the framework's early days (Apple lists the initializer from iOS 8.0, macOS 10.10, tvOS 9.0, watchOS 2.0 and visionOS 1.0; the line-length options on the encoding side even reach back to iOS 7.0). On Linux and Windows the same Foundation ships with the open-source toolchain, so the code below behaves the same in an iPhone app, a server worker, and a script in your terminal.

There is a sibling initializer, Data(base64Encoded: Data, options:), for the case where your base64 arrives as raw ASCII bytes instead of a string. Both take an options argument that defaults to []. And both share one personality trait that matters more than any option: they are failable.

import Foundation

let packed = "SGVsbG8sIFN3aWZ0IQ=="
if let data = Data(base64Encoded: packed) {
  let text = String(data: data, encoding: .utf8)
  print(text ?? "not text after all")
} else {
  print("that was not base64")
}
// Hello, Swift!

Apple's documentation for the initializer is beautifully blunt: it "returns nil when the input is not recognized as valid Base-64". No exceptions, no thrown errors, no log spam. Just a quiet nil and the responsibility of deciding what that means for your user. If you remember one thing about base64 in Swift, make it this: the decoder never crashes and never complains. It simply declines.

The Decoder's Verdict: A Table of Yes and No

So what does "valid" mean to this decoder? It turns out to be a short list of hard rules, and that list is the difference between "works in the demo" and "survives production". Every row of the table below is real behavior of the initializer on a current toolchain, so you can quote it straight into your error messages:

Input Verdict Why
TWFu Man a full four-character group needs no padding at all
TQ== M one byte plus two pads, the textbook case
SGVsbG8h Hello! eight characters is a multiple of four, so no pads are needed
==== empty Data padding with nothing behind it is legal and decodes to zero bytes
the empty string empty Data nothing in, nothing out, and the optional still succeeds
TQ nil length two: a group of four was promised but never delivered
T nil one character carries six bits and a byte needs eight
SGVsbG8hTQ nil ten characters: the final group dangles without its pads
TQ=== nil three pads: the third one has nothing left to pad
TQ==TQ nil data after the padding is a hard no
SGVs bG8h nil a single space is off-alphabet, and strict mode shows no mercy
SGVsbG8h plus a trailing newline nil the line break at the end of a file you just read counts as noise

Three rows deserve a second look. The ==== row means an if let check passes and your code sails on with zero bytes, so if an empty payload is not a valid state in your app, check the count right after the decode. The empty-string row is the same trick with less makeup. And the trailing-newline row is the single most common reason a base64 file that encoded perfectly in the morning refuses to decode in the afternoon: something along the way added a line ending, and the strict decoder takes it personally.

There is also a famous soft spot the table cannot show. Compare TQ== and TS==: both decode to the same byte, M, because the two lowest bits of that final character are discarded before they are ever inspected. Point Tg== at it instead and you get N without a fight. The decoder polices the characters and lets the trailing bits go. That leniency is not a bug, but it does mean two different strings can mean the same data, and it starts to matter the moment your system compares, deduplicates, or caches base64 values (more on that in the security section).

When the Input Is Noisier Than You Think

Real-world base64 rarely arrives as one pristine line. E-mail attachments are wrapped at 76 characters with a carriage return and line feed after each line, a habit inherited from the 1996 MIME specification, and certificate files like it wrapped at 64. The decoder has exactly one option to handle that noise, and it is a big one:

import Foundation

let mimeBody = "SGVs\r\nbG8sIG1h\naWwgbm9pc2Uu"
if let data = Data(base64Encoded: mimeBody, options: .ignoreUnknownCharacters) {
  print(String(data: data, encoding: .utf8) ?? "")
}
// Hello, mail noise.

.ignoreUnknownCharacters is documented as a decoder that "ignores unknown non-Base-64 bytes, including line ending characters", and for that job it is the right tool: the noise is deleted, the alphabet survives, and the payload comes out whole. But the option has a blind spot, and it is the one that bites Swift developers hardest: it deletes every off-alphabet character, including the - and _ of base64url. It does not translate them to + and /; it simply throws them away. Depending on what that deletion leaves behind, you get a nil (when the survivors no longer form whole groups) or, worse, a confident answer with the wrong number of bytes. A 16-character base64url string that encodes 12 bytes can come back from the lenient decoder as 9 different bytes, with no error and no apology.

The rule to keep: .ignoreUnknownCharacters is for transport noise (line breaks, stray spaces from a copy-paste), never for alphabet differences. If the payload might be base64url, convert the characters yourself first, exactly as the next section shows, and hand the decoder a clean standard string.

The URL Alphabet

Section 5 of RFC 4648 defines the cousin of the standard alphabet you have been meeting: base64url, where + becomes -, / becomes _, and the = padding is usually dropped. The reason is the same one that keeps your URLs honest: in a query string, a + is read as a space by form parsing, a / is a path separator, and an = separates keys from values. The RFC is blunt about the relationship between the two: the URL variant "should not be regarded as the same as the base64 encoding". JWTs, Web Push messages, YouTube video ids, and most modern API identifiers speak base64url, so expect to meet it on your first day.

On the decoding side, the recipe has two moves: translate the alphabet, then top up the padding, because the strict decoder still wants its multiple of four.

import Foundation

extension String {
  func dataFromBase64URL() -> Data? {
    var fixed = self
      .replacingOccurrences(of: "-", with: "+")
      .replacingOccurrences(of: "_", with: "/")
    let missing = fixed.count % 4
    if missing > 0 {
      fixed += String(repeating: "=", count: 4 - missing)
    }
    return Data(base64Encoded: fixed)
  }
}

let tokenPart = "0S__zMWaTC-iVgJ-"
if let bytes = tokenPart.dataFromBase64URL() {
  print(bytes.count) // 12
}

The modulo line is the whole trick: base64url payloads typically arrive without padding, and one or two = characters (never three) restore the group of four the decoder expects. You will find a version of this five-line extension in a surprising number of Swift codebases, and for good reason. There is one reason it will get shorter in the future: the newest Apple SDKs (26.4 and up, as of this writing) grew a native .base64URLAlphabet option for the encoder, with the matching decoding options still maturing in open-source Foundation behind an availability marker for a later toolchain. Until that reaches your minimum deployment target, the extension is the portable answer, and it will keep working on every version by construction.

Bytes First, Words Later

Here is the decision the decoder cannot make for you: it hands you a Data, a bag of bytes with no idea which alphabet the original payload was written in. If the payload was text, choosing that alphabet is your job, and Swift gives you two doors out of the byte world with very different temperaments.

  • String(data:encoding:) is the strict door. It returns an optional and answers nil when the bytes are not valid in the encoding you named. Ideal for validation, dangerous if you force-unwrap the answer.
  • String(decoding:as:) is the never-refuse door. It always returns a string, swapping in the U+FFFD replacement character for anything it cannot make sense of. Ideal for logging and previews, dangerous if you store the result and call it data.
import Foundation

let bytes = Data([0xC3, 0xA5]) // the UTF-8 spelling of the letter a with a ring
print(String(data: bytes, encoding: .utf8) ?? "?")      // a with a ring, read correctly
print(String(data: bytes, encoding: .isoLatin1) ?? "?") // two confused letters, same bytes
print(String(decoding: bytes, as: UTF8.self))           // a with a ring, and it never crashes

The recipe that covers almost everything: try strict UTF-8 first, because it is what modern APIs almost always mean; fall back to ISO Latin-1 only when the contract is silent and you would rather have readable-but-wrong than silence; reserve the never-refuse door for debug output. And one invisible intruder to check for: if the payload starts with a UTF-8 BOM (the three bytes EF BB BF), the strict conversion keeps it, and your string now begins with an invisible U+FEFF character that quietly breaks equality checks and JSON round-trips. Strip it with a prefix check when the specification does not promise one.

Opening Files

The "there is a .b64 file, give me what it hides" job is a read, a trim, a decode, and a write. The trim is not decoration; it is the difference between a file that opens and a file that returns nil, because tools, mail clients, and editors all love to leave a line break at the end:

import Foundation

let inbox = URL(fileURLWithPath: "Downloads/avatar.b64")
let outbox = URL(fileURLWithPath: "Downloads/avatar.png")
let raw = try String(contentsOf: inbox, encoding: .utf8)
if let data = Data(base64Encoded:
  raw.trimmingCharacters(in: .whitespacesAndNewlines)) {
  try data.write(to: outbox)
} else {
  print("the file was not base64 after all")
}

If the file is MIME-wrapped (line breaks every 76 characters), you have two clean escapes: decode with .ignoreUnknownCharacters and let the option eat the line endings, or strip them yourself with replacingOccurrences before a strict decode. Both are one line each. For files that are simply large, decode in aligned groups instead of reading the whole thing: every group of four characters decodes on its own, so you can carry only the current group plus a small remainder across read boundaries.

import Foundation

func decodeBase64Chunks(_ stream: InputStream, into result: inout Data) throws {
  let chunkSize = 65_536
  var buffer = [UInt8](repeating: 0, count: chunkSize)
  var leftover = ""
  result = Data()
  stream.open()
  defer { stream.close() }
  while stream.hasBytesAvailable {
    let read = stream.read(&buffer, maxLength: chunkSize)
    if read < 0 { throw CocoaError(.fileReadUnknown) }
    if read == 0 { break }
    var text = String(decoding: buffer[0..<read], as: UTF8.self)
    text = text.replacingOccurrences(of: "\r", with: "")
      .replacingOccurrences(of: "\n", with: "")
    text = leftover + text
    if text.count % 4 != 0 {
      let whole = text.count - (text.count % 4)
      leftover = String(text.suffix(text.count - whole))
      text = String(text.prefix(whole))
    } else {
      leftover = ""
    }
    guard !text.isEmpty else { continue }
    guard let part = Data(base64Encoded: text) else {
      throw CocoaError(.fileReadCorruptFile)
    }
    result.append(part)
  }
  if !leftover.isEmpty {
    guard let part = Data(base64Encoded: leftover) else {
      throw CocoaError(.fileReadCorruptFile)
    }
    result.append(part)
  }
}

Memory stays flat no matter how large the file is: one read buffer, one leftover fragment, and the result you are building. The same loop handles a download that arrives as base64 over the wire, a log file that is really an encoded stream, or any payload too big to hold in your hand.

JWTs: Reading the Three Dots

A compact JSON Web Token is three base64url parts joined by dots, and the first two of those parts are plain JSON in a trench coat. They arrive without padding, which is exactly the combination the strict decoder rejects on sight, so your dataFromBase64URL() helper from the URL section does all the heavy lifting:

import Foundation

let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0"

func openPart(_ part: String) -> String? {
  var fixed = part
    .replacingOccurrences(of: "-", with: "+")
    .replacingOccurrences(of: "_", with: "/")
  let missing = fixed.count % 4
  if missing > 0 {
    fixed += String(repeating: "=", count: 4 - missing)
  }
  guard let data = Data(base64Encoded: fixed) else { return nil }
  return String(data: data, encoding: .utf8)
}

let pieces = token.split(separator: ".")
print(openPart(String(pieces[0])) ?? "?")
// {"alg":"HS256","typ":"JWT"}
print(openPart(String(pieces[1])) ?? "?")
// {"sub":"1234567890","name":"John Doe"}

Two reminders ride along. A JWT is signed, not encrypted: the header and the payload are public information, which is exactly why a password never belongs in one (the encrypted cousin, JWE, is a different specification entirely). And the third dot-separated part is a cryptographic signature, not a document, so decode parts one and two and leave the rest alone.

Data URIs: The File Behind the Comma

Web APIs love to hide binary inside text with the data: scheme: a PNG in a profile field, a font in a CSS blob, a QR code in a settings file. The format is data:{mime};base64,{payload}, and peeling the payload off is one split away:

import Foundation

let uri = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
let payload = uri.components(separatedBy: ",").last ?? ""
if let bytes = Data(base64Encoded: payload) {
  print(String(decoding: bytes.prefix(6), as: UTF8.self)) // GIF89a
  print(bytes.count)                                      // 42
} else {
  print("not a base64 data uri")
}

The example uses the famous 42-byte transparent GIF, the smallest image in the format, which is why its opening characters show up in more codebases than almost any other base64 string on the internet. On Apple platforms the pipeline ends with a one-liner: the same Data you just decoded feeds straight into UIImage(data:) or NSImage(data:), which is why "display an avatar from an API" is a small feature and not a project.

HTTP: The Basic Header and Its Friends

The old Authorization: Basic header is a username and a password, joined by a colon, packed with standard base64 for the journey (not the URL dialect: this one lives in a header where + and / are perfectly harmless). Unpacking it is a split and a decode:

import Foundation

let header = "Basic ZWRpdG9yOnMzY3JldA=="
let packed = header.replacingOccurrences(of: "Basic ", with: "")
if let creds = Data(base64Encoded: packed) {
  print(String(data: creds, encoding: .utf8) ?? "") // editor:s3cret
} else {
  print("malformed header")
}

Keep the security footnote loud, because it applies to every base64 you will ever meet: this is packing, not protection. Basic auth is only acceptable over HTTPS, where TLS does the actual guarding and base64 merely keeps the bytes from breaking the header grammar. The same courtesy explains Authorization: Bearer tokens: the token itself is a JWT, so the decoding recipe from the JWT section applies to it unchanged.

Email: The 76-Character Habit

An e-mail attachment encoded as base64 is wrapped at 76 characters with CRLF line endings, exactly the noise the lenient option exists for. The raw MIME headers tell you which alphabet and which wrapping the sender used (Content-Transfer-Encoding: base64), and the fix is one flag:

import Foundation

let attachment = "VGhpcyBhdHRhY2htZW50IHN1cnZpdmVk\r\nIHRoZSA3Ni1jaGFyYWN0ZXIgaGFiaXQu"
if let data = Data(base64Encoded: attachment, options: .ignoreUnknownCharacters) {
  print(String(data: data, encoding: .utf8) ?? "")
}
// This attachment survived the 76-character habit.

If you are writing a mail feature rather than reading one, remember that the 76-character wrap costs you too: with a line break every 76 characters, the encoded text lands near 137 percent of the original size, which is why old mail engineers eyeballed attachment sizes with the shortcut "multiply the original by 1.37, add roughly 800 bytes of headers". The number is folklore now, but the arithmetic is still the arithmetic.

The Doubly Wrapped Payload

The most common "my data is corrupted" ticket in base64 land is data that was packed twice: one integration layer encoded it, and a second layer that never read the documentation encoded the result. The defensive move is to decode once, look at what you got, and if the result is itself a clean base64-looking string (right length, right alphabet, nothing surprising), decode it once more, deliberately, and stop. Do not write a loop that decodes until it fails. Such a loop happily eats a perfectly good file whose contents happen to look base64-ish, and after it runs, nobody can tell where the original data started.

import Foundation

func unwrapOnce(_ packed: String) -> Data? {
  let cleaned = packed.trimmingCharacters(in: .whitespacesAndNewlines)
  return Data(base64Encoded: cleaned)
}

let suspicious = "WVdKag==" // already looks packed
if let first = unwrapOnce(suspicious) {
  let inner = String(data: first, encoding: .utf8) ?? ""
  if let second = unwrapOnce(inner) {
    print("it was wrapped twice:", String(data: second, encoding: .utf8) ?? "?")
  }
}
// it was wrapped twice: abc

Two unwraps, two conscious decisions, and a payload that is finally just abc again.

Making the Nil Mean Something

Because the decoder answers nil instead of throwing, the error-handling style of your base64 code is a choice you make, and the choice you will be glad you made later is a small wrapper that turns the silent refusal into a loud, specific error:

import Foundation

enum Base64Failure: Error, CustomStringConvertible {
  case notBase64(Int)

  var description: String {
    switch self {
    case .notBase64(let length):
      return "input of \(length) characters is not valid base64"
    }
  }
}

func decodeStrict(_ text: String) throws -> Data {
  let cleaned = text.trimmingCharacters(in: .whitespacesAndNewlines)
  guard let data = Data(base64Encoded: cleaned) else {
    throw Base64Failure.notBase64(cleaned.count)
  }
  return data
}

do {
  let bytes = try decodeStrict("c3ludGF4IGVycm9y")
  print(String(data: bytes, encoding: .utf8) ?? "?")
} catch {
  print(error) // input of 14 characters is not valid base64
}

The wrapper also becomes the single place where normalization lives: the trim, any alphabet translation, any padding top-up. Callers get one function, one meaning for failure, and no ! in sight. Force-unwrapping Data(base64Encoded:)! is how a bad payload becomes a crashed app, and the wrapper is the cheap insurance against it. The same pattern works at the command line, where a script with CommandLine.arguments and a FileHandle write makes "decode this file from the shell" a five-line utility instead of a copy-paste detour through a website.

Security, Measured in Bytes

  • It is not encryption. Base64 is a reversible, instantly readable repacking. If your threat model includes a human with a browser and five seconds, you have zero protection, and every JWT header proves the point daily.
  • Canonicalize before you compare. Because TQ== and TS== decode to the same bytes, two systems can hold different spellings of the same data. A 2022 paper, "Base64 Malleability in Practice", documented what that broken uniqueness guarantee does in the wild: log mismatches, denial of service attacks, and duplicated database entries. If your Swift app caches, deduplicates, or compares base64 values, run one canonical decode (or one canonical re-encode) at the gate.
  • Cap the input before the decode. Decoding N characters allocates roughly three quarters of N bytes while you still hold the input string. A hostile client can send 100 megabytes of the letter A and watch your memory climb before the decoder ever says no. Check the length first, cheaply, and reject what is too big.
  • Beware the lenient option as a filter. .ignoreUnknownCharacters deletes characters. A "sanitizing" pass through it can turn a valid base64url payload into different data without an error. It is a noise filter for line breaks, not a validator.
  • Keep it out of URLs where you can. Large base64 payloads in query strings or paths blow past comfortable URL lengths and get mangled by proxies. Put them in request bodies, files, or tokens instead.

Performance, Briefly

The decoder is a lookup-table walk: each character is indexed into a small table and a few bits are shifted and or-ed into output bytes. On a current toolchain that is plenty fast for anything that fits in memory, and the number to remember is the output ratio: decoded bytes are about three quarters of the input length, so a 4-megabyte string costs you around 3 megabytes of result on top of the string you already hold. If you are on a path where Foundation itself is not allowed (a deeply embedded target, a WebAssembly bundle), the community package swift-extras-base64 is the notable alternative: pure Swift with no Foundation dependency, an RFC 4648-compliant encoder and decoder with base64url and padding options, and benchmarks that put it several times faster than Foundation. An earlier implementation of the same package even ships inside swift-nio's WebSocket support, which is about as close to production-grade as a side project gets. For an ordinary app or script, it is unnecessary luggage; for the constrained corner of Swift, it is the standard answer.

A Decade of Unpacking

Swift did not invent any of this, and it is worth knowing where each piece of the toolbox came from:

  • 1980s, the same-machine era. The earliest encodings of this family (uuencode on UNIX, BinHex on the TRS-80 and classic Mac) moved files between machines that assumed the other end was like theirs. uuencode used an alphabet of uppercase letters, digits, and punctuation, and its letters sit at consecutive ASCII positions, so encoding was a matter of adding 32 with no lookup table at all. Decoders of this era could assume a great deal, and the moment data crossed ecosystems, it fell over.
  • 1987, the alphabet gets an address. RFC 989 (Privacy-Enhanced Mail, February 1987) standardized the 64-character alphabet, wrapped lines at exactly 64 characters, and used = for padding and * to mark encoded-but-unencrypted data. Every PEM-style block is a descendant of that document.
  • 1996, the liberal era. MIME (RFC 2045) took the alphabet for e-mail, moved the wrap to 76 characters, and told compliant decoders to ignore any character outside the alphabet, such as the CRLF line breaks. This is the era that trained a generation to expect forgiving decoders, and the era whose expectations Swift's strict default deliberately breaks.
  • 2003 to 2006, the rules harden. RFC 3548 (2003) took a first swing at unifying the family; RFC 4648 (October 2006) settled it, codified the padding rules, and added the URL-safe alphabet. Its decoder paragraph is the one Swift follows: reject characters outside the alphabet, unless the format you are serving explicitly says to ignore them, as MIME does.
  • 2013 to 2014, the API waits in the wings. Apple's NSData class had packed and unpacked base64 for years, and the options-based API with its decoding option landed in iOS 7, in 2013, a year before Swift existed. When Swift 1.0 shipped on September 9, 2014, the decoder walked in with the language and has kept the same personality ever since: strict core, one lenient knob, failable initializer.
  • December 3, 2015, Linux gets a decoder. Swift was open-sourced that day, and with it Foundation's base64 crossed to Linux and, later, Windows. "Base64 decoding in Swift on a non-Apple machine" is barely a decade old: a late guest at a party that started in 1987.
  • 2023 to 2026, the rewrite. The Foundation rewrite (the swift-foundation project) moved Data into a pure-Swift core, and in 2025 a community pitch added native base64url and padding-omission options. As of this writing the newest Apple SDKs (26.4) ship the encoding options while the decoding options are still maturing in the open-source toolchain, so the hand-rolled extensions remain the universal answer in the meantime.

Small Wonders

  • ==== is legal input. Four pads and no data decode to an empty Data, the only base64 string whose entire content is "there is nothing here", and Swift agrees with it.
  • The decoder's character police do not check the bit police's work: TS== and TQ== both hand you M, while Tg== hands you N. Same grammar, different bits, no questions asked.
  • Swift's Data can decode base64 that arrived as bytes instead of a string, through the Data(base64Encoded: Data) variant, so a payload that crossed the wire as ASCII can skip the string round-trip entirely.
  • The word that has been base64 since the test vectors were born is foobar, and it packs to Zm9vYmFy. If you have ever seen a base64 example in the wild, there is a fair chance foobar was involved.
  • The famous 1x1 transparent GIF is exactly 42 bytes and opens with the magic word GIF89a, which is why its first eight encoded characters show up in more codebases than almost any other base64 prefix on Earth.
  • The modern open-source decoder does its invalid-character check with a single comparison: it or-s together four per-position lookup values and tests the result against a sentinel, so one branch decides the fate of a whole four-character group. The older implementation did the same job with a 128-byte table where any value at or past 0x80 meant "not a letter".
  • UTF-8 BOMs are invisible: EF BB BF at the start of a payload becomes a U+FEFF character that survives the strict conversion and then breaks equality checks a few lines of code later.
  • Swift is 27 years younger than the alphabet it decodes. The language shipped in 2014; the 64 letters it handles were standardized in 1987 and have not changed since.

That is the whole decoding toolbox: one failable initializer with a short rulebook, one lenient knob with a documented blind spot, a five-line base64url helper, a charset decision that belongs to you, a chunked loop for the big files, and a wrapper that makes the nil mean something. Decoding is where base64 bites, and you now know the names of every tooth. When the job flips around and you start packing bytes for the trip instead of unpacking them, the roughly 33 percent surcharge takes over and the wrapping options appear. The related encoding article covers that half of the round trip in full, so head over there when you are ready to ship the other direction.

Last updated: 2026-08-30

Related article: Base64 Encoding in Swift: A Complete Guide