MD5 Hash in Base64: Content-MD5, S3 and Azure, and Converting Hex to Base64

An MD5 digest is 16 bytes. Usually those bytes are shown as 32 hexadecimal characters, but HTTP's `Content-MD5` header and several cloud storage APIs want them as Base64 instead, a 24-character string ending in `==`. The conversion has one trap that causes almost every failure: encoding the hex text instead of the raw bytes. This page shows the correct form, the wrong one and how to tell them apart, with commands for every common tool, all producing the same value for the test string `The quick brown fox jumps over the lazy dog`.

Two encodings of the same 16 bytes

Hex and Base64 are just two ways of writing the digest as text. Hex uses two characters per byte; Base64 uses four characters per three bytes, so 16 bytes become 22 characters plus two = padding characters:

raw digest   16 bytes   9e 10 7d 9d 37 2b b6 82 6b d8 1d 35 42 a4 19 d6
hex          32 chars   9e107d9d372bb6826bd81d3542a419d6
Base64       24 chars   nhB9nTcrtoJr2B01QqQZ1g==
Base64url    22 chars   nhB9nTcrtoJr2B01QqQZ1g      (no padding)

Every correct Base64 MD5 is exactly 24 characters and ends in ==, because 16 bytes always leave one byte over after the last full group of three. The empty input, for example, is 1B2M2Y8AsgTpgAmY7PhCfg==. If the value you have is 44 characters long, something went wrong, as the next section shows.

The common mistake: Base64 of the hex string

Most tools print MD5 as hex text. Piping that text into base64 encodes the 32 characters 9, e, 1, 0… rather than the 16 bytes they describe. The result is twice as long and is rejected by every API that checks it:

# correct: Base64 of the raw digest bytes
$ openssl dgst -md5 -binary fox.txt | base64
nhB9nTcrtoJr2B01QqQZ1g==

# wrong: Base64 of the 32-character hex text
$ md5sum fox.txt | cut -d' ' -f1 | tr -d '\n' | base64
OWUxMDdkOWQzNzJiYjY4MjZiZDgxZDM1NDJhNDE5ZDY=

The wrong value is easy to recognise: it is 44 characters long, and it decodes to readable hex digits. Programming languages make the same mistake easy. PHP's md5_file() returns hex by default, so base64_encode(md5_file($f)) gives the 44-character string; pass true as the second argument to get the raw bytes.

Computing Base64 MD5 directly

Ask the library for the raw digest and encode that. Each of these prints nhB9nTcrtoJr2B01QqQZ1g== for the test file:

# Python
import base64, hashlib
with open("fox.txt", "rb") as f:
    print(base64.b64encode(hashlib.file_digest(f, "md5").digest()).decode())

// Node.js: crypto can output Base64 and Base64url itself
crypto.createHash("md5").update(data).digest("base64");      // nhB9nTcrtoJr2B01QqQZ1g==
crypto.createHash("md5").update(data).digest("base64url");   // nhB9nTcrtoJr2B01QqQZ1g

// PHP: true = raw binary output
echo base64_encode(md5_file("fox.txt", true));

In Python the key is .digest() rather than .hexdigest(); in Java, MessageDigest.digest() already returns bytes, which you pass to Base64.getEncoder().encodeToString(). In PowerShell, Get-FileHash only returns hex, so convert it as shown in the next section, or, in PowerShell 7, compute the digest with [System.Security.Cryptography.MD5]::HashData() and pass the bytes to [Convert]::ToBase64String().

Converting between hex and Base64

When you already have one form, convert through bytes. xxd -r -p turns hex text into raw bytes on the command line, and base64 -d | xxd -p goes back:

# hex -> Base64
$ echo 9e107d9d372bb6826bd81d3542a419d6 | xxd -r -p | base64
nhB9nTcrtoJr2B01QqQZ1g==

# Base64 -> hex
$ echo nhB9nTcrtoJr2B01QqQZ1g== | base64 -d | xxd -p
9e107d9d372bb6826bd81d3542a419d6

