Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 25 minutes
  • Examples run with Node.js 24.21.0, Python 3.14.8, Pyodide 314.0.7 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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, its length and its UTF-8 bytes Python · text/text_bytes.py
"""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

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 with TextEncoder JavaScript · text-js/text_bytes.mjs
// 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

  1. Question 1 of 2 How many UTF-8 bytes does the word café take in its NFD form, with the accent as a separate combining mark?

    Type a number.

    A hint

    c, a and f take one byte each; so does e. The combining accent U+0301 is not ASCII.

    Show the answer: Question 1 of 2

    Answer: 6 bytes

    NFD writes é as e (1 byte) plus U+0301 (2 bytes in UTF-8), so café is 3 + 1 + 2 = 6 bytes, 63 61 66 65 cc 81 in the program's output. In NFC the é is the single code point U+00E9, 2 bytes, and the word is 5.

  2. Question 2 of 2 The emoji 😀 has a length of 2 in JavaScript and 1 in Python. How many bytes does each language hand to SHA-256 after encoding it as UTF-8?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 4 in both

    String lengths count different things in different languages, so crypto code measures bytes after encoding. The program printed f09f9880 for the emoji in both runs.

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:

Interactive · stage by stage Encoding "fo" as Base64, bit by bit Step through the two bytes of "fo" as they become 16 bits, three 6-bit groups, three letters and a padded string of four.
Encoding "fo" as Base64, bit by bit, step 7 of 7. Showing The text 'fo', UTF-8 bytes, 16 bits, Three 6-bit groups, Values 25, 38, 60, Letters Z, m, 8, Padded: Zm8=. In focus: Three 6-bit groups, Padded: Zm8=.last 2 bits ignoredThe text 'fo'two charactersUTF-8 bytes0x66 0x6F16 bits01100110 01101111Three 6-bit groups011001 100110 1111 + 00Values 25, 38, 60from the 64-letter alphabetLetters Z, m, 8A-Z, a-z, 0-9, + and /Padded: Zm8== stands for a missing byteZm8=, Zm9=, Zm+=, Zm/= all give fo

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Bytes first. Base64 encodes bytes, not text. UTF-8 turns "fo" into the two bytes 0x66 and 0x6F.
  2. Sixteen bits. Written in binary, the two bytes are 16 bits, high-order bit first.
  3. 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.
  4. Each group is a number. The groups are the numbers 25, 38 and 60, each from 0 to 63.
  5. 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.
  6. 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.
  7. 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:

RFC 4648 test vectors in Base16, Base32, Base64 and Base64url Python · rfc/rfc4648.py
"""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

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

  1. Question 1 of 2 How many characters does a 16-byte value take in padded Base64?

    Type a number.

    A hint

    Base64 writes every group of 3 bytes, and a final group of 1 or 2, as 4 characters.

    Show the answer: Question 1 of 2

    Answer: 24 characters

    16 bytes are 5 full groups of 3 and 1 byte left over. That is 6 groups of 4 characters, 24 in all, and the last group ends with ==. Unpadded Base64url would take 22.

  2. Question 2 of 2 A random token travels in a URL query parameter. Which encoding fits it best?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Base64url without padding

    Base64url is Base64 with a URL-safe alphabet, written without padding by most token formats. Pick an encoding for the channel; none of them adds secrecy.

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:

Verify the bytes you received, before anything parses them Python · exact/byte_exact.py
"""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

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

  1. Question 1 of 2 After a refactor, your webhook handler parses the JSON body, then serialises it again and checks the HMAC tag over that. Some genuine webhooks are now rejected. Why, and what is the fix?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Serialising changes bytes such as spaces and key order; verify the raw body as it arrived, and parse only after the check passes

    Verify first, parse second. The program's received body was {"order":4471,"amount":500}; after a parse and dump it gained three spaces, and the tag check went from True to False.

  2. Question 2 of 2 A denylist stores revoked tags as lower-case hex strings. A client sends a revoked tag in upper-case hex. What does a plain tag in denylist do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It misses the match, because the text differs while the bytes are the same

    Turn every value into its bytes, or one canonical text form, before you compare, index or store it. For secrets, compare bytes in constant time.

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 inputs: Buffer, atob() and a strict decoder JavaScript · strict-js/lenient_decoding.mjs
// 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 inputs: b64decode, validate=True and a strict decoder Python · strict/strict_decoding.py
"""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

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:

Interactive · answer and compare Which Base64 decoder can take this input? Answer three questions about where your Base64 comes from and what you do with it. The table marks the decoders that fit and says why the others do not.
Where does the Base64 come from?
Will you compare, store, index or sign the Base64 text itself?
Is it wrapped in lines, as in PEM files or MIME email?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

How five decoders treat six kinds of malformed Base64, from the two programs above (good means it refuses the input)
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

  1. Question 1 of 2 Which of these inputs does Python's base64.b64decode(text, validate=True) still accept that the lesson's strict decoder refuses?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Zm9vYmF=

    validate=True refused five of the six malformed inputs and still took Zm9vYmF= for fooba. The strict decoder adds a round trip, encoding the result again and comparing it with the input, which only the canonical Zm9vYmE= survives.

  2. Question 2 of 2 Your service blocks revoked API keys by their Base64 text, and decodes keys with Buffer.from(text, "base64"). A revoked key arrives with !! inserted in the middle. What happens, and what is the fix?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The text is not on the list, Buffer skips the !! and decodes the revoked key, so it is accepted; decode strictly, and block and compare by decoded bytes

    A lenient decoder turns many strings into one value. If the check reads the string and the rest of the code uses the bytes, a variant string slips past. Decode strictly and do every check on the bytes.

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:

Interactive · message by message One digest from JavaScript to Python Step through the bytes of one SHA-256 digest as JavaScript makes them, the wire carries them as text, and Python reads and compares them.
One digest from JavaScript to Python, step 5 of 5. Messages so far: JavaScript to The wire: 3bCNd8LVEZR2UhYbNZhw…; The wire to Python: 3bCNd8LVEZR2UhYbNZhw….JavaScriptArrayBuffer, 32 bytesThe wirecarries text onlyPythonbytes, 32 of them3bCNd8LVEZR2UhYbNZhw…3bCNd8LVEZR2UhYbNZhw…same 32 bytes

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. Text to bytes. TextEncoder turns the text into UTF-8 bytes in a Uint8Array: 18 bytes for the six characters of नमस्ते.
  2. 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.
  3. Bytes to text. JSON and HTTP headers carry text, so the 32 bytes travel as 43 Base64url characters, with no padding and no + or /.
  4. 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.
  5. 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:

JavaScript: Uint8Array, ArrayBuffer, DataView and Buffer JavaScript · across-js/across.mjs
// 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

Python: bytes, hashlib and struct Python · across/across.py
"""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

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

  1. Question 1 of 2 HOTP puts its counter into HMAC as 8 bytes, high-order byte first. What are the bytes of counter 1?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 00 00 00 00 00 00 00 01

    The run printed 0000000000000001 for big-endian and 0100000000000000 for little-endian. A counter written in the wrong order gives a different HMAC and every one-time password is wrong.

  2. Question 2 of 2 In Node.js, const b = Buffer.from(digest), where digest is an ArrayBuffer. Later you zero a Uint8Array view of the same digest. What is in b?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Zeros too, because Buffer.from(arrayBuffer) shares the memory

    Buffer.from(arrayBuffer) gives a view of the same memory, while Buffer.from(uint8array) makes a copy. Copy deliberately when a value must not change under you, such as a key you keep.

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.

  1. An unpadded Base64url codec that refuses bad input To do
  2. 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 (a Uint8Array) and returns the Base64url text: the alphabet A-Z a-z 0-9 - _, and no = at the end.
  • base64url_decode(text) (JavaScript: base64urlDecode(text)) returns the bytes, and raises ValueError (JavaScript: throws an Error) 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 as Zh instead of Zg for the byte f.

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.

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)) returns n as exactly 8 bytes, big-endian. It raises ValueError (JavaScript: throws) for a counter below 0 or from 2⁶⁴ up. In JavaScript n may be a BigInt or a Number, and a Number that 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 a BigInt) 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.

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.

Base64 Encoder & Decoder Encode your own bytes as Base64 or Base64url and compare the result with what your codec writes.

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.

  1. Question 1 of 10 Which character encoding does JavaScript's TextEncoder produce?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: UTF-8, always

    TextEncoder gives UTF-8 bytes in a Uint8Array, which is what Web Crypto expects for text. Python's str.encode() uses UTF-8 by default as well.

  2. Question 2 of 10 Users say their password Renée works on the laptop and fails on the phone. Your server hashes the UTF-8 bytes it receives. What is the most likely cause, and the fix?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: The two keyboards send é in different normalisation forms; normalise to NFC before hashing, as NIST SP 800-63B-4 recommends

    The lesson's program showed café in NFC (5 bytes) and NFD (6 bytes) with unrelated digests. Normalising both sides to NFC made them equal.

  3. Question 3 of 10 How many hex characters does a 32-byte key take?

    Type a number.

    Show the answer: Question 3 of 10

    Answer: 64 characters

    Hex uses two characters per byte, so 32 bytes take 64, as the program's last line printed. Base64 takes 44 for the same key.

  4. Question 4 of 10 A teammate says it is fine to log session tokens because "they are Base64url, so they are obfuscated". What do you answer?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: Base64url is reversible by anyone and the logged text is the token itself; log a short fingerprint instead

    Encodings carry bytes; they do not protect them. A fingerprint such as the first 8 hex characters of a hash of the token lets you match log lines without storing the token.

  5. Question 5 of 10 A message gets one extra newline at the end on its way to you. Does its HMAC tag still verify?

    Choose one answer.

    Show the answer: Question 5 of 10

    Answer: No, the bytes changed, so the tag computed over them changes

    One byte of difference anywhere gives an unrelated tag. That is what makes MACs useful, and why they must be checked on the exact bytes that were tagged.

  6. Question 6 of 10 A new proxy in front of your service reformats JSON bodies to save space. Signed webhooks from a partner start failing. What is the right fix?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: Make the proxy pass webhook bodies through byte for byte, or verify at the edge before anything rewrites them

    Anything that changes bytes between the sender and the verifier breaks signatures and MACs. Keep the original bytes until the check has passed.

  7. Question 7 of 10 What does RFC 4648 say an implementation must do with characters outside the Base64 alphabet?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: Reject the encoded data, unless the specification that uses it says otherwise

    RFC 4648 asks for rejection by default and lets decoders reject non-zero padding bits too. Most decoders are more lenient, which is why untrusted input needs a strict one.

  8. Question 8 of 10 Which of these does a lenient decoder such as atob() turn into the two bytes of fo? Choose every one that applies.

    Choose every answer that is right.

    Show the answer: Question 8 of 10

    Answer:

    • Zm/=
    • Zm8=
    • Zm9=

    The values 60 to 63, the characters 8, 9, + and /, differ only in their last two bits, which are padding bits in the third character of a two-byte group. So Zm8=, Zm9=, Zm+= and Zm/= all give fo, and only Zm8= is canonical.

  9. Question 9 of 10 What does crypto.subtle.digest('SHA-256', data) resolve to, and how do you read its bytes?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: An ArrayBuffer; read it through a view such as new Uint8Array(digest)

    Web Crypto takes and returns ArrayBuffers and typed arrays. The lesson's program wraps the digest in a Uint8Array before writing it as hex or Base64url.

  10. Question 10 of 10 Which Python call writes the counter 1 the way RFC 4226 puts it into HMAC?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: struct.pack(">Q", 1)

    The run printed 0000000000000001 for struct.pack(">Q", 1), the same as DataView.setBigUint64 in JavaScript. The sample tests of the counter exercise check it against the RFC's own HOTP values.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.