Applied Cryptography for Developers Module 1 – Foundations: goals, bytes and security levels
Bytes, hex and Base64 in crypto code
Turn text into UTF-8 bytes, hex, Base64 and Base64url without corrupting it, decode untrusted Base64 strictly, and move bytes between JavaScript and Python.
What you will learn
- Convert between text, UTF-8 bytes, hex, Base64 and Base64url without corrupting data
- Explain why an encoding adds no secrecy and why signatures and MACs need byte-exact input
- Decode untrusted Base64 strictly, rejecting non-alphabet characters, bad padding and non-canonical input
- Move bytes between Web Crypto (ArrayBuffer, Uint8Array), Node.js Buffer and Python bytes
Before you start
What you’ll learn · Before you start
What you will learn
- Convert between text, UTF-8 bytes, hex, Base64 and Base64url without corrupting data
- Explain why an encoding adds no secrecy and why signatures and MACs need byte-exact input
- Decode untrusted Base64 strictly, rejecting non-alphabet characters, bad padding and non-canonical input
- Move bytes between Web Crypto (ArrayBuffer, Uint8Array), Node.js Buffer and Python bytes
Before you start
On this page
Every crypto function reads bytes, never text: the word café can be 5 bytes or 6, and its SHA-256 digest changes
with them. A webhook check that parses the JSON before verifying it rejects valid messages, and a Base64 decoder that
skips junk turns Zm9v!!YmFy into foobar without a word. Bugs of this kind fail quietly, often only for some users.
This lesson keeps bytes exact from end to end. You turn text into UTF-8, write bytes as hex, Base64 and Base64url by the rules of RFC 4648, see why a MAC must check the bytes exactly as they arrived, decode untrusted Base64 strictly, and move the same bytes between Web Crypto, Node.js and Python, with a two-step code lab at the end.
Crypto functions read bytes, so text must become bytes first
hashlib.sha256() takes bytes, and crypto.subtle.digest() takes an ArrayBuffer or a typed array. Text has to be
encoded first, and in crypto code that encoding is UTF-8: The standard way to store Unicode text as bytes: 1 byte for ASCII characters and 2 to 4 bytes for every other code point. TextEncoder and Python's str.encode() produce it.: in JavaScript TextEncoder
always produces UTF-8, and in Python str.encode() uses it by default. What looks like one string can still become
different bytes. Unicode can write é as one A number that Unicode assigns to one character or mark, written U+00E9 for é. UTF-8 stores each code point in 1 to 4 bytes., U+00E9, or as e followed by a combining accent,
U+0301. The Unicode normalisation: Rewriting text into one standard form, so that text that looks the same has the same code points. NFC composes characters such as é into one code point; NFD splits them into a letter and combining marks. of Unicode Annex 15 name the two: NFC
composes, NFD decomposes. Run the program to see what that does to the bytes and to a digest, then add your own name,
or a word in your own language, to samples:
"""Text becomes bytes before any crypto function sees it, and the same-looking text can become different bytes."""
import hashlib
import unicodedata
samples = [
("hello", "hello"),
("café, NFC", unicodedata.normalize("NFC", "café")),
("café, NFD", unicodedata.normalize("NFD", "café")),
("नमस्ते", "नमस्ते"),
("😀", "😀"),
]
print(f"{'Text':<11}{'len()':<7}{'UTF-8 bytes':<13}Hex")
for label, text in samples:
data = text.encode("utf-8")
print(f"{label:<11}{len(text):<7}{len(data):<13}{data.hex()}")
nfc = unicodedata.normalize("NFC", "café")
nfd = unicodedata.normalize("NFD", "café")
print()
print("Equal as Python strings:", nfc == nfd)
print("SHA-256 of the NFC bytes:", hashlib.sha256(nfc.encode()).hexdigest()[:16], "...")
print("SHA-256 of the NFD bytes:", hashlib.sha256(nfd.encode()).hexdigest()[:16], "...")
same = unicodedata.normalize("NFC", nfd) == nfc
print("Equal after normalising both to NFC:", same)
print("Back to text:", bytes.fromhex("e0a4a8e0a4aee0a4b8e0a58de0a4a4e0a587").decode("utf-8")) Output
Text len() UTF-8 bytes Hex hello 5 5 68656c6c6f café, NFC 4 5 636166c3a9 café, NFD 5 6 63616665cc81 नमस्ते 6 18 e0a4a8e0a4aee0a4b8e0a58de0a4a4e0a587 😀 1 4 f09f9880 Equal as Python strings: False SHA-256 of the NFC bytes: 850f7dc43910ff89 ... SHA-256 of the NFD bytes: 81ef060bcd98adc7 ... Equal after normalising both to NFC: True Back to text: नमस्ते
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 text_bytes.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The two forms of café print the same on screen, yet one is 5 bytes and the other 6, and their SHA-256 digests share
nothing. A password typed on one keyboard as NFC and on another as NFD would fail to match, which is why NIST’s
SP 800-63B-4 recommends normalising passwords to NFC before they are hashed. Length is no help either: नमस्ते is 6
characters and 18 bytes, and the emoji is 1 character in Python, 2 in JavaScript and 4 bytes in both. The same program in
JavaScript shows that difference in its .length column:
// The same text in JavaScript: TextEncoder always produces UTF-8, and .length counts UTF-16 code units.
const hex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
const encoder = new TextEncoder();
const samples = [
['hello', 'hello'],
['café, NFC', 'café'.normalize('NFC')],
['café, NFD', 'café'.normalize('NFD')],
['नमस्ते', 'नमस्ते'],
['😀', '😀'],
];
console.log(`${'Text'.padEnd(11)}${'.length'.padEnd(9)}${'UTF-8 bytes'.padEnd(13)}Hex`);
for (const [label, text] of samples) {
const data = encoder.encode(text);
console.log(`${label.padEnd(11)}${String(text.length).padEnd(9)}${String(data.length).padEnd(13)}${hex(data)}`);
}
const nfc = 'café'.normalize('NFC');
const nfd = 'café'.normalize('NFD');
const sha256 = async (text) => hex(new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(text))));
console.log();
console.log('Equal as JavaScript strings:', nfc === nfd);
console.log('SHA-256 of the NFC bytes:', (await sha256(nfc)).slice(0, 16), '...');
console.log('SHA-256 of the NFD bytes:', (await sha256(nfd)).slice(0, 16), '...');
console.log('Equal after normalising both to NFC:', nfd.normalize('NFC') === nfc); Output
Text .length UTF-8 bytes Hex hello 5 5 68656c6c6f café, NFC 4 5 636166c3a9 café, NFD 5 6 63616665cc81 नमस्ते 6 18 e0a4a8e0a4aee0a4b8e0a58de0a4a4e0a587 😀 2 4 f09f9880 Equal as JavaScript strings: false SHA-256 of the NFC bytes: 850f7dc43910ff89 ... SHA-256 of the NFD bytes: 81ef060bcd98adc7 ... Equal after normalising both to NFC: true
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node text_bytes.mjs
Sizing a buffer, a key or a limit by string length
Wrong: checking that a key is “32 characters long”, or that a name fits a 16-byte field by counting its characters.
Right: encode first and measure bytes. नमस्ते passes a 16-character check and still needs 18 bytes, two more than
the field holds.
Checkpoint
Check: Text becomes UTF-8 bytes
Hex and Base64 write bytes as text, and change nothing else
Bytes often have to travel through places made for text: JSON, a URL, an HTTP header, a configuration file. RFC 4648
defines the standard ways to write them. hex (Base16): Writing each byte as two characters from 0-9 and a-f, as RFC 4648 defines Base16. It doubles the size of the data and is case-insensitive., its Base16, uses two characters per byte.
Base64: The encoding of RFC 4648 that writes every 3 bytes as 4 characters from A-Z, a-z, 0-9, + and /, with = as padding. It carries bytes through text channels and hides nothing. packs 3 bytes into 4 characters from an alphabet of 64, so it grows data by a third;
Base64url: Base64 with - and _ in place of + and /, safe in URLs and file names (RFC 4648 section 5). Tokens and JSON Web Signatures usually write it without = padding. is the same with - and _ instead of + and /, safe in URLs and file names;
Base32 uses only capital letters and the digits 2 to 7, for places that may lose the difference between capital and
small letters; the one-time password lesson of this track meets it again. Here is how Base64 turns the two bytes of
fo into four characters:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Bytes first. Base64 encodes bytes, not text. UTF-8 turns "fo" into the two bytes 0x66 and 0x6F.
- Sixteen bits. Written in binary, the two bytes are 16 bits, high-order bit first.
- Groups of six. Base64 reads the bits six at a time. Sixteen bits make two full groups and a group of four, which the encoder fills with two zero bits.
- Each group is a number. The groups are the numbers 25, 38 and 60, each from 0 to 63.
- Numbers become letters. The alphabet of RFC 4648 maps 0 to 25 to A to Z, 26 to 51 to a to z, 52 to 61 to the digits, then 62 to + and 63 to /. So 25 is Z, 38 is m and 60 is 8.
- Padding to four. Every 3 bytes become 4 letters. With 2 bytes left over the encoder writes 3 letters and one =, so "fo" is Zm8=, and a 32-byte key takes 44 characters.
- Four strings, one value. The two zero bits carry nothing. A decoder that ignores them, as Buffer, atob() and b64decode() do, also turns Zm9=, Zm+= and Zm/= into "fo". RFC 4648 lets decoders reject them, and a strict one does.
The final = is padding (Base64): The = characters that fill the last group of four when the data does not divide into groups of 3 bytes: one = for 2 leftover bytes, two for 1.: it stands for the byte that the last group of three lacks.
RFC 7515, the JSON Web Signature standard, writes Base64url with every trailing = left out, and so do most tokens.
The program below computes the test vectors of RFC 4648 section 10 in all four forms; compare them with the
standard:
"""The test vectors of RFC 4648 section 10, computed here, plus the unpadded Base64url form."""
import base64
print(f"{'Input':<9}{'Base16 (hex)':<14}{'Base32':<18}{'Base64':<10}Base64url, unpadded")
for n in range(7):
data = b"foobar"[:n]
b16 = base64.b16encode(data).decode()
b32 = base64.b32encode(data).decode()
b64 = base64.b64encode(data).decode()
b64url = base64.urlsafe_b64encode(data).decode().rstrip("=")
print(f"{repr(data.decode()):<9}{b16:<14}{b32:<18}{b64:<10}{b64url}".rstrip())
two = bytes([0xFB, 0xFF])
print()
print("Bytes fb ff: Base64", base64.b64encode(two).decode(), "| Base64url", base64.urlsafe_b64encode(two).decode())
key = bytes(range(32))
print("A 32-byte key: hex", len(key.hex()), "characters, Base64", len(base64.b64encode(key)), "characters") Output
Input Base16 (hex) Base32 Base64 Base64url, unpadded '' 'f' 66 MY====== Zg== Zg 'fo' 666F MZXQ==== Zm8= Zm8 'foo' 666F6F MZXW6=== Zm9v Zm9v 'foob' 666F6F62 MZXW6YQ= Zm9vYg== Zm9vYg 'fooba' 666F6F6261 MZXW6YTB Zm9vYmE= Zm9vYmE 'foobar' 666F6F626172 MZXW6YTBOI====== Zm9vYmFy Zm9vYmFy Bytes fb ff: Base64 +/8= | Base64url -_8= A 32-byte key: hex 64 characters, Base64 44 characters
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 rfc4648.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every value matches the RFC, from Zg== for f to Zm9vYmFy for foobar, and a 32-byte key takes 64 hex characters
or 44 in Base64. All four forms can be reversed by anyone with one standard function, which is the point: an encoding
carries bytes, it does not protect them, as the first lesson of this module showed for Base64.
Base64 hides no key, token or password
Never log keys, tokens or passwords in any encoding, and never print them to make debugging easier: Base64 or hex in a log file is as good as the value itself. To match log lines, log a short fingerprint of a random key or token instead, such as the first 8 hex characters of its SHA-256 hash, and nothing at all that is derived from a password, which someone could test guesses against.
Checkpoint
Check: Hex, Base64 and Base64url
Signatures and MACs cover exact bytes
A MAC or a signature is computed over bytes, and one changed byte, even a space, gives a different result. That creates a classic bug. A webhook arrives, the handler parses the JSON, then serialises it again to check the tag. The data is the same, but the bytes are not:
"""A MAC covers exact bytes: parse first and verify later, and a valid message fails."""
import base64
import hashlib
import hmac
import json
# A fixed demo key, so the output repeats. Real keys come from a secrets store.
KEY = b"key-for-demo-only-not-a-real-key"
received = b'{"order":4471,"amount":500}' # the bytes exactly as they arrived
tag = hmac.new(KEY, received, hashlib.sha256).digest()
def check(body):
return hmac.compare_digest(hmac.new(KEY, body, hashlib.sha256).digest(), tag)
reserialised = json.dumps(json.loads(received)).encode()
print("Received bytes: ", received.decode())
print("After parse + dump:", reserialised.decode())
print("Same data, same bytes?", json.loads(received) == json.loads(reserialised), received == reserialised)
print("Tag check on the received bytes:", check(received))
print("Tag check on the re-serialised bytes:", check(reserialised))
print()
# Two different Base64 strings, one byte: the unused low bits of the last character are ignored.
for text in ["YQ==", "YR=="]:
print(f"b64decode({text!r}) = {base64.b64decode(text)!r}")
print("Equal as text:", "YQ==" == "YR==", "| equal as bytes:", base64.b64decode("YQ==") == base64.b64decode("YR=="))
# Hex is case-insensitive, so one tag has many spellings.
upper, lower = tag.hex().upper(), tag.hex()
print("Hex tag, two spellings equal as text:", upper == lower, "| as bytes:", bytes.fromhex(upper) == bytes.fromhex(lower)) Output
Received bytes: {"order":4471,"amount":500}
After parse + dump: {"order": 4471, "amount": 500}
Same data, same bytes? True False
Tag check on the received bytes: True
Tag check on the re-serialised bytes: False
b64decode('YQ==') = b'a'
b64decode('YR==') = b'a'
Equal as text: False | equal as bytes: True
Hex tag, two spellings equal as text: False | as bytes: True
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 byte_exact.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The re-serialised body gained three spaces, so the tag check failed on a genuine message. The rule is to verify the raw
bytes exactly as they arrived, and to parse only after the check passes. The rest of the output shows the opposite
problem: two different strings that stand for the same bytes. YQ== and YR== both decode to a, because the last
four bits of their second character are padding bits that a lenient decoder ignores, and a tag written in upper-case
hex is the same 32 bytes as in lower case. Code that compares, stores or blocks values as text sees two values where
the bytes hold one, so compare decoded bytes, and for tags use a constant-time comparison such as
hmac.compare_digest.
Key idea
A MAC or a signature vouches for one exact sequence of bytes: check it on the bytes as they arrived, and compare values as decoded bytes, never as one of their many text spellings.
Checkpoint
Check: Exact bytes for MACs and signatures
Decode untrusted Base64 strictly
RFC 4648 is strict about input: implementations must reject encoded data that contains characters outside the
alphabet unless the specification that uses it says otherwise, and decoders may reject an encoding whose unused pad
bits are not zero. Its security section explains why: a decoder that skips characters lets data hide in what it skips,
and makes equality checks on strings unreliable. Most decoders are more forgiving than that. This program gives Node’s
Buffer, atob() and a strict decoder of a few lines six malformed inputs:
// Six malformed Base64 inputs and one good one, given to Node's Buffer, to atob() and to a strict decoder.
const CANONICAL = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
function strictDecode(text) {
if (!CANONICAL.test(text)) throw new Error('bad characters, length or padding');
const bytes = Buffer.from(text, 'base64');
if (bytes.toString('base64') !== text) throw new Error('padding bits are not zero');
return bytes;
}
// Printable ASCII as it is, any other byte as \xNN.
const show = (bytes) => Array.from(bytes, (b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : `\\x${b.toString(16).padStart(2, '0')}`)).join('');
const attempt = (decode) => {
try {
return show(decode());
} catch (err) {
return `error: ${err.name === 'Error' ? err.message : err.name}`;
}
};
const inputs = [
['Zm9vYmFy', 'good'],
['Zm9v!!YmFy', 'junk characters'],
['Zm9v YmFy', 'a space'],
['Zm9vYmE', 'padding missing'],
['Zm9vYmE===', 'padding extra'],
['Zm9vYmF=', 'last bits not zero'],
['Zm9v-_8=', 'URL-safe letters'],
];
for (const [text, what] of inputs) {
console.log(`${JSON.stringify(text)} (${what})`);
console.log(` Buffer.from: ${attempt(() => Buffer.from(text, 'base64'))}`);
console.log(` atob: ${attempt(() => Uint8Array.from(atob(text), (c) => c.charCodeAt(0)))}`);
console.log(` strict: ${attempt(() => strictDecode(text))}`);
} Output
"Zm9vYmFy" (good) Buffer.from: foobar atob: foobar strict: foobar "Zm9v!!YmFy" (junk characters) Buffer.from: foobar atob: error: InvalidCharacterError strict: error: bad characters, length or padding "Zm9v YmFy" (a space) Buffer.from: foobar atob: foobar strict: error: bad characters, length or padding "Zm9vYmE" (padding missing) Buffer.from: fooba atob: fooba strict: error: bad characters, length or padding "Zm9vYmE===" (padding extra) Buffer.from: fooba atob: error: InvalidCharacterError strict: error: bad characters, length or padding "Zm9vYmF=" (last bits not zero) Buffer.from: fooba atob: fooba strict: error: padding bits are not zero "Zm9v-_8=" (URL-safe letters) Buffer.from: foo\xfb\xff atob: error: InvalidCharacterError strict: error: bad characters, length or padding
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node lenient_decoding.mjs
Buffer.from accepted all six, including the URL-safe letters inside standard Base64. atob() refused three and
accepted the space, the missing padding and the non-zero last bits, as the WHATWG’s forgiving-base64 algorithm
prescribes. Here are Python’s decoders on the same inputs. Add your own malformed strings to inputs and run it
again:
"""The same seven inputs given to Python's b64decode, with and without validate=True, and to a strict decoder."""
import base64
import binascii
import re
CANONICAL = re.compile(r"(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?")
def strict_decode(text):
if not CANONICAL.fullmatch(text):
raise ValueError("bad characters, length or padding")
data = base64.b64decode(text, validate=True)
if base64.b64encode(data).decode() != text:
raise ValueError("padding bits are not zero")
return data
def show(data):
"""Printable ASCII as it is, any other byte as \\xNN."""
return "".join(chr(b) if 0x20 <= b < 0x7F else f"\\x{b:02x}" for b in data)
def attempt(decode):
try:
return show(decode())
except (ValueError, binascii.Error) as err:
return f"error: {err}"
inputs = [
("Zm9vYmFy", "good"),
("Zm9v!!YmFy", "junk characters"),
("Zm9v YmFy", "a space"),
("Zm9vYmE", "padding missing"),
("Zm9vYmE===", "padding extra"),
("Zm9vYmF=", "last bits not zero"),
("Zm9v-_8=", "URL-safe letters"),
]
for text, what in inputs:
print(f'"{text}" ({what})')
print(f" b64decode: {attempt(lambda: base64.b64decode(text))}")
print(f" validate=True: {attempt(lambda: base64.b64decode(text, validate=True))}")
print(f" strict: {attempt(lambda: strict_decode(text))}") Output
"Zm9vYmFy" (good) b64decode: foobar validate=True: foobar strict: foobar "Zm9v!!YmFy" (junk characters) b64decode: foobar validate=True: error: Only base64 data is allowed strict: error: bad characters, length or padding "Zm9v YmFy" (a space) b64decode: foobar validate=True: error: Only base64 data is allowed strict: error: bad characters, length or padding "Zm9vYmE" (padding missing) b64decode: error: Incorrect padding validate=True: error: Incorrect padding strict: error: bad characters, length or padding "Zm9vYmE===" (padding extra) b64decode: fooba validate=True: error: Excess padding not allowed strict: error: bad characters, length or padding "Zm9vYmF=" (last bits not zero) b64decode: fooba validate=True: fooba strict: error: padding bits are not zero "Zm9v-_8=" (URL-safe letters) b64decode: error: Invalid base64-encoded string: number of data characters (5) cannot be 1 more than a multiple of 4 validate=True: error: Only base64 data is allowed strict: error: bad characters, length or padding
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 strict_decoding.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Version note
Run in the browser, this program uses Pyodide 314.0.7, which contains CPython 3.14.2. That version words one message
differently: for Zm9vYmE=== it says “Excess data after padding” where CPython 3.14.8 says “Excess padding not
allowed”. Both refuse the input, and every other line is the same.
In your browser, Pyodide 314.0.7 (CPython 3.14.2) prints:
"Zm9vYmFy" (good) b64decode: foobar validate=True: foobar strict: foobar "Zm9v!!YmFy" (junk characters) b64decode: foobar validate=True: error: Only base64 data is allowed strict: error: bad characters, length or padding "Zm9v YmFy" (a space) b64decode: foobar validate=True: error: Only base64 data is allowed strict: error: bad characters, length or padding "Zm9vYmE" (padding missing) b64decode: error: Incorrect padding validate=True: error: Incorrect padding strict: error: bad characters, length or padding "Zm9vYmE===" (padding extra) b64decode: fooba validate=True: error: Excess data after padding strict: error: bad characters, length or padding "Zm9vYmF=" (last bits not zero) b64decode: fooba validate=True: fooba strict: error: padding bits are not zero "Zm9v-_8=" (URL-safe letters) b64decode: error: Invalid base64-encoded string: number of data characters (5) cannot be 1 more than a multiple of 4 validate=True: error: Only base64 data is allowed strict: error: bad characters, length or padding
b64decode() with its default settings discarded the junk and the space and let the extra padding through;
validate=True refused five of the six but still took Zm9vYmF= for fooba. Only the strict decoder refused all
six. It does two things: a pattern that allows only the alphabet and the two legal endings, then a round trip that
encodes the result again and compares it with the input, which catches non-zero last bits. Which decoder your code can use depends on where the text comes from and what
you do with it. Answer the questions in the table to see:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Decoder | Junk characters | Whitespace | Missing or extra padding | Non-zero last bits | URL-safe letters |
|---|---|---|---|---|---|
| Buffer.from(text, 'base64') Runs in: Node.js | Poor skipped: 'Zm9v!!YmFy' gives foobar | Poor skipped without a word | Poor missing and extra padding both accepted | Poor 'Zm9vYmF=' accepted as fooba | Poor - and _ decoded inside standard Base64 |
| atob() Runs in: browsers and Node.js | Good throws InvalidCharacterError | Poor removed first, as its specification says | Mixed missing accepted, extra rejected | Poor 'Zm9vYmF=' accepted as fooba | Good throws InvalidCharacterError |
| b64decode() Runs in: Python, default settings | Poor discarded: 'Zm9v!!YmFy' gives foobar | Poor discarded without a word | Mixed missing rejected, extra accepted | Poor 'Zm9vYmF=' accepted as fooba | Mixed discarded, and only the leftover length fails |
| b64decode(validate=True) Runs in: Python | Good binascii.Error: Only base64 data is allowed | Good rejected | Good missing and extra padding rejected | Poor 'Zm9vYmF=' accepted as fooba | Good rejected |
| Strict decoder Runs in: a pattern check, then decode and re-encode | Good rejected by the pattern | Good rejected by the pattern | Good rejected by the pattern | Good rejected: re-encoding gives Zm9vYmE= | Good rejected by the pattern |
How the answers decide
- “Where does the Base64 come from?” From a request, a token or another system: rules out Buffer.from(text, 'base64') and b64decode(). They decode junk characters and padding mistakes silently, so a malformed value is taken for some other value instead of being refused.
- “Will you compare, store, index or sign the Base64 text itself?” Yes: rules out Buffer.from(text, 'base64'), atob(), b64decode() and b64decode(validate=True). They accept non-zero last bits, so several strings mean one value: Zm8=, Zm9=, Zm+= and Zm/= are all "fo". Decode strictly, or compare decoded bytes.
- “Is it wrapped in lines, as in PEM files or MIME email?” Yes, with line breaks: adds a caveat to Strict decoder and b64decode(validate=True). Remove the line breaks on purpose, and only those, before a strict decode, rather than letting a decoder skip whatever it does not recognise.
- “Where does the Base64 come from?” From your own code, in the same release: adds a caveat to Buffer.from(text, 'base64'), atob() and b64decode(). Workable for values your own encoder wrote, as long as nothing compares or signs the text and no other system ever sends you any.
In short: for anything that arrives from outside, decode strictly, and whenever the text itself is compared, stored as a key or signed, insist on the canonical encoding: The one spelling an encoder writes for a value. For Base64 it means the unused bits of the last character are zero, so that every value has exactly one valid string. as well.
Checkpoint
Check: Strict Base64 decoding
Moving bytes between Web Crypto, Buffer and Python
Each runtime has its own type for bytes. Web Crypto returns an ArrayBuffer, a block of memory with no methods to
read it, and you read it through a Uint8Array, a view of that memory. Node.js adds Buffer, a subclass of
Uint8Array with conversions built in. Python has one immutable type, bytes, and a mutable bytearray. Follow one
digest from JavaScript to Python:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Text to bytes. TextEncoder turns the text into UTF-8 bytes in a Uint8Array: 18 bytes for the six characters of नमस्ते.
- The digest. crypto.subtle.digest returns an ArrayBuffer, which has no methods to read its bytes. new Uint8Array(digest) is a view of the same memory that can.
- Bytes to text. JSON and HTTP headers carry text, so the 32 bytes travel as 43 Base64url characters, with no padding and no + or /.
- Text to bytes again. Python decodes the Base64url text back into a bytes object. A strict decoder rejects anything that is not the canonical form of 32 bytes.
- Compare bytes. Python computes its own digest of the same UTF-8 bytes and compares the two bytes objects with hmac.compare_digest. Comparing bytes avoids the many spellings a text form can have.
Integers need a byte order too. HOTP one-time passwords put their counter into HMAC as 8 bytes, high-order byte first
(RFC 4226, sections 5.1 and 5.2); this is called big-endian: A byte order that puts the most significant byte of a number first. HOTP counters, TLS record numbers and most network protocols use it.. JavaScript’s
DataView.setBigUint64 writes big-endian unless its third argument asks for little-endian, and Python’s struct says
which with a > or a <. The two programs below print the same digest, the same Base64url and the same counter:
// One digest, one counter and their encodings in JavaScript. across.py prints the same first six lines.
const hex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
// Node.js 24 has no Uint8Array.prototype.toBase64 yet, so a small helper that works everywhere:
const base64url = (bytes) => btoa(String.fromCharCode(...bytes)).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
const data = new TextEncoder().encode('नमस्ते'); // Uint8Array of UTF-8 bytes
const digest = await crypto.subtle.digest('SHA-256', data); // an ArrayBuffer: no methods to read it
const bytes = new Uint8Array(digest); // a view of the same memory, to read the bytes
console.log('UTF-8 bytes: ', data.length);
console.log('SHA-256, hex: ', hex(bytes));
console.log('SHA-256, base64url:', base64url(bytes));
// An 8-byte counter, high-order byte first, as HOTP (RFC 4226) wants it.
const counter = new Uint8Array(8);
new DataView(counter.buffer).setBigUint64(0, 1n); // big-endian unless littleEndian is true
const little = new Uint8Array(8);
new DataView(little.buffer).setBigUint64(0, 1n, true);
console.log('Counter 1, big-endian: ', hex(counter));
console.log('Counter 1, little-endian:', hex(little));
console.log('Back to a number:', new DataView(counter.buffer).getBigUint64(0).toString());
// Node's Buffer is a Uint8Array. Buffer.from(arrayBuffer) shares its memory; Buffer.from(uint8array) copies.
const shared = Buffer.from(digest);
const copied = Buffer.from(bytes);
bytes[0] ^= 0xff;
console.log('After changing bytes[0]: shared view follows', shared[0] === bytes[0], '| copy follows', copied[0] === bytes[0]);
console.log('Uint8Array.prototype.toBase64 in this Node.js:', typeof Uint8Array.prototype.toBase64); Output
UTF-8 bytes: 18 SHA-256, hex: ddb08d77c2d511947652161b35987022711aa216387b041dd459a03eb66a8304 SHA-256, base64url: 3bCNd8LVEZR2UhYbNZhwInEaohY4ewQd1FmgPrZqgwQ Counter 1, big-endian: 0000000000000001 Counter 1, little-endian: 0100000000000000 Back to a number: 1 After changing bytes[0]: shared view follows true | copy follows false Uint8Array.prototype.toBase64 in this Node.js: undefined
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node across.mjs
"""One digest, one counter and their encodings in Python. across.mjs prints the same first six lines."""
import base64
import hashlib
import struct
data = "नमस्ते".encode("utf-8") # bytes
digest = hashlib.sha256(data).digest() # bytes as well: Python has one type for both
print("UTF-8 bytes: ", len(data))
print("SHA-256, hex: ", digest.hex())
print("SHA-256, base64url:", base64.urlsafe_b64encode(digest).rstrip(b"=").decode())
# An 8-byte counter, high-order byte first, as HOTP (RFC 4226) wants it.
counter = struct.pack(">Q", 1) # ">" is big-endian, "Q" an unsigned 64-bit integer
print("Counter 1, big-endian: ", counter.hex())
print("Counter 1, little-endian:", struct.pack("<Q", 1).hex())
print("Back to a number:", int.from_bytes(counter, "big")) Output
UTF-8 bytes: 18 SHA-256, hex: ddb08d77c2d511947652161b35987022711aa216387b041dd459a03eb66a8304 SHA-256, base64url: 3bCNd8LVEZR2UhYbNZhwInEaohY4ewQd1FmgPrZqgwQ Counter 1, big-endian: 0000000000000001 Counter 1, little-endian: 0100000000000000 Back to a number: 1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 across.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The JavaScript run ends with two lines of its own. Buffer.from(arrayBuffer) shares the memory it was given, so
changing the Uint8Array changed the Buffer too, while Buffer.from(uint8array) made a copy. A key or a buffer you
meant to keep can change under you that way, so copy deliberately when it matters.
Uint8Array.prototype.toBase64 and toHex
Current versions of the major browsers have Uint8Array.prototype.toBase64(), toHex(), Uint8Array.fromBase64()
and fromHex(), but Node.js 24.21.0 does not: the last line of the JavaScript run prints undefined. The examples here use a two-line
helper that works in every engine.
Checkpoint
Check: Bytes across runtimes
Practise: a strict Base64url codec and a counter
Two exercises build the two conversions that crypto code needs most, and their sample tests run on this page. Step one is an unpadded Base64url codec whose decoder refuses everything its encoder would never write, held to the RFC 4648 vectors. Step two packs a 64-bit counter big-endian, held in Python to the official HOTP values of RFC 4226:
Code lab · 2 steps
Build the byte conversions of crypto code
Write an unpadded Base64url codec that refuses bad input, then pack a 64-bit counter the way HOTP reads it.
- An unpadded Base64url codec that refuses bad input To do
- Pack a 64-bit counter the way HOTP reads it To do
Exercise · Medium · Python, JavaScript
An unpadded Base64url codec that refuses bad input
Tokens, JWS signatures and many APIs carry bytes as Base64url without padding (RFC 4648 section 5, as RFC 7515 uses it). Write two functions in base64url.py (JavaScript: base64url.mjs):
base64url_encode(data)(JavaScript:base64urlEncode(bytes)) takes bytes (aUint8Array) and returns the Base64url text: the alphabetA-Z a-z 0-9 - _, and no=at the end.base64url_decode(text)(JavaScript:base64urlDecode(text)) returns the bytes, and raisesValueError(JavaScript: throws anError) for anything that is not exactly what the encoder would write: a=, a+or a/, whitespace, any other character, a length that no number of bytes gives (one character more than a multiple of four), and a last character whose unused bits are not zero, such asZhinstead ofZgfor the bytef.
The starter code is lenient: its encoder keeps the padding and the standard alphabet, and its decoder skips what it does not understand. The sample tests start with the test vectors of RFC 4648 section 10, written without padding.
Python · Starter code · base64url.py
import base64
def base64url_encode(data):
"""data (bytes) as Base64url text, without '=' padding."""
return base64.urlsafe_b64encode(data).decode() # still writes the padding
def base64url_decode(text):
"""The bytes of unpadded Base64url text; raises ValueError for anything else."""
return base64.urlsafe_b64decode(text + "==") # lenient: accepts far too much The sample tests · test_base64url.py
from base64url import base64url_decode, base64url_encode
# RFC 4648 section 10, without the padding.
VECTORS = [(b"", ""), (b"f", "Zg"), (b"fo", "Zm8"), (b"foo", "Zm9v"), (b"foob", "Zm9vYg"), (b"fooba", "Zm9vYmE"), (b"foobar", "Zm9vYmFy")]
def rejected(text):
try:
base64url_decode(text)
except ValueError:
return True
return False
def test_rfc_4648_vectors_encode():
"""the RFC 4648 section 10 vectors, unpadded"""
for data, text in VECTORS:
assert base64url_encode(data) == text, f"{data!r} should encode to {text!r}"
def test_rfc_4648_vectors_decode():
"""the same vectors decode back to their bytes"""
for data, text in VECTORS:
assert base64url_decode(text) == data, f"{text!r} should decode to {data!r}"
def test_url_safe_alphabet():
"""bytes fb ff use - and _ instead of + and /"""
assert base64url_encode(bytes([0xFB, 0xFF])) == "-_8"
assert base64url_decode("-_8") == bytes([0xFB, 0xFF])
def test_round_trip():
"""every length from 0 to 40 bytes, and every byte value, survive a round trip"""
for n in range(41):
data = bytes((7 * i + n) % 256 for i in range(n))
assert base64url_decode(base64url_encode(data)) == data
every = bytes(range(256))
assert base64url_decode(base64url_encode(every)) == every
def test_rejects_what_the_encoder_never_writes():
"""padding, the standard alphabet, whitespace, other characters, impossible lengths, non-zero last bits"""
for bad in ["Zg==", "Zm8=", "+_8", "-/8", "Zm9 v", "Zm9v\n", "Zm9v!", "Z", "Zm9vY", "Zh", "Zm9", "Zm9vYmF"]:
assert rejected(bad), f"{bad!r} should raise ValueError" JavaScript · Starter code · base64url.mjs
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
/** bytes (a Uint8Array) as Base64url text, without "=" padding. */
export function base64urlEncode(bytes) {
// Standard Base64 with padding: change the alphabet and drop the padding.
let out = '';
for (let i = 0; i < bytes.length; i += 3) {
const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
out += ALPHABET[(n >> 18) & 63] + ALPHABET[(n >> 12) & 63];
out += i + 1 < bytes.length ? ALPHABET[(n >> 6) & 63] : '=';
out += i + 2 < bytes.length ? ALPHABET[n & 63] : '=';
}
return out;
}
/** The bytes of unpadded Base64url text; throws for anything else. */
export function base64urlDecode(text) {
// Lenient: skips every character it does not know. Make it strict.
const out = [];
let value = 0;
let bits = 0;
for (const ch of text) {
const v = ALPHABET.indexOf(ch);
if (v < 0) continue;
value = ((value << 6) | v) & 0xffffff;
bits += 6;
if (bits >= 8) {
bits -= 8;
out.push((value >> bits) & 255);
}
}
return Uint8Array.from(out);
} The sample tests · base64url.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { base64urlDecode, base64urlEncode } from './base64url.mjs';
const ascii = (text) => Uint8Array.from(text, (c) => c.charCodeAt(0));
// RFC 4648 section 10, without the padding.
const VECTORS = [['', ''], ['f', 'Zg'], ['fo', 'Zm8'], ['foo', 'Zm9v'], ['foob', 'Zm9vYg'], ['fooba', 'Zm9vYmE'], ['foobar', 'Zm9vYmFy']];
test('the RFC 4648 section 10 vectors, unpadded', () => {
for (const [data, text] of VECTORS) assert.equal(base64urlEncode(ascii(data)), text, `"${data}" should encode to "${text}"`);
});
test('the same vectors decode back to their bytes', () => {
for (const [data, text] of VECTORS) assert.deepEqual(base64urlDecode(text), ascii(data), `"${text}" should decode to "${data}"`);
});
test('bytes fb ff use - and _ instead of + and /', () => {
assert.equal(base64urlEncode(Uint8Array.from([0xfb, 0xff])), '-_8');
assert.deepEqual(base64urlDecode('-_8'), Uint8Array.from([0xfb, 0xff]));
});
test('every length from 0 to 40 bytes, and every byte value, survive a round trip', () => {
for (let n = 0; n <= 40; n++) {
const data = Uint8Array.from({ length: n }, (_, i) => (7 * i + n) % 256);
assert.deepEqual(base64urlDecode(base64urlEncode(data)), data, `${n} bytes`);
}
const every = Uint8Array.from({ length: 256 }, (_, i) => i);
assert.deepEqual(base64urlDecode(base64urlEncode(every)), every);
});
test('padding, the standard alphabet, whitespace, other characters, impossible lengths, non-zero last bits', () => {
for (const bad of ['Zg==', 'Zm8=', '+_8', '-/8', 'Zm9 v', 'Zm9v\n', 'Zm9v!', 'Z', 'Zm9vY', 'Zh', 'Zm9', 'Zm9vYmF']) {
assert.throws(() => base64urlDecode(bad), `"${bad}" should throw`);
}
}); A hint
Encode first, then strip every = from the end. To decode strictly, check the characters and the length before decoding, decode, then encode the result again and compare it with the input: only the canonical text survives that round trip, so Zh fails while Zg passes. In JavaScript, three bytes a b c become the 24-bit number (a << 16) | (b << 8) | c, which gives four letters of 6 bits each.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Easy · Python, JavaScript
Pack a 64-bit counter the way HOTP reads it
HOTP one-time passwords (RFC 4226) feed an 8-byte counter into HMAC, high-order byte first. Write the two conversions in counter.py (JavaScript: counter.mjs):
counter_to_bytes(n)(JavaScript:counterToBytes(n)) returnsnas exactly 8 bytes, big-endian. It raisesValueError(JavaScript: throws) for a counter below 0 or from 2⁶⁴ up. In JavaScriptnmay be aBigIntor aNumber, and aNumberthat is not a safe integer (above 2⁵³ − 1, or with a fraction) is refused, because it may already have been rounded.counter_from_bytes(data)(JavaScript:counterFromBytes(bytes), returning aBigInt) turns 8 big-endian bytes back into the number, and refuses any other length.
The starter code uses the wrong byte order, and in JavaScript only 32 bits. The Python sample tests include the official HOTP values of RFC 4226 Appendix D for counts 0, 1 and 2, computed over your bytes.
Python · Starter code · counter.py
def counter_to_bytes(n):
"""n as 8 bytes, high-order byte first; ValueError unless 0 <= n < 2**64."""
return n.to_bytes(8, "little") # the wrong byte order
def counter_from_bytes(data):
"""The number in 8 big-endian bytes; ValueError for any other length."""
return int.from_bytes(data, "little") The sample tests · test_counter.py
import hashlib
import hmac
from counter import counter_from_bytes, counter_to_bytes
def raises_value_error(call):
try:
call()
except ValueError:
return True
return False
def test_rfc_4226_appendix_d():
"""HMAC-SHA-1 over your counter bytes gives the official HOTP values for counts 0, 1 and 2"""
secret = b"12345678901234567890"
expected = ["cc93cf18508d94934c64b65d8ba7667fb7cde4b0", "75a48a19d4cbe100644e8ac1397eea747a2d33ab", "0bacb7fa082fef30782211938bc1c5e70416ff44"]
for count, value in enumerate(expected):
assert hmac.new(secret, counter_to_bytes(count), hashlib.sha1).hexdigest() == value, f"count {count}"
def test_big_endian():
"""the high-order byte comes first"""
assert counter_to_bytes(1) == bytes.fromhex("0000000000000001")
assert counter_to_bytes(0x0102030405060708) == bytes([1, 2, 3, 4, 5, 6, 7, 8])
assert counter_to_bytes(2**64 - 1) == b"\xff" * 8
def test_out_of_range():
"""below 0 and from 2**64 up are refused"""
for bad in [-1, 2**64, 2**70]:
assert raises_value_error(lambda: counter_to_bytes(bad)), f"{bad} should raise ValueError"
def test_round_trip():
"""numbers come back unchanged, up to the largest 64-bit one"""
for n in [0, 1, 255, 256, 2**32, 2**53 + 1, 2**64 - 1]:
assert counter_from_bytes(counter_to_bytes(n)) == n, n
def test_from_bytes_needs_eight():
"""7 or 9 bytes are refused"""
for bad in [bytes(7), bytes(9)]:
assert raises_value_error(lambda: counter_from_bytes(bad)), f"{len(bad)} bytes should raise ValueError" JavaScript · Starter code · counter.mjs
/** n (a BigInt or a safe integer) as 8 bytes, high-order byte first; throws unless 0 <= n < 2**64. */
export function counterToBytes(n) {
const out = new Uint8Array(8);
new DataView(out.buffer).setUint32(4, Number(n), true); // only 32 bits, and the wrong byte order
return out;
}
/** The BigInt in 8 big-endian bytes; throws for any other length. */
export function counterFromBytes(bytes) {
return BigInt(new DataView(bytes.buffer, bytes.byteOffset, 8).getUint32(4, true));
} The sample tests · counter.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { counterFromBytes, counterToBytes } from './counter.mjs';
test('the high-order byte comes first', () => {
assert.deepEqual(counterToBytes(1), Uint8Array.from([0, 0, 0, 0, 0, 0, 0, 1]));
assert.deepEqual(counterToBytes(0x0102030405060708n), Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]));
assert.deepEqual(counterToBytes(2n ** 64n - 1n), Uint8Array.from([255, 255, 255, 255, 255, 255, 255, 255]));
});
test('below 0, from 2**64 up and unsafe numbers are refused', () => {
for (const bad of [-1n, -1, 2n ** 64n, 2 ** 53, 1.5]) assert.throws(() => counterToBytes(bad), `${String(bad)} should throw`);
});
test('numbers come back unchanged, up to the largest 64-bit one', () => {
for (const n of [0n, 1n, 255n, 256n, 2n ** 32n, 2n ** 53n + 1n, 2n ** 64n - 1n]) assert.equal(counterFromBytes(counterToBytes(n)), n, String(n));
assert.equal(counterFromBytes(counterToBytes(4294967296)), 4294967296n);
});
test('7 or 9 bytes are refused', () => {
for (const bad of [new Uint8Array(7), new Uint8Array(9)]) assert.throws(() => counterFromBytes(bad), `${bad.length} bytes should throw`);
}); A hint
Python: n.to_bytes(8, "big") and int.from_bytes(data, "big") do the conversion; check the range yourself first, so that the error is the ValueError the tests expect. JavaScript: new DataView(out.buffer).setBigUint64(0, n) writes big-endian when you leave out the third argument; convert a safe Number with BigInt(n) first.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
To finish, practise across the five ideas: questions on your weakest idea come first, each right answer earns a harder one, and a miss returns before the session ends:
Practice that adapts to you
Practise this lesson
10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to mid level): what is the difference between Base64 and Base64url, and when do you use each?
Both write 3 bytes as 4 characters from an alphabet of 64, as RFC 4648 defines them. Base64 uses + and / for the
last two values, and both mean something in URLs and file names, so a Base64 string in a query parameter can arrive
changed. Base64url uses - and _ instead, and is usually written without the = padding, which is how JSON Web
Tokens and many APIs carry signatures and keys. So use Base64 where the standard you follow says so, as in PEM files,
MIME email and many data formats, and Base64url for anything that travels in a URL, a cookie, a token or a file name.
Never mix them in one decoder: decode each with its own strict decoder, so that a value has one spelling only.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Your API verifies an HMAC over each request body. After a new gateway is added in front of it, about 2 % of valid requests fail verification, most of them from one mobile client. How do you find the cause, and what do you change?
-
Mid to senior
Revoked API tokens are kept in a denylist of their Base64 strings. A log shows a revoked token accepted again: the request carried the same token with its last character changed. Explain how that can happen, and how you fix the design.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Why can one word, such as café, have two different SHA-256 digests?
The letter é has two Unicode spellings: the single code point U+00E9, which NFC produces, or a plain e with the combining mark U+0301 after it, which NFD produces. In UTF-8 one café is then 5 bytes and the other 6, and their digests have nothing in common. That is why NIST SP 800-63B-4 wants passwords normalised to NFC before they are hashed.
-
Why must a key or a field be measured in bytes, not in characters?
Crypto code reads bytes, and the byte count depends on the encoding. नमस्ते is 6 characters but 18 bytes in UTF-8, so a 16-character check passes it and a 16-byte field cannot hold it. One emoji is 1 character in Python, 2 in JavaScript and 4 bytes in both.
-
How much bigger does hex or Base64 make data, and how long is a 32-byte key in each?
Hex uses two characters per byte, so it doubles the size. Base64 packs 3 bytes into 4 characters, so it grows data by a third. A 32-byte key is 64 hex characters or 44 in Base64. Anyone can reverse either, so neither protects anything.
-
How does Base64url differ from Base64, and what does the final = stand for?
Base64url uses - and _ instead of + and /, so it is safe in URLs, cookies and file names. The = is padding for the byte the last group of three lacks. JSON Web Signatures (RFC 7515) and most tokens write Base64url with the trailing = left out.
-
Why must a webhook's tag be checked on the raw body, before the JSON is parsed?
A MAC or signature covers exact bytes. Parsing the JSON and serialising it again changes them: in the lesson's run the copy gained three spaces, so the check failed on a genuine message. Verify the bytes as they arrived and parse only after the check passes.
-
Why compare decoded bytes instead of text spellings?
Several strings can stand for the same bytes. YQ== and YR== both decode to a in a lenient decoder, and upper-case hex is the same tag as lower-case. Code that compares, stores or blocks text sees two values where the bytes hold one.
-
How do you decode untrusted Base64 strictly?
Match a pattern that allows only the alphabet and the two legal endings, decode, then encode the result again and compare it with the input. The round trip catches non-zero unused bits. RFC 4648 says implementations must reject characters outside the alphabet unless the using specification says otherwise.
-
In what form does HOTP feed its counter to HMAC?
As 8 bytes, high-order byte first (big-endian), as RFC 4226 sections 5.1 and 5.2 say. JavaScript's DataView.setBigUint64 writes big-endian unless asked otherwise; Python's struct needs a > in its format.
Key takeaways
- Crypto functions read bytes: encode text as UTF-8, normalise it (NFC) where people type it, and measure bytes, not characters.
- Hex, Base64, Base64url and Base32 write bytes as text by the rules of RFC 4648, can be reversed by anyone, and so protect nothing.
- A MAC or a signature covers exact bytes: verify the body as it arrived, before parsing, and compare decoded bytes rather than text.
- Decoders are lenient by default: for untrusted input, check the alphabet and padding and re-encode to reject non-canonical text.
- Know each runtime’s byte type and byte order: ArrayBuffer behind a Uint8Array view, Buffer’s shared memory, Python’s bytes, and big-endian counters for HOTP.
Terms in this lesson
- Base64
- The encoding of RFC 4648 that writes every 3 bytes as 4 characters from A-Z, a-z, 0-9, + and /, with = as padding. It carries bytes through text channels and hides nothing.
- Base64url
- Base64 with - and _ in place of + and /, safe in URLs and file names (RFC 4648 section 5). Tokens and JSON Web Signatures usually write it without = padding.
- big-endian
- A byte order that puts the most significant byte of a number first. HOTP counters, TLS record numbers and most network protocols use it.
- canonical encoding
- The one spelling an encoder writes for a value. For Base64 it means the unused bits of the last character are zero, so that every value has exactly one valid string.
- hex (Base16)
- Writing each byte as two characters from 0-9 and a-f, as RFC 4648 defines Base16. It doubles the size of the data and is case-insensitive.
- padding (Base64)
- The = characters that fill the last group of four when the data does not divide into groups of 3 bytes: one = for 2 leftover bytes, two for 1.
- Unicode normalisation
- Rewriting text into one standard form, so that text that looks the same has the same code points. NFC composes characters such as é into one code point; NFD splits them into a letter and combining marks.
- UTF-8
- The standard way to store Unicode text as bytes: 1 byte for ASCII characters and 2 to 4 bytes for every other code point. TextEncoder and Python's str.encode() produce it.
References
- RFC 4648: The Base16, Base32, and Base64 Data Encodings (Internet Engineering Task Force (IETF))
- RFC 7515: JSON Web Signature (section 2, Base64url Encoding) (Internet Engineering Task Force (IETF))
- RFC 4226: HOTP, an HMAC-Based One-Time Password Algorithm (sections 5.1 and 5.2, Appendix D) (Internet Engineering Task Force (IETF))
- Encoding Standard: TextEncoder (WHATWG)
- Infra Standard: forgiving-base64 decode (WHATWG)
- Unicode Standard Annex #15: Unicode Normalization Forms (Unicode Consortium)
- NIST SP 800-63B-4: Authentication and Authenticator Management (section 3.1.1.2, password verifiers) (National Institute of Standards and Technology (NIST))
- Node.js 24 documentation: Buffer (buffers and character encodings) (OpenJS Foundation (Node.js))
- Python 3.14 documentation: base64 (Python Software Foundation)
- Python 3.14 documentation: struct (Python Software Foundation)
- MDN Web Docs: Uint8Array.prototype.toBase64() (Mozilla)
- ECMAScript language specification: DataView.prototype.setBigUint64 (Ecma International (TC39))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress