Bits and bytes

A bit is a single 0 or 1. Eight bits make a byte, and eight bits can represent 28=2562^8 = 256 different values, the integers 0 to 255. Everything a computer stores, from a photo to a private key, is ultimately a sequence of bytes.

Cryptographic sizes are quoted in both units, so it pays to convert quickly. A 256-bit key is 32 bytes. A SHA-256 digest is 32 bytes. An AES block is 128 bits, which is 16 bytes. An ML-KEM-768 public key is 1,184 bytes.

Writing numbers: binary and hex

We usually write numbers in base 10. Computers store them in base 2. The number 3329, the modulus used by ML-KEM, is 110100000001 in binary. That is hard to read, so programmers use hexadecimal (base 16), with the digits 0–9 and a–f. Each hex digit stands for exactly 4 bits, so every byte is exactly two hex digits: 3329 is 0xd01.

DecimalBinaryHex
00000 000000
100000 10100a
650100 000141
2551111 1111ff

Byte order (endianness)

A number larger than 255 needs several bytes, and there are two ways to order them. Big-endian puts the most significant byte first, the way we write digits. Little-endian puts the least significant byte first, which is how x86 and ARM processors store integers in memory. Network protocols and most cryptographic standards say explicitly which order they use; getting it wrong gives a different number and a different hash.

n = 3329
print(bin(n), hex(n))
print(n.to_bytes(4, "big").hex())     # most significant byte first
print(n.to_bytes(4, "little").hex())  # least significant byte first
print(int.from_bytes(bytes.fromhex("010d0000"), "little"))
0b110100000001 0xd01
00000d01
010d0000
3329

Text is not bytes: ASCII and UTF-8

Text has to be turned into bytes before any algorithm can touch it. That translation is a character encoding. The oldest one, ASCII, maps 128 characters (English letters, digits, punctuation) to the values 0–127, one byte each: A is 0x41.

ASCII has no room for é, ж, 中 or emoji. UTF-8, the encoding used by almost the whole web, keeps ASCII unchanged and spends more bytes on everything else: 2 bytes for most accented and Cyrillic letters, 3 for most other symbols, and 4 for emoji.

CharacterUTF-8 bytes (hex)Length
A411 byte
éc3 a92 bytes
€e2 82 ac3 bytes
🔑f0 9f 94 914 bytes

So the six-character string café 🔑 is 10 bytes long. Hashing or encrypting a string always means hashing or encrypting its bytes, and two programs only agree on the result if they agree on the encoding.

Writing bytes as text: hex and Base64

Raw bytes are awkward to handle. Many byte values are not printable characters, and some (like 0) break text formats, logs and JSON. So bytes are usually written down in a text-safe form:

Text to bytes, and back again

Type any text. Then paste the Base64 or hex into the second box and decode it.

UTF-8 bytes in binary

01100011 01100001 01100110 11000011 10101001 00100000 11110000 10011111 10010100 10010001

Hex (2 characters per byte)

63 61 66 c3 a9 20 f0 9f 94 91

Base64 (4 characters per 3 bytes)

Y2Fmw6kg8J+UkQ==

characters
6
UTF-8 bytes
10
hex length
20
Base64 length
16

Nothing to decode yet.

What to notice: The same bytes can be written as binary, hex or Base64; the bytes never change, only how they are written down. Decoding needs no key, so an encoding hides nothing. Notice also that characters and bytes are not the same count: é takes 2 bytes and 🔑 takes 4 in UTF-8.
import base64

text = "café 🔑"
data = text.encode("utf-8")          # str -> bytes
print(len(text), "characters,", len(data), "bytes")
print(data.hex())                    # bytes -> hex text
print(base64.b64encode(data))        # bytes -> Base64 text
print(base64.urlsafe_b64encode(data))

# Anyone can reverse it: no key involved
print(base64.b64decode("Y2Fmw6kg8J+UkQ==").decode("utf-8"))
6 characters, 10 bytes
636166c3a920f09f9491
b'Y2Fmw6kg8J+UkQ=='
b'Y2Fmw6kg8J-UkQ=='
café 🔑

Encoding is not encryption

Encoding versus encryption

An encoding is a public, reversible way of representing data: anyone who knows the format can undo it. Encryption is reversible only with a secret key; without the key the output should look like random bytes.

This is the single most common beginner confusion in security. A password stored as Base64, a token that is “just hex”, or a config value that is “obfuscated” with Base64 are all readable by anyone who sees them. If a value must stay secret, it needs encryption; if it must not be altered, it needs a MAC or a signature; and passwords need a slow password hash, never an encoding.

Why cryptography works on bytes

Hash functions, ciphers and signatures are defined on byte strings (or on numbers built from bytes) so that every implementation, in every language, produces exactly the same output for the same input. That is why the first step in almost any cryptographic code is choosing the bytes precisely: which text encoding, which byte order, and whether a value is raw bytes or its hex or Base64 spelling.

import hashlib
digest = hashlib.sha256(b"hello").digest()
print(len(digest), "raw bytes")
print(len(digest.hex()), "hex characters")
print(digest.hex()[:16] + "...")
32 raw bytes
64 hex characters
2cf24dba5fb0a30e...

The next page, 0.2 Hash functions, starts from exactly this kind of byte string.

Common mistakes

Further reading

Check Your Understanding

  1. Q1. An API returns the value "c2VjcmV0". Is it protected?

  2. Q2. A SHA-256 digest is 32 bytes. How long is it when written in hex?

  3. Q3. How many bytes does the string "é" take in UTF-8?

  4. Q4. The integer 3329 is stored as the four bytes 01 0d 00 00. Which byte order is that?