A binary-to-text encoding that is never larger than base64.
Here is a thing that happens to every engineer eventually. You have some bytes. Could be a session blob, a saved view, a thumbnail, a protobuf, a signed token. And you have somewhere to put it that only accepts text. A JSON field. A cookie. A log line. A database column that somebody declared as varchar in 2014 and nobody is going to migrate this quarter.
So you base64 it, because that is what everybody does, and it works, and you move on with your life. Then six months later you are staring at a graph of your storage bill, or a cookie that stopped fitting in 4 KB, or a queue rejecting your messages, and you go back and look at that field, and you realise you have been paying a 33% tax on it the entire time.
That is what ba64 is about. It is not a clever new compression algorithm. It is just the observation that if you are going to base64 something, you might as well check whether compressing it first makes the string shorter, and use the shorter one. The whole idea fits in one sentence. The rest of this page is the details that make it safe to actually turn on.
Base64 takes three bytes at a time. Three bytes is 24 bits. It chops those 24
bits into four groups of six bits, and writes each 6-bit group as one character
from a 64-character alphabet: A-Z, a-z,
0-9, + and /. Six bits has 64 possible
values, hence 64 characters, hence the name.
Three bytes in, four characters out. That ratio is where the tax comes from.
Four divided by three is 1.333, so a base64 string is always a third bigger than
the bytes it came from. If your input length is not a multiple of three, the
encoder pads the last group with = characters so the output length
stays a multiple of four.
"Hello, world!" 13 bytes
SGVsbG8sIHdvcmxkIQ== 20 characters
Thirteen bytes became twenty characters. Two of those are padding. This is not a flaw in base64, by the way. Base64 was designed in the email era to move binary through channels that would mangle anything outside a safe character set, and it does that job perfectly. It just was not designed to be small, because in 1987 nobody was putting a 30 KB JSON blob in a cookie.
Most of the time it does not. If you base64 a 40-byte key into a config file, who cares. The 33% starts to matter when you hit one of these:
A hard limit you cannot raise. A cookie is 4 KB, and that is the browser's rule, not yours. SQS messages cap at 256 KB. DynamoDB items at 400 KB. Kafka defaults to 1 MB. These limits do not care that your payload is 33% bigger than it needed to be. One day somebody adds three fields to a session object and the cookie stops fitting, and the failure is not a nice exception. It is a cookie that silently does not get set, and users who cannot stay logged in.
Storage you pay for repeatedly. A base64 column is not just bigger on disk. It is bigger in every index, every replica, every nightly backup, and every time it crosses a network boundary. A third more of all of that, forever.
Volume. If you are shipping a billion log events a day and each one carries a base64 field, the 33% is a line item.
And here is the annoying part. The stuff people put in these fields is almost always JSON, or logs, or HTML, or protobuf, or sparse binary. All of that compresses beautifully. You are paying a 33% expansion tax on data that would have shrunk by 70% if anybody had bothered to squeeze it first.
You should, and lots of people do, and it usually goes wrong in one of three ways.
You forked the format. The moment you write gzip-then-base64 into a field, that field is no longer base64. It looks like base64 to every tool that sees it. It decodes to garbage in every tool that tries. Everything that reads that field now has to know about your convention, including things you do not control and did not think about.
You cannot tell which is which. Old rows have plain base64. New rows
have compressed base64. Both are just strings. So you add a prefix like
gz:, and now your value is not base64 either, and anything that
validates the field with a base64 regex starts rejecting it, and if that field
goes in a URL or a cookie you get to find out which characters your prefix broke.
Sometimes it gets bigger. Compression is not free on already-random data. gzip a 4 KB encrypted blob and you get 4 KB plus a header. Now your "optimisation" made the value larger, and unless you check for that every time, you have shipped a size regression that only fires on the payloads you least want to break.
So the requirements write themselves. Whatever you do has to stay inside the base64 character set, has to be self-describing so a decoder knows what it is holding, and must never make anything bigger than plain base64 would have been.
It does both. The encoder compresses your bytes with DEFLATE, wraps them in a small header, base64s that, and compares the length against plain base64 of the original. Then it emits whichever string is shorter. If they tie, plain wins.
plain form: base64(input)
compressed form: "=" + base64(header + deflate(input))
That is the entire format. Two forms, and a rule for choosing between them.
Here is a real saved-view preferences object, the kind of thing that ends up in a cookie. 239 bytes of JSON:
{"theme":"dark","lang":"en-GB","tz":"Europe/London","density":"compact",
"columns":["id","customer","status","total","updated"], ... }
base64 320 characters
ba64 237 characters (74%)
And a batch of twelve log lines, 1,088 bytes:
base64 1,452 characters
ba64 321 characters (22%)
And here is the same encoder on "Hello, world!" and on a raw UUID:
"Hello, world!" -> SGVsbG8sIHdvcmxkIQ== (identical to base64)
UUID, 16 bytes -> jxTkX87qFnpaNt7dS+olQw== (identical to base64)
Thirteen bytes of English and sixteen bytes of UUID do not compress. There is nothing to find. So ba64 hands back exactly what base64 would have produced, down to the byte. This is the property that makes the whole thing safe to enable: you cannot regress. The worst case is the status quo.
This is the one genuinely cute part of the design, so let me spend a paragraph on it.
Compressed output starts with =. That character is inside the
base64 alphabet, which means every channel that already carries base64 safely
will carry it too. Cookies, JSON strings, log lines, HTTP headers: none of them
need to change, because = was always going to show up in base64
anyway, as padding.
But padding only ever appears at the end of a base64 string. It is
mathematically impossible for a valid base64 string to begin with =,
because the first character always encodes real data. So = is a
character that is simultaneously safe everywhere base64 is safe, and impossible
in any legitimate base64 string.
Which means the decoder is one branch:
if text starts with "=" -> it is a compressed frame, unwrap it
otherwise -> it is plain base64, decode it normally
No prefix that breaks URL validation. No version negotiation. No side channel to tell the reader what it is holding. The string says what it is, in one character, using a character the ecosystem already tolerates.
Four fields and then the payload:
version 1 byte always 0x01
method 1 byte 0x01 = raw DEFLATE, 0x02 = raw DEFLATE with padding
decoded_len 1-9 bytes the original length, as a varint
crc32 4 bytes CRC-32 of your original bytes
payload rest a raw DEFLATE stream
The length is there so a decoder can refuse to allocate before it has agreed to the size. A 100-byte string that claims it decompresses to 8 GB is rejected in constant time, without touching the payload. Decompression bombs are a real attack against anything that inflates untrusted input, and the length field plus a caller-supplied cap is what makes them boring.
The CRC-32 is there so a decoder can promise something useful: a damaged ba64 string is always a clear error, never wrong bytes. Corruption in a base64 string usually decodes to plausible-looking garbage, and you find out downstream when something else breaks. Here you find out immediately, with an error code that says which check failed.
To be clear about what the CRC is not: it is not authentication. An attacker who can rewrite your string can recompute the checksum. If you need to know that nobody tampered with the value, you need a MAC or a signature, over the decoded bytes, at the application layer. The CRC catches accidents and bugs, which is most of what actually happens to data in transit.
Here is the honest table. It comes from bench/bench.py in the repo, and you can run it yourself in about a second.
| Payload | base64 | ba64 | vs base64 | behind gzip |
|---|---|---|---|---|
| JSON API response | 12,808 B | 3,493 B | 27% | 69% |
| Access logs, 120 lines | 27,432 B | 9,065 B | 33% | 73% |
| Server-rendered HTML | 10,672 B | 2,701 B | 25% | 70% |
| Sparse binary page | 10,924 B | 3,349 B | 31% | 102% |
| Protobuf record batch | 4,560 B | 3,781 B | 83% | 92% |
| JWT | 412 B | 349 B | 85% | 92% |
| Session token, 48 B | 64 B | 64 B | 100% | 100% |
| UUID, 16 B | 24 B | 24 B | 100% | 100% |
| SHA-256 digest | 44 B | 44 B | 100% | 100% |
| JPEG | 5,464 B | 5,464 B | 100% | 100% |
A word about those numbers, because benchmark tables lie by default. The first version of this benchmark repeated one log line 120 times and reported 3%. That number was true and completely useless, because no real log file is one line repeated. Every sample here is varied: different IPs, different request IDs, different timestamps, real prose in the HTML. A quarter to a third is what you should expect on text. If you see somebody advertising 5%, look at their input.
This is the first question anybody with an HTTP background asks, and it is the
right question. If your response body is already going out with
Content-Encoding: gzip, that gzip is running over your base64 string
anyway, so what is left for ba64 to do?
More than I expected, which is why that last column is in the table. Compare gzip over base64 against gzip over ba64 and you still save around 30% on text.
The reason is that base64 is actively hostile to gzip. Compression works on repeated byte patterns, and base64 takes your nicely repetitive data and smears it across 6-bit symbols, so the same three input bytes encode to different characters depending on their alignment. The repetition is still in there somewhere, but gzip has a much harder time seeing it. Compress first, while the patterns still line up on byte boundaries, and you keep most of the win even after gzip has had its turn.
Where the transport already compresses and your payload is sparse binary, it is a wash. Where nothing compresses, nothing compresses.
Often. I would rather you know this now than discover it after a migration.
In every one of those cases you get plain base64 back, byte for byte, and you paid one compression attempt in CPU to find out. If your channel carries nothing but keys and UUIDs, ba64 gives you a checksum and nothing else, and you should not bother.
It also does not replace transport compression. If you control the whole body, gzip the whole body. ba64 is for when the individual text field is the unit under pressure, which is exactly the case where transport compression does not help you: the cookie, the queue message, the database column, the log line at rest.
One thing, and it breaks silently, so read this bit.
Do not compare encoded strings. The same input can produce different valid ba64 strings, because DEFLATE output depends on the compression level and the library version. Two services can encode identical bytes and get different text. Both are correct.
So anything that treats the encoded string as an identity breaks: equality checks, deduplication, cache keys, ETags, and worst of all HMAC comparison. Compare decoded bytes instead. Sign decoded bytes instead.
Plenty of systems compare base64 strings today and get away with it, because base64 is canonical: same bytes in, same string out, always. Those are exactly the systems this will break. If you have one, fix the comparison before you turn on the encoder, not after.
Yes, in a specific situation, and it is worth understanding because it applies to any compression anywhere near a secret, not just this one.
Compression makes output length depend on content. If an attacker can inject their own text into the same blob as your secret, and can observe the size of the result, then they can guess. A guess that matches the secret compresses slightly better, so the output gets slightly shorter, and they learn that they guessed right. Repeat, one byte at a time. This is CRIME, and its HTTP variant BREACH.
The correct answer is to not compress there. Plain base64 is a conforming ba64 encoder mode, and its length depends only on the input length, so it leaks nothing about content.
If you need compression in that position anyway, the format has an optional pad. You give the encoder a quantum and it rounds the output length up:
ba64.encode(data, pad=64) # length is always 1 mod 64
ba64.encode(data, pad=ba64.PAD_MAX) # always len(base64(data)) - 3
At PAD_MAX, two inputs of the same size always encode to the same
length, no matter how differently they compress. The attacker's oracle goes
quiet.
It costs exactly what it hides. A fully padded value is three characters shorter than plain base64 and no shorter, so you have traded the entire saving for length uniformity. Use it where a hostile party can see the length, and not elsewhere. And it raises the cost of the attack without removing it: enough queries still move a bucket boundary, and one bit always leaks, namely whether the value compressed at all. The full write-up is in SECURITY.md.
Compatibility runs one way. A ba64 decoder reads every base64 string ever written, because the plain form is base64. A plain base64 decoder cannot read the compressed form. That asymmetry dictates the order:
The hard part is not step 1 or step 2. It is knowing who your readers are.
Values escape. They get copied into a spreadsheet, grepped out of a log by a
support engineer, read by a partner service that integrated three years ago, or
parsed by a script somebody wrote once and left running. A =AQE...
in front of any of those looks like corruption, and you will hear about it.
So the honest rule is: if you cannot enumerate the readers of a channel, do not turn on the encoder for that channel. Start with the ones that are entirely inside your own system.
Six languages, seven builds. Each one is a single file with no dependencies, so you can vendor it by copying one file into your tree.
| Language | File | Notes |
|---|---|---|
| Python | python/ba64.py | reference implementation |
| TypeScript | js/ba64.ts | Node, node:zlib, synchronous |
| TypeScript | js/ba64.browser.ts | browser, web APIs only, async |
| Go | go/ba64.go | stdlib only |
| Rust | rust/src/lib.rs | miniz_oxide for inflate |
| Java | java/Ba64.java | java.util.zip |
| C# | csharp/Ba64.cs | System.IO.Compression |
Packages go to PyPI, npm, crates.io, Maven Central and NuGet with the v1.0.0 release. Until then, copy the file.
encode takes bytes and returns text. decode takes
text and returns bytes, or fails with an error carrying a code from a fixed set
of nine. decode also takes a size limit, 64 MiB by default, and that
limit is per call, not global, because the right cap for a cookie is not the
right cap for a file.
import ba64
text = ba64.encode(b"hello world") # a string
data = ba64.decode(text) # the original bytes
try:
ba64.decode(untrusted)
except ba64.Ba64Error as e:
print(e.code) # for example "E_CHECKSUM"
import { encode, decode, Ba64Error } from "ba64";
const text = encode(new TextEncoder().encode("hello world"));
const data = decode(text);
The browser build is the same API with promises, because the only DEFLATE the
web platform hands you is CompressionStream, which is stream-based.
No bundler, no wasm, no polyfill.
import { encode, decode } from "ba64/browser";
const text = await encode(new TextEncoder().encode("hello world"));
const data = await decode(text);
text := ba64.Encode([]byte("hello world")) // string
data, err := ba64.Decode(text) // []byte, error
let text = ba64::encode(b"hello world"); // String
let data = ba64::decode(&text).unwrap(); // Vec<u8>
Because an encoding is only useful if every implementation of it produces exactly what every other implementation expects, and the only way to believe that is to test it across all of them at once.
There is a shared conformance corpus of 4,084 vectors: plain passthrough, valid frames, every error code with the exact code required, encoder invariants, resource-exhaustion frames, and a 4,000-case differential set. It is generated deterministically and every vector is checked against the reference codec before it is committed. An implementation conforms if it reproduces every decode vector byte for byte and raises every error vector's exact code. Not "an error". The exact one.
On top of that, all seven builds encode the same 10,000 seeded inputs, and then every build decodes every other build's output. That is 49 encoder and decoder pairs, and they agree. Bit-flip and truncation sweeps produce zero silent corruptions across all of them. The Python reference has 100% branch coverage and a 95% mutation score, and every bug ever found is frozen into the corpus as a permanent vector, so it can never come back.
make verify # corpus regenerates deterministically and passes the reference
make cross # every language passes the shared vectors
make matrix # 7x7 encoder and decoder agreement over 10k inputs
SPEC.md is the working spec: frame layout, the decoding algorithm as an ordered list of checks so every invalid input maps to exactly one error code, the error taxonomy, resource limits, the padding method, golden examples, and a runnable reference implementation in an appendix.
The same format is an individual, Informational Internet-Draft: draft-gaikwad-ba64. Individual on purpose. Working-group adoption is where somebody proposes negotiation, and somebody else proposes a dictionary registry, and a year later you have a format with four compression methods and a capability handshake. This one has two forms and one rule, and it should stay that way.
Version 1 freezes once the vectors are tagged. There is no version 2 planned. The version byte is an escape hatch, not a roadmap.
If your text field carries compressible data and you are near a size limit, yes, and the migration is about as safe as format migrations get, because the fallback is the thing you already have.
If your field carries keys, hashes, tokens or images, no. You will get plain base64 with extra steps.
If you are not near any limit and nobody is complaining about storage, also probably no. A third off a field nobody looks at is not worth a coordination problem across every reader you own.
That is the whole pitch. It is a small format that does one thing, it cannot make anything worse, and it is tested harder than it strictly needs to be.