# Python
base64.b64encode(bytes.fromhex("9e107d9d372bb6826bd81d3542a419d6")).decode()
base64.b64decode("nhB9nTcrtoJr2B01QqQZ1g==").hex()

This is useful when a cloud console shows one form and your build pipeline produces the other. Hex is case-insensitive, Base64 is not: never change the case of a Base64 string, because n and N encode different bits.

Content-MD5 and cloud storage

RFC 1864 defined the Content-MD5 header as the Base64 encoding of the 128-bit MD5 digest of the message body. HTTP itself later dropped the header, but storage APIs kept it as an integrity check on uploads. Amazon S3 accepts Content-MD5 on PutObject and rejects the upload with BadDigest if the body does not match, and with InvalidDigest if the value is not a valid Base64 MD5. Azure Blob Storage uses the same header and stores the value as the blob's Content-MD5 property. Google Cloud Storage reports md5Hash in object metadata, also in Base64.

MD5=$(openssl dgst -md5 -binary report.pdf | base64)
aws s3api put-object --bucket my-bucket --key report.pdf \
    --body report.pdf --content-md5 "$MD5"

One S3 detail misleads people: the ETag of an object uploaded in a single part without KMS encryption is the hex MD5 of the content, so it does not match the Base64 value you sent, although both describe the same digest. For multipart uploads the ETag is not an MD5 of the file at all; it ends in - and the number of parts. S3 also accepts newer checksum algorithms through x-amz-checksum-* headers, which avoid MD5 entirely.

Checking a value you were given

When an API rejects your checksum, decode it before suspecting the file. A correct value decodes to exactly 16 bytes; a hex-text value decodes to 32 printable characters; a value with spaces, line breaks or URL-safe characters (-, _) in a field that expects standard Base64 fails validation outright. Paste it into a Base64 decoder to see which case you have, and compare the result with the output of an MD5 hash generator for the same input. The MD5 hash length article lists every length an MD5 can legitimately have.

MD5 here protects against accidental corruption in transit, not against an attacker, who could change both the body and the header. Use HTTPS for transport security, and SHA-256 when the checksum must resist deliberate tampering.

Sources and further reading

RFC 1321 defines MD5 and its 128-bit output. RFC 4648 defines Base64 and Base64url. RFC 1864 defines the Content-MD5 header; RFC 7231 notes its removal from HTTP. The Amazon S3 PutObject API reference documents Content-MD5, BadDigest, InvalidDigest and ETag behaviour; Microsoft's Put Blob reference covers Azure, and the Google Cloud Storage JSON API documents md5Hash. The OpenSSL dgst manual describes -binary.

Frequently asked questions

How do I convert an MD5 hash to Base64?

Decode the 32 hex characters to 16 bytes and Base64-encode those bytes, for example with echo HEX | xxd -r -p | base64. The result is 24 characters ending in ==.

How long is an MD5 hash in Base64?

24 characters with padding, or 22 without. A 44-character value is the Base64 of the hex text, not of the digest, and APIs reject it.

What format does the Content-MD5 header use?

The Base64 encoding of the 16 raw bytes of the body's MD5 digest, as defined in RFC 1864. Amazon S3 and Azure Blob Storage both expect that form.

Why does S3 reject my Content-MD5 with InvalidDigest?

Usually the value is Base64 of the hex string, or hex itself, instead of Base64 of the raw digest. Compute it with openssl dgst -md5 -binary file | base64.

Why does the S3 ETag differ from my Base64 MD5?

For single-part uploads the ETag is the same digest written in hex, so it looks different but matches after conversion. Multipart upload ETags are not an MD5 of the file and end with a dash and the part count.

How do I get a Base64 MD5 in Python?

Call base64.b64encode(hashlib.md5(data).digest()).decode(). Use digest(), not hexdigest(), so that the raw 16 bytes are encoded.