Base64 Encoding and Decoding in Golang
Published Updated Golang 12 min read
Four encodings in one package and why picking the wrong one breaks a URL, the padding that some APIs reject, and the streaming encoder that avoids holding the whole payload twice.
encoding/base64 exposes four pre-built encodings rather than one, and the difference between them
is two characters. Those two characters are + and /, both of which mean something else in a URL —
which is why a token that works in a request body fails as a path segment.
Written against Go 1.22.
The four encodings
package main
import (
"encoding/base64"
"fmt"
)
func main() {
data := []byte("hello, 世界?~")
fmt.Println(base64.StdEncoding.EncodeToString(data))
fmt.Println(base64.URLEncoding.EncodeToString(data))
fmt.Println(base64.RawStdEncoding.EncodeToString(data))
fmt.Println(base64.RawURLEncoding.EncodeToString(data))
}
| Encoding | Alphabet | Padding |
|---|---|---|
StdEncoding | A–Z a–z 0–9 + / | yes, = |
URLEncoding | A–Z a–z 0–9 - _ | yes, = |
RawStdEncoding | A–Z a–z 0–9 + / | no |
RawURLEncoding | A–Z a–z 0–9 - _ | no |
URLEncoding replaces + with - and / with _. Both replacements matter: + in a query string
decodes to a space, and / inside a path segment splits it into two.
Raw means no = padding. That is what JWT uses — every segment of a JWT is RawURLEncoding — and
what several APIs require.
Encoding with the wrong variant produces output that decodes to the right bytes with the matching decoder and fails with the other one. There is no self-description in the format, so the choice has to be agreed out of band.
Decoding
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
log.Fatalf("decode: %v", err)
}
fmt.Println(string(decoded))
Always check the error. DecodeString returns base64.CorruptInputError with the byte offset for an
invalid character or a bad length, and discarding it leaves you with a truncated result — the
decoder returns the bytes it managed before the failure.
The strict length rule is where padded and unpadded disagree. StdEncoding requires the input length
to be a multiple of four; a RawStdEncoding string handed to it fails with
illegal base64 data at input byte, which reads like corruption rather than a padding mismatch.
Handling either form means trying both, or normalising:
func decodeAny(s string) ([]byte, error) {
if b, err := base64.RawURLEncoding.DecodeString(s); err == nil {
return b, nil
}
return base64.StdEncoding.DecodeString(s)
}
RawURLEncoding first, because it is the stricter alphabet — a string containing + or / fails it
immediately and falls through.
Size
Base64 turns three bytes into four characters, so the output is 4/3 the size of the input, plus padding. A 1 MB payload becomes about 1.37 MB.
EncodedLen and DecodedLen give the exact figures, which is what to use when sizing a buffer:
buf := make([]byte, base64.StdEncoding.EncodedLen(len(data)))
base64.StdEncoding.Encode(buf, data)
Encode writes into a caller-supplied slice and allocates nothing, where EncodeToString allocates
the result. For anything in a hot path the first form is worth the extra line.
DecodedLen is an upper bound rather than an exact figure for the padded encodings, because padding
means the last group may carry one or two bytes rather than three. Decode returns the number
actually written, so the slice has to be resliced:
buf := make([]byte, base64.StdEncoding.DecodedLen(len(encoded)))
n, err := base64.StdEncoding.Decode(buf, encoded)
if err != nil {
return err
}
buf = buf[:n]
Skipping the reslice leaves up to two zero bytes on the end, which is invisible for text and corrupts a checksum or a key.
Streaming
EncodeToString needs the whole input in memory and produces the whole output in memory — two copies
of a large payload. NewEncoder writes as it goes:
out, err := os.Create("encoded.txt")
if err != nil {
log.Fatal(err)
}
defer out.Close()
encoder := base64.NewEncoder(base64.StdEncoding, out)
if _, err := io.Copy(encoder, input); err != nil {
log.Fatal(err)
}
if err := encoder.Close(); err != nil { // required
log.Fatal(err)
}
encoder.Close() is not optional and not covered by a defer on the file. Base64 works in three-byte
groups, and the final partial group is only written when the encoder is closed. Skip it and the
output is missing up to three bytes of input — a corruption that only appears when the length is not
a multiple of three, so it passes most casual testing.
Decoding streams the same way, and needs no Close because there is no partial group to flush:
decoder := base64.NewDecoder(base64.StdEncoding, in)
io.Copy(out, decoder)
Custom alphabets and line wrapping
base64.NewEncoding builds an encoding from a 64-character alphabet, which is occasionally necessary
for a protocol that predates the standard:
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
enc := base64.NewEncoding(alphabet).WithPadding(base64.NoPadding)
WithPadding takes a rune or base64.NoPadding, and returns a new encoding rather than mutating the
receiver — the encodings are values, and the package-level ones are safe to share across goroutines
precisely because nothing modifies them.
The other option worth knowing is line wrapping, which MIME requires at 76 characters:
wrapped := base64.StdEncoding.EncodeToString(data)
Go does not wrap. encoding/base64 produces one unbroken line, and a decoder following MIME
rules may or may not accept that. Conversely, Go’s decoder ignores \r and \n in its input, so it
reads wrapped data written by something else without help. Wrapping on output, when a consumer needs
it, is a manual step over the encoded string.
Comparing encoded values
An encoded token is text, so the reflex is ==. For anything acting as a credential — an API key, a
signature, a session identifier — that is a timing side channel, because string comparison stops at
the first differing byte:
if subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 {
// authorised
}
crypto/subtle exists for this. It is worth applying to the encoded form rather than decoding first,
since decoding an attacker-supplied string is work done before any check.
Base64 is not encryption
It is worth stating plainly, because the mistake is common: base64 is a transport encoding. Anyone can decode it, there is no key, and encoding a password or a token does not protect it.
Two places this shows up. HTTP Basic authentication sends base64(user:password), which is why it
requires TLS — the encoding is there to make the credentials safe for a header, not secret. And a JWT
payload is RawURLEncoding, readable by anyone holding the token; the signature proves it was not
altered, not that nobody read it.
Encoding also does not sanitise. Decoded bytes are as untrusted as they were before, and a decoder handed attacker-controlled input will happily produce megabytes from a short string, so a length check belongs before the decode rather than after it.
Where it is actually the right tool
Base64 exists to put binary data through a text-only channel. Legitimate uses look like:
- A binary field in JSON, which has no byte type. Go’s
encoding/jsonmarshals[]byteasStdEncodingautomatically, which is worth knowing before doing it by hand. - A small image inlined as a
data:URI. - A key or certificate in a configuration file or an environment variable.
- An attachment in an email body, where the transport is historically 7-bit.
Where the channel is already binary — a file, a request body, a database BLOB — base64 adds 33% for
nothing.
type Payload struct {
Data []byte `json:"data"` // marshalled as base64 automatically
}
More Go walkthroughs in the Golang guides, including URL encoding for the other encoding that comes up constantly.
Frequently asked questions
Which base64 encoding should I use?
StdEncoding for a body or a file, URLEncoding when the
value goes into a URL or a filename, and the Raw variants when the recipient rejects = padding.
What is the difference between StdEncoding and URLEncoding?
The last two alphabet characters:
+ and / become - and _. In a URL, + decodes to a space and / splits a path segment.
What does Raw mean?
No = padding. JWT uses RawURLEncoding for every segment, and several APIs
reject padded input.
Why does decoding fail with “illegal base64 data”?
Usually a padding mismatch — unpadded input given to a padded decoder — or the wrong alphabet. The error carries the byte offset.
Why is my streamed output truncated?
encoder.Close() was not called. The final partial group of
three bytes is only flushed on close, so the loss appears only for inputs whose length is not a
multiple of three.
How much larger is base64 data?
Four characters per three bytes, so about 33% plus padding. Use
EncodedLen for the exact number.
Is base64 secure?
No. It is an encoding, not encryption — anyone can decode it. HTTP Basic authentication relies on TLS for confidentiality, not on the encoding.
Can I read a JWT payload without the key?
Yes. It is RawURLEncoding and decodes with no secret.
The signature proves the token was not modified; it does not keep the contents private.
How do I put binary data in JSON in Go?
Use a []byte field. encoding/json encodes it as
standard base64 on marshal and decodes it on unmarshal.
When should I avoid base64?
Whenever the channel already carries bytes — a file, a request body, a BLOB column. The 33% overhead buys nothing there.