Applied Cryptography for Developers Module 1 – Foundations: goals, bytes and security levels
What cryptography can and cannot do
Match each primitive to the property it gives (confidentiality, integrity, authenticity, non-repudiation), threat-model a feature, and see what no cipher fixes.
What you will learn
- Name the property each primitive provides, among confidentiality, integrity, authenticity and non-repudiation
- Write a short threat model that states what an attacker can see, change, replay or guess
- Choose a maintained library and its highest-level API before reaching for raw primitives
- Recognise problems cryptography cannot fix, such as broken authorisation or a compromised device
What you’ll learn
What you will learn
- Name the property each primitive provides, among confidentiality, integrity, authenticity and non-repudiation
- Write a short threat model that states what an attacker can see, change, replay or guess
- Choose a maintained library and its highest-level API before reaching for raw primitives
- Recognise problems cryptography cannot fix, such as broken authorisation or a compromised device
On this page
The OWASP Top 10:2025 ranks cryptographic failures fourth among the risks to web applications, with 32 weakness types and 1,665,348 occurrences in its data. The first weaknesses it names are a broken or risky algorithm, too little entropy and predictable random numbers: choices made in application code, where a tool was picked that does not give the guarantee the feature needed.
Cryptography gives four precise guarantees, each from its own kind of tool, and nothing beyond them. Here you match each guarantee to the cryptographic primitive: A basic building block with one precise job, such as a hash function, a MAC, a cipher or a signature scheme. Protocols such as TLS combine several primitives. that provides it, see in a program why Base64 and a plain hash protect nothing from an attacker, turn a feature into a four-line threat model, choose a library, and meet the problems that no cipher fixes, so that you know when the answer is an access check rather than more encryption.
Four guarantees, and the tool that gives each
Your shop’s server receives this from its payment provider: {"order": 4471, "amount": 500}. Before it marks the
order paid, it needs four answers, and each one is a different guarantee with a different tool behind it:
| Guarantee | The question it answers | Tools that give it | Tools that do not |
|---|---|---|---|
| confidentiality: The guarantee that information is not disclosed to anyone the owner has not authorised. Encryption gives it; encoding, compression and hashing do not. | Who else can read it? | Encryption: TLS on the connection, or an authenticated cipher such as AES-GCM on the data | Base64, hex, compression, a hash |
| data integrity: The guarantee that data has not been changed without authorisation since it was created, sent or stored. A MAC, an authenticated cipher's tag or a signature detects deliberate changes; a checksum catches only accidents. | Has it changed since it was sent? | A MAC, the tag of an authenticated cipher, or a signature | A checksum or a plain hash, which catch accidents only |
| source authentication: Assurance about who created or sent a piece of data, also called authenticity. A MAC shows that a holder of the shared key made it; a signature shows which private key made it. | Did it really come from the provider? | A MAC (a holder of the shared key made it) or a signature (the owner of the private key made it) | A sender name inside the message |
| non-repudiation: Evidence that lets a third party decide whether a given party signed a message. It needs a digital signature from a key that only that party holds, and in the end it is a legal question. | Could an auditor or a court decide who sent it? | A digital signature, with a record of whose key it is | A MAC: both sides hold the same key |
NIST’s key-management recommendation, SP 800-57 Part 1, defines the same services: confidentiality keeps information from unauthorised parties, data integrity means no unauthorised change since the data was created, sent or stored, and source authentication gives assurance about who created or sent it. It adds two warnings worth keeping. One mechanism may provide several services but never all of them, and whether a signature really commits its signer is in the end a legal decision, not a mathematical one.
A message authentication code (MAC): A function of a secret key and a message whose output, the tag, only holders of the key can compute. A receiver with the key recomputes the tag to check that the message is unchanged and came from a key holder; HMAC and KMAC are MACs. More in “HMAC and constant-time verification”, such as HMAC from RFC 2104, mixes a secret key into a hash, and that key is the whole difference. Step through one order update sent three ways and watch which design stops a person on the network path:
The drawing shows the whole scene; its 9 steps are listed below.
Every step as text
- The order update. The provider sends {"order": 4471, "amount": 500} to your webhook. It crosses networks you do not control, so assume a person on the path can read it, change it, drop it or send it again.
- Base64 only. The body leaves as Base64. Base64 has no key, so anyone who sees the text turns it back into the order with one standard function.
- Read and changed. The person on the path decodes it, writes 5000, encodes it again and passes it on. Your server decodes a well-formed order and has no way to tell that it changed.
- Body and its digest. Now the body travels with its SHA-256 digest. If one bit flips on the way, turning 500 into 501, the digest no longer matches and the server notices.
- Digest recomputed. A SHA-256 digest needs no key. The person on the path changes the amount and computes the digest of the new body, so the server's check passes on a forged order.
- Body and an HMAC tag. This time the body carries an HMAC-SHA-256 tag, computed with a key that only the provider and your server hold.
- No key, no tag. Without the key, nobody on the path can compute the right tag for an amount of 5000. Your server recomputes the tag, compares, finds no match and rejects the forged order.
- Still readable. None of the three designs hid the amount. Base64, a digest and a tag all leave the body readable. To keep it secret, encrypt it as well, with TLS on the connection or an authenticated cipher on the data.
- Proof for a third party. The tag proves to your server that a key holder sent the body, but both sides hold the key, so it cannot prove to an auditor which one did. A digital signature can, because only the provider holds its private key.
The program below runs those three designs with real functions. It flips one bit on the way, which turns 500 into 501, then swaps in a forged order with whatever someone without the key can compute. Press Run, then try your own forged amount, or a guessed key that is closer to the real one:
"""One webhook message protected three ways, and what someone on the network path can do."""
import base64
import hashlib
import hmac
# 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"
MESSAGE = b'{"order": 4471, "amount": 500}'
CHANGED = b'{"order": 4471, "amount": 5000}'
def tag(key, message):
return hmac.new(key, message, hashlib.sha256).digest()
# What the receiving server checks in each design. In all three the message
# itself travels as it is, so anyone on the path can read it.
def accepts_base64(wire):
return True # whatever decodes is taken as the message
def accepts_digest(message, digest):
return hashlib.sha256(message).digest() == digest
def accepts_tag(message, received_tag):
return hmac.compare_digest(tag(KEY, message), received_tag)
flipped = MESSAGE[:-2] + bytes([MESSAGE[-2] ^ 1]) + MESSAGE[-1:] # 500 -> 501
wire = base64.b64encode(MESSAGE)
print("Message: ", MESSAGE.decode())
print("Base64 form: ", wire.decode())
print("Decoded again:", base64.b64decode(wire).decode())
print("SHA-256: ", hashlib.sha256(MESSAGE).hexdigest()[:24], "...")
print("HMAC-SHA-256: ", tag(KEY, MESSAGE).hex()[:24], "...")
print()
# Someone on the path flips one bit, or sends CHANGED with whatever they can compute.
rows = [
("Base64", None, accepts_base64(base64.b64encode(CHANGED))),
("SHA-256 digest",
not accepts_digest(flipped, hashlib.sha256(MESSAGE).digest()),
accepts_digest(CHANGED, hashlib.sha256(CHANGED).digest())),
("HMAC-SHA-256 tag",
not accepts_tag(flipped, tag(KEY, MESSAGE)),
accepts_tag(CHANGED, tag(b"a guess, not the key", CHANGED))),
]
print(f"{'Protection':<18}{'Readable':<10}{'Bit flip caught':<17}Forged change accepted")
for name, caught, forged in rows:
flip = "no check" if caught is None else ("yes" if caught else "no")
print(f"{name:<18}{'yes':<10}{flip:<17}{'yes' if forged else 'no'}") Output
Message: {"order": 4471, "amount": 500}
Base64 form: eyJvcmRlciI6IDQ0NzEsICJhbW91bnQiOiA1MDB9
Decoded again: {"order": 4471, "amount": 500}
SHA-256: 276917d7713fc8190603a982 ...
HMAC-SHA-256: d1e5e0defa35bb038d523328 ...
Protection Readable Bit flip caught Forged change accepted
Base64 yes no check yes
SHA-256 digest yes yes yes
HMAC-SHA-256 tag yes yes no
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 properties.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
Only the HMAC tag rejected the forged order, and all three left the amount readable. Here are the same checks in JavaScript with Web Crypto, as Node.js 24.21.0 ran them; the table is identical:
// One webhook message protected three ways, and what someone on the network path can do.
// The same checks as properties.py, with Web Crypto (globalThis.crypto.subtle).
const { subtle } = globalThis.crypto;
const bytes = (text) => new TextEncoder().encode(text);
const hex = (buffer) => Array.from(new Uint8Array(buffer), (b) => b.toString(16).padStart(2, '0')).join('');
// A fixed demo key, so the output repeats. Real keys come from a secrets store.
const KEY = bytes('key-for-demo-only-not-a-real-key');
const MESSAGE = bytes('{"order": 4471, "amount": 500}');
const CHANGED = bytes('{"order": 4471, "amount": 5000}');
const hmacKey = (raw) => subtle.importKey('raw', raw, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
const sha256 = (data) => subtle.digest('SHA-256', data);
const tag = async (raw, message) => subtle.sign('HMAC', await hmacKey(raw), message);
// What the receiving server checks in each design. In all three the message
// itself travels as it is, so anyone on the path can read it.
const acceptsBase64 = (wire) => true; // whatever decodes is taken as the message
const acceptsDigest = async (message, digest) => hex(await sha256(message)) === hex(digest);
const acceptsTag = async (message, receivedTag) => subtle.verify('HMAC', await hmacKey(KEY), receivedTag, message);
const flipped = MESSAGE.slice();
flipped[flipped.length - 2] ^= 1; // 500 -> 501
const wire = btoa(String.fromCharCode(...MESSAGE));
console.log('Message: ', new TextDecoder().decode(MESSAGE));
console.log('Base64 form: ', wire);
console.log('Decoded again:', atob(wire));
console.log('SHA-256: ', hex(await sha256(MESSAGE)).slice(0, 24), '...');
console.log('HMAC-SHA-256: ', hex(await tag(KEY, MESSAGE)).slice(0, 24), '...');
console.log();
// Someone on the path flips one bit, or sends CHANGED with whatever they can compute.
const rows = [
['Base64', null, acceptsBase64(btoa(String.fromCharCode(...CHANGED)))],
['SHA-256 digest', !(await acceptsDigest(flipped, await sha256(MESSAGE))), await acceptsDigest(CHANGED, await sha256(CHANGED))],
['HMAC-SHA-256 tag', !(await acceptsTag(flipped, await tag(KEY, MESSAGE))), await acceptsTag(CHANGED, await tag(bytes('a guess, not the key'), CHANGED))],
];
console.log(`${'Protection'.padEnd(18)}${'Readable'.padEnd(10)}${'Bit flip caught'.padEnd(17)}Forged change accepted`);
for (const [name, caught, forged] of rows) {
const flip = caught === null ? 'no check' : caught ? 'yes' : 'no';
console.log(`${name.padEnd(18)}${'yes'.padEnd(10)}${flip.padEnd(17)}${forged ? 'yes' : 'no'}`);
} Output
Message: {"order": 4471, "amount": 500}
Base64 form: eyJvcmRlciI6IDQ0NzEsICJhbW91bnQiOiA1MDB9
Decoded again: {"order": 4471, "amount": 500}
SHA-256: 276917d7713fc8190603a982 ...
HMAC-SHA-256: d1e5e0defa35bb038d523328 ...
Protection Readable Bit flip caught Forged change accepted
Base64 yes no check yes
SHA-256 digest yes yes yes
HMAC-SHA-256 tag yes yes no
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node properties.mjs
Key idea
Name the guarantee before the tool: encryption hides, a MAC or an authenticated cipher’s tag proves that a key holder sent the data unchanged, a signature proves which private key signed it, and encodings and plain hashes prove nothing to an attacker.
Checkpoint
Check: Four guarantees and their tools
Encoding hides nothing, and neither does a secret design
A mobile team wants the app’s discount codes out of plain sight, so it stores them Base64-encoded and reversed. The app ships to a million phones, and every copy carries the code that undoes the trick. Nobody needs a key, because there is none: the first person to unpack the app has every code.
In 1883 Auguste Kerckhoffs listed six requirements for a cipher system, and the second has outlived the telegraph: the details of the system may become known without harm to the people using it, so its security must rest on the key alone. This is Kerckhoffs's principle: The rule that a cryptosystem must stay secure even when everything about it except the key is public, so that its security rests on the key alone.. Today it reads: assume that an attacker has your source code, your binaries and the algorithm’s specification, and design so that the only thing they lack is the key. There are good reasons for it. Designs leak through shipped apps, public repositories and former staff. A leaked key can be replaced in minutes, while a leaked design cannot. And a published algorithm has been attacked by many people before you trust it.
Every transformation that needs no key fails that test:
| Transformation | Needs a key to undo? | What it is for |
|---|---|---|
| Base64, hex | No: one standard function | Carrying bytes through channels built for text |
| Compression | No | Making data smaller |
| Obfuscation: reversing, renaming, a XOR with a constant in the code | No: the “key” ships with the code | Slowing down a casual reader |
| Encryption with AES-GCM | Yes, and nothing but the key | Confidentiality |
The first row of the program’s table above shows this for Base64: decoded again, the order reads exactly as it was sent.
A password in Base64 is a password in plain text
Wrong: DB_PASSWORD=c2VjcmV0LWRiLXBhc3M= in a configuration file, “so that nobody can read it”. Anyone who can read
the file decodes it at once. Right: keep secrets in the platform’s secret store, such as a key management service or
the operating system’s keychain, and treat anything inside a shipped app as public.
Checkpoint
Check: Keyless transformations hide nothing
A threat model in four lines
A shop emails a link to reset a password: https://shop.example/reset?token=…. Before choosing any tool, write down
what you are defending against. Four lines are enough for most features:
- What we protect: the account the link opens.
- Who might attack, and from where: someone who sees the link in a log or a forwarded email, someone who tries tokens at random, someone who gets a copy of the database.
- What they can see, change, replay or guess: they can read the address in a log, use a link they saw again, guess tokens, and read the stored copy of every token.
- What must still hold: a token cannot be guessed, works once, expires soon, belongs to one account, and is stored only as a hash, so a database copy reveals no working link.
Each ability in line 3 points at what the design needs. Reading calls for confidentiality, and changing for integrity with authenticity. Guessing calls for enough randomness: OWASP’s verification standard, ASVS 5.0, asks for at least 128 bits from a cryptographically secure generator for any value that must not be guessed (requirement 11.5.1). replay: Sending a captured, valid message again. A MAC or a signature still verifies on the copy, so a design needs its own freshness check, such as a timestamp, a nonce or a record of what was accepted. is the one that tags and signatures do not stop on their own, because a resent message verifies exactly like the original, so it needs its own rule, such as a timestamp, a single-use token or a record of what was already accepted. Say what is out of scope too: a person who controls the user’s mailbox can reset the password whatever the token, and that is a job for a second factor, not for the link.
Answer the threat model’s questions for your own data in the table below. It rules out the tools that cannot give what you need, and says why:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Tool | Keeps it secret | Detects a deliberate change | Proves who made it | Convinces a third party | Stops a replay |
|---|---|---|---|---|---|
| Base64 or hex encoding Needs: nothing (no key) | Poor one standard call decodes it, no key needed | Poor change the data and encode it again | Poor anyone can produce it | Poor proves nothing to anyone | Poor a copy is as good as the original |
| Plain hash (SHA-256) Needs: nothing (no key) | Poor the data travels beside it; a guessable value can be found by trying | Mixed catches accidents, but anyone recomputes it for a changed message | Poor anyone can compute it | Poor no key means no claim about anyone | Poor an old message keeps its old digest |
| MAC (HMAC-SHA-256) Needs: a key both sides share | Poor the message stays readable | Good a change without the key fails the check | Good a holder of the shared key made it | Poor either key holder could have made the tag | Poor a resent message verifies: add a timestamp or a nonce |
| AEAD cipher (AES-GCM) Needs: a key both sides share | Good only key holders can decrypt | Good the tag fails if one bit changes | Mixed a key holder made it, as with a MAC | Poor both sides hold the key | Poor a resent ciphertext decrypts as before |
| Signature (Ed25519) Needs: a private key one side holds | Poor signs but does not hide: encrypt as well | Good a changed message fails verification | Good only the private key can sign | Good anyone with the public key can check it | Poor a resent signed message still verifies |
| Password hash (Argon2id) Needs: a salt and a cost setting | Mixed no way back, and slow to guess if salted and costly | Poor made for comparing passwords, not messages | Poor no key, so it proves no sender | Poor proves nothing about who acted | Poor not its job |
How the answers decide
- “Must the content stay hidden from the network and from whoever reads the storage?” Yes, keep it secret: rules out Base64 or hex encoding, Plain hash (SHA-256), MAC (HMAC-SHA-256) and Signature (Ed25519). These leave the content readable: Base64 decodes, and a digest, a tag or a signature travels beside the data in the clear.
- “Could someone change it on the way or where it is stored?” Yes: rules out Base64 or hex encoding and Plain hash (SHA-256). No key is involved, so a changed message arrives with a valid encoding or a freshly computed digest.
- “Who has to check where it came from?” Anyone, or a third party such as an auditor: rules out MAC (HMAC-SHA-256) and AEAD cipher (AES-GCM). Checking needs the shared key, and either key holder could have made the tag, so a third party learns nothing from it.
- “Who has to check where it came from?” A party that shares a key with the sender: adds a caveat to Signature (Ed25519). A signature works, but when both sides already share a key, a MAC or an AEAD tag gives the same proof for less.
- “Could someone change it on the way or where it is stored?” Yes: adds a caveat to MAC (HMAC-SHA-256), AEAD cipher (AES-GCM) and Signature (Ed25519). A resent message still verifies. Add a timestamp or a message id, and reject old or repeated ones.
- “Is it a password that a person chose?” Yes, we store it to check logins: rules out Base64 or hex encoding, Plain hash (SHA-256), MAC (HMAC-SHA-256), AEAD cipher (AES-GCM) and Signature (Ed25519). Stored passwords need a slow, salted password hash, so that a stolen table resists guessing. A later module covers it.
- “Is it a password that a person chose?” No: rules out Password hash (Argon2id). A password hash is built for secrets that people choose and type. Messages and records need the other tools.
In short: data that must stay secret needs encryption, and an authenticated cipher also proves it unchanged; a message checked by a party that shares your key needs a MAC; one that anyone or a third party must check needs a signature; passwords need a password hash; and replay needs its own check whatever you pick.
Checkpoint
Check: Threat models and replay
Reach for the highest-level tool in a maintained library
The README of crypto-js, a JavaScript library that many projects still install, says that its active development has been discontinued and that it is no longer maintained, and it points users to the native crypto module of Node.js and browsers instead. Every package you add carries its maintainers’ future with it: a library nobody maintains gets no fixes for the next weakness found in it.
Two rules keep most code out of trouble. Use the platform’s own cryptography where it exists: Web Crypto in browsers
and Node.js, node:crypto, Python’s hashlib, hmac and secrets with the cryptography package for the rest, Java’s
cryptography architecture, Go’s crypto packages. ASVS 5.0 asks for industry-validated implementations of every
operation (requirement 11.2.1). And use the highest-level operation that fits:
| Instead of | Use | Why |
|---|---|---|
| A cipher and a MAC that you combine yourself | An authenticated cipher: AES-GCM or ChaCha20-Poly1305 | One call gives secrecy and a tag; ASVS 11.3.3 prefers it |
| SHA-256 of a password | A password hash: Argon2id, scrypt, bcrypt or PBKDF2 | Slow and salted, built for secrets that people guess |
| Raw RSA encryption | HPKE, or a library’s sealed box | Padding and key handling are already right |
| A home-made secure channel | TLS 1.3 from the platform | Years of analysis and test suites |
Math.random() or random |
crypto.getRandomValues() or secrets |
Unpredictable values from the operating system |
The W3C specification even names its low-level interface SubtleCrypto, to signal that its algorithms have subtle
usage requirements. Before you add a package, ask the platform what it already offers. This program does that by
trying, which is how portable code should detect an algorithm:
// Which algorithms does this runtime's Web Crypto offer? Feature-detect by trying,
// instead of assuming: engines differ, and old algorithms are left out on purpose.
const { subtle } = globalThis.crypto;
const publicExponent = new Uint8Array([1, 0, 1]); // 65537, big-endian
const keys = [
['AES-GCM, 256-bit key', { name: 'AES-GCM', length: 256 }, ['encrypt', 'decrypt']],
['HMAC with SHA-256', { name: 'HMAC', hash: 'SHA-256' }, ['sign', 'verify']],
['Ed25519 signatures', { name: 'Ed25519' }, ['sign', 'verify']],
['ECDSA on P-256', { name: 'ECDSA', namedCurve: 'P-256' }, ['sign', 'verify']],
['X25519 key agreement', { name: 'X25519' }, ['deriveBits']],
['RSA-OAEP, 3072-bit', { name: 'RSA-OAEP', modulusLength: 3072, publicExponent, hash: 'SHA-256' }, ['encrypt', 'decrypt']],
['DES-CBC', { name: 'DES-CBC', length: 64 }, ['encrypt', 'decrypt']],
['AES-ECB', { name: 'AES-ECB', length: 128 }, ['encrypt', 'decrypt']],
];
const digests = ['SHA-256', 'SHA-512', 'SHA-1', 'MD5'];
const result = async (work) => {
try {
await work();
return 'yes';
} catch (err) {
return `no (${err.name})`;
}
};
console.log('Key generation');
for (const [label, algorithm, usages] of keys) {
console.log(` ${label.padEnd(24)}${await result(() => subtle.generateKey(algorithm, false, usages))}`);
}
console.log('Digests');
for (const name of digests) {
console.log(` ${name.padEnd(24)}${await result(() => subtle.digest(name, new Uint8Array(1)))}`);
} Output
Key generation AES-GCM, 256-bit key yes HMAC with SHA-256 yes Ed25519 signatures yes ECDSA on P-256 yes X25519 key agreement yes RSA-OAEP, 3072-bit yes DES-CBC no (NotSupportedError) AES-ECB no (NotSupportedError) Digests SHA-256 yes SHA-512 yes SHA-1 yes MD5 no (NotSupportedError)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node which_algorithms.mjs
Node.js 24.21.0 offers every modern tool in the list and refuses DES, AES in ECB mode and MD5, none of which the Web Cryptography specification defines at all. Code that asks for one of them is a sign of an old design.
Algorithms still in active development in Node.js 24
Node.js 24 also has ChaCha20-Poly1305, SHA-3, ML-KEM and ML-DSA in Web Crypto, but its documentation marks them as still in active development, and using one prints a warning. The program above leaves them out for that reason; the symmetric-encryption and post-quantum modules use them.
“FIPS validated” is a different question again. It means that NIST’s Cryptographic Module Validation Program accepted a lab’s test of the module against FIPS 140, so the module has a validation certificate number; modules validated under the older FIPS 140-2 now sit on the program’s historical list, kept for systems already in use. A validated module can still be used the wrong way, so compliance and security are related questions, not the same one.
Checkpoint
Check: Maintained, high-level tools
What no cipher can fix
Asha is signed in to an invoicing app over TLS 1.3, with a session token whose HMAC tag the server checks on every request. Her invoice is number 1042. She changes the address to 1043 and gets Ben’s invoice. Step through the request:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- A valid session. Asha signed in and holds a session token with an HMAC tag that the API checks on every request. TLS protects the connection. Every cryptographic part of what follows works as designed.
- Another number. Her own invoice is 1042. She changes the number in the address to 1043. Nothing in TLS or in the token stops her from asking.
- Token verified. The API recomputes the token's tag and it matches, so the token really is Asha's. That answers who is asking, not what she may see.
- Looked up by number. The weak handler looks the invoice up by its number alone.
- Ben's row. Invoice 1043 exists, and it belongs to Ben.
- The wrong reader. The API returns Ben's invoice to Asha over a perfectly encrypted connection. This is broken access control, first in the OWASP Top 10:2025, and no cipher could have stopped it.
- The fix. The fixed handler asks for the invoice by its number and by its owner, and takes the owner from the verified token, never from the request.
- Nothing to learn. No row matches, so Asha gets the same 404 as for an invoice that does not exist, which also tells her nothing about Ben's. The fix is a rule in the code, not more encryption.
Every cryptographic check passed: the connection was encrypted and the token was genuine. What failed is a rule, and OWASP ranks it first in the Top 10:2025 as broken access control: access control enforces a policy so that users cannot act outside their permissions, and this handler never asked whether invoice 1043 was Asha’s. NIST draws the same line in SP 800-57: authorisation, the permission to perform an activity, is a security service that cryptography often supports rather than provides. The program below runs both handlers against the same three requests, and the last column is the test that fails on the weak handler and passes on the fix. Run it, then add a third invoice for Ben and ask for it with Asha’s token:
"""Every cryptographic check passes, and one handler still shows the wrong person an invoice."""
import hashlib
import hmac
# A fixed demo key, so the output repeats. Real keys come from a secrets store.
SESSION_KEY = b"session-key-for-this-demo-only!!"
INVOICES = {1042: {"owner": "asha", "total": 1200}, 1043: {"owner": "ben", "total": 87000}}
def issue_session(user):
"""A toy session token: the user's name and an HMAC tag (real ones also expire)."""
tag = hmac.new(SESSION_KEY, user.encode(), hashlib.sha256).hexdigest()
return f"{user}.{tag}"
def verify_session(token):
"""The user a session token names, or None when its tag does not match."""
user, _, tag = token.partition(".")
expected = hmac.new(SESSION_KEY, user.encode(), hashlib.sha256).hexdigest()
return user if hmac.compare_digest(expected.encode(), tag.encode()) else None
def get_invoice_weak(token, invoice_id):
user = verify_session(token)
if user is None:
return 401
return 200 if invoice_id in INVOICES else 404
def get_invoice_fixed(token, invoice_id):
user = verify_session(token)
if user is None:
return 401
invoice = INVOICES.get(invoice_id)
if invoice is None or invoice["owner"] != user:
return 404 # the same answer as "no such invoice": nothing to probe
return 200
asha = issue_session("asha")
forged = "ben." + "0" * 64
print("Asha's session token:", asha[:20] + "...")
print()
print(f"{'Handler':<8}{'Asha: 1042':<12}{'Asha: 1043':<12}{'Forged token':<14}Result")
for name, handler in [("weak", get_invoice_weak), ("fixed", get_invoice_fixed)]:
own, other, fake = handler(asha, 1042), handler(asha, 1043), handler(forged, 1043)
result = "PASS" if (own, other, fake) == (200, 404, 401) else "FAIL: Asha reads Ben's invoice"
print(f"{name:<8}{own:<12}{other:<12}{fake:<14}{result}") Output
Asha's session token: asha.b61ad51fe0929e6... Handler Asha: 1042 Asha: 1043 Forged token Result weak 200 200 401 FAIL: Asha reads Ben's invoice fixed 200 404 401 PASS
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 invoice_access.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 forged token got 401 from both handlers, so the cryptography worked in both; only the ownership check separates them.
Encrypting the invoice numbers instead of checking the owner
Wrong: turning /invoices/1043 into /invoices/9f2c…, an encrypted or random identifier, and calling it fixed. The
link still works for anyone who sees it in a shared email, a log or a browser history. Right: keep any identifier you
like, and check on every request that the invoice belongs to the user named by the verified session.
Problems of this kind need something other than cryptography:
- Missing authorisation checks, as above: every request needs a rule about who may do what.
- Leaked or hard-coded keys: encryption is only as secret as its key, and a key in a repository is public.
- A compromised device: malware on a phone or a laptop reads data before it is encrypted and after it is decrypted.
- A person tricked into the wrong action: a phishing page that the user signs in to gets a perfectly valid session.
- Copies outside the protected path: logs, caches and backups that hold the plaintext. OWASP’s guidance on cryptographic failures warns about caches that keep sensitive responses.
Key idea
Cryptography protects data on the way and at rest, and proves who made it; deciding who may do what is a rule your code has to check on every request.
Checkpoint
Check: What cryptography cannot fix
Practise: make the webhook check a real one
Write the server side of the third design: a function that accepts a webhook only when its HMAC-SHA-256 tag is right. The sample tests start with test case 2 of RFC 4231, the official HMAC-SHA-256 vector, then try a changed body, a tag made with another key, a plain digest of a forged body, a missing signature and signatures that are not hex. The starter code is the second design, a digest check, and the forged-digest test shows why it is not enough:
Exercise · Easy · Python
Replace a digest check with an HMAC check
A payment provider sends your server webhooks. Each one carries the body as bytes and a header with a 64-character hex signature: the HMAC-SHA-256 tag of the body under a key that only the provider and your server hold.
Write verify(body, signature, key) in webhook.py. It returns True only when signature is the right tag of body under key, and False for everything else: a changed body, a tag made with another key, a plain SHA-256 digest of the body, a missing signature (None, for a request without the header) and a signature that is not 64 hex characters (it never raises). Upper-case hex is fine. Compare the tags with hmac.compare_digest, not ==.
The starter code checks a plain SHA-256 digest of the body, which anyone can compute for a body of their choice. The sample tests start with test case 2 of RFC 4231, the official HMAC-SHA-256 vector: key b"Jefe", body b"what do ya want for nothing?".
Starter code · webhook.py
import hashlib
def verify(body, signature, key):
"""True when signature is the hex HMAC-SHA-256 tag of body under key, else False."""
# This checks a plain SHA-256 digest, which anyone can compute for any body.
# Replace it with an HMAC-SHA-256 check under key, compared in constant time.
return hashlib.sha256(body).hexdigest() == signature The sample tests · test_webhook.py
import hashlib
import hmac
from webhook import verify
KEY = b"webhook-key-for-the-sample-tests"
BODY = b'{"order": 4471, "amount": 500}'
def tag_of(body, key=KEY):
return hmac.new(key, body, hashlib.sha256).hexdigest()
def test_rfc_4231_test_case_2():
"""the official HMAC-SHA-256 vector is accepted"""
tag = "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
assert verify(b"what do ya want for nothing?", tag, b"Jefe") is True
def test_right_tag_accepted_in_either_case():
"""the right tag is accepted, in lower-case and upper-case hex"""
assert verify(BODY, tag_of(BODY), KEY) is True
assert verify(BODY, tag_of(BODY).upper(), KEY) is True
def test_changed_body_rejected():
"""a body changed after it was tagged is rejected"""
changed = b'{"order": 4471, "amount": 5000}'
assert verify(changed, tag_of(BODY), KEY) is False
def test_plain_digest_rejected():
"""a SHA-256 digest of the body, which anyone can compute, is rejected"""
changed = b'{"order": 4471, "amount": 5000}'
assert verify(changed, hashlib.sha256(changed).hexdigest(), KEY) is False
def test_other_key_rejected():
"""a tag made with another key is rejected"""
assert verify(BODY, tag_of(BODY, b"some-other-key"), KEY) is False
def test_missing_signature_rejected():
"""a request without the signature header (None) gives False, without an exception"""
assert verify(BODY, None, KEY) is False
def test_malformed_signatures_rejected():
"""signatures that are not 64 hex characters give False, without an exception"""
for bad in ["", "abc", "zz" * 32, tag_of(BODY)[:-1], tag_of(BODY) + "00", "é" * 64]:
assert verify(BODY, bad, KEY) is False, f"{bad!r} should be rejected" A hint
Compute the tag you expect with hmac.new(key, body, hashlib.sha256).digest(). Turn the received hex into bytes with bytes.fromhex(signature), which raises ValueError for anything that is not hex, and return False in that case. Check first that the signature is a string of 64 characters: a tag of SHA-256 is 32 bytes, 64 hex characters.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 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.
Finish with a mixed set on all five ideas: it opens with the one you are weakest on, climbs a level each time you answer well, and asks again at the end whatever you missed:
Practice that adapts to you
Practise this lesson
12 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 encoding, hashing and encryption, and which of them needs a key?
Encoding, such as Base64 or hex, rewrites bytes in another alphabet so that they survive a text channel; it needs no key, anyone can reverse it, and it protects nothing. A hash such as SHA-256 maps any input to a fixed-size digest that cannot be turned back into the input; it needs no key either, so it detects accidental changes, but anyone can compute the digest of a forged message. Encryption needs a key: without it the ciphertext reveals nothing, and an authenticated cipher such as AES-GCM also detects any change. Add a key to a hash and you get a MAC such as HMAC, which proves that a key holder sent the message unchanged without hiding it. So the quick test is the key: no key, no protection against an attacker.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A payment provider will send order updates to your webhook. A teammate proposes that the provider add a SHA-256 digest of each body in a header, so that your server can check integrity. What is wrong with the proposal, and what do you build instead?
-
Fresher to senior
Testers found that any signed-in user can read any invoice by changing the number in /invoices/1043. A teammate proposes encrypting the invoice numbers in every URL with AES-GCM. Evaluate the proposal and give the fix.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Which four guarantees does cryptography give, and which tool gives each one?
Confidentiality: encryption. Integrity: a MAC, the tag of an authenticated cipher or a signature. Authenticity: a MAC (a key holder made it) or a signature (the owner of the private key made it). Non-repudiation: a digital signature, with a record of whose key it is.
-
Why can a MAC not give non-repudiation?
Both sides hold the same key, so either of them could have made a valid tag, and an auditor cannot tell which did. A signature comes from a private key that only the signer holds, so it can point at one party, although whether it binds the signer is in the end a legal decision, not a mathematical one.
-
What does Kerckhoffs's principle say, and why does it still hold?
A cryptosystem must stay secure when everything about it except the key is known, so its security rests on the key alone. Designs leak through shipped apps, repositories and former staff; a leaked key can be replaced in minutes but a leaked design cannot; and a public algorithm has been attacked by many people before you trust it.
-
A team stores a database password Base64-encoded in a config file so that nobody can read it. What is wrong?
Base64, hex, compression and obfuscation need no key to undo, so anyone who can read the file decodes it at once. Keep secrets in a secret store such as a key management service or the operating system's keychain, and treat anything inside a shipped app as public.
-
What are the four lines of a quick threat model?
What we protect; who might attack and from where; what they can see, change, replay or guess; and what must still hold. Each ability points at a need: reading calls for confidentiality, changing for integrity and authenticity, guessing for enough randomness (ASVS 5.0 asks for at least 128 bits from a secure generator), replaying for a freshness check.
-
Why does a valid MAC or signature not stop a replayed message?
A resent copy verifies exactly like the original. The design needs its own freshness rule: a timestamp, a single-use token, or a record of what was already accepted.
-
A request passes TLS and carries a genuine session token, yet Asha reads Ben's invoice by changing the number in the address. What failed?
Access control, not cryptography: the handler never asked whether the invoice belonged to the user in the verified session. Check ownership on every request. Encrypting or randomising the invoice number only hides the link, which still works for anyone who sees it.
-
Name the higher-level choice for each: a cipher plus a MAC you combine yourself; SHA-256 of a password; raw RSA encryption; a home-made secure channel.
An authenticated cipher (AES-GCM or ChaCha20-Poly1305); a password hash (Argon2id, scrypt, bcrypt or PBKDF2); HPKE or a library's sealed box; TLS 1.3 from the platform. Use the platform's maintained cryptography and its highest-level operation that fits.
Key takeaways
- Cryptography gives four guarantees: confidentiality, integrity, authenticity and non-repudiation, each from its own tool, and a MAC is not a signature, because both sides hold its key.
- Base64, hex, compression and obfuscation need no key, so by Kerckhoffs’s principle they hide nothing from someone who knows the design.
- A four-line threat model (what you protect, who attacks from where, what they can see, change, replay or guess, and what must hold) decides the tool, and replay always needs its own check.
- Prefer the platform’s maintained cryptography and its highest-level operation: an authenticated cipher, a password hash, TLS, HPKE, a secure random generator.
- Authorisation, leaked keys, compromised devices and phishing are not cryptographic problems, and encrypting more does not fix them.
Terms in this lesson
- confidentiality
- The guarantee that information is not disclosed to anyone the owner has not authorised. Encryption gives it; encoding, compression and hashing do not.
- cryptographic primitive
- A basic building block with one precise job, such as a hash function, a MAC, a cipher or a signature scheme. Protocols such as TLS combine several primitives.
- data integrity
- The guarantee that data has not been changed without authorisation since it was created, sent or stored. A MAC, an authenticated cipher's tag or a signature detects deliberate changes; a checksum catches only accidents.
- Kerckhoffs's principle
- The rule that a cryptosystem must stay secure even when everything about it except the key is public, so that its security rests on the key alone.
- non-repudiation
- Evidence that lets a third party decide whether a given party signed a message. It needs a digital signature from a key that only that party holds, and in the end it is a legal question.
- replay
- Sending a captured, valid message again. A MAC or a signature still verifies on the copy, so a design needs its own freshness check, such as a timestamp, a nonce or a record of what was accepted.
- source authentication
- Assurance about who created or sent a piece of data, also called authenticity. A MAC shows that a holder of the shared key made it; a signature shows which private key made it.
References
- OWASP Top 10:2025, A04 Cryptographic Failures (OWASP Foundation)
- OWASP Top 10:2025, A01 Broken Access Control (OWASP Foundation)
- OWASP Application Security Verification Standard 5.0, V11 Cryptography (OWASP Foundation)
- NIST SP 800-57 Part 1 Rev. 5: Recommendation for Key Management, Part 1 (section 3, security services) (National Institute of Standards and Technology (NIST))
- Handbook of Applied Cryptography, chapter 1 (section 1.4, Kerckhoffs’ desiderata) (CRC Press (authors' copy, Menezes, van Oorschot and Vanstone))
- RFC 2104: HMAC, Keyed-Hashing for Message Authentication (Internet Engineering Task Force (IETF))
- RFC 4231: Identifiers and Test Vectors for HMAC-SHA-224, HMAC-SHA-256, HMAC-SHA-384 and HMAC-SHA-512 (Internet Engineering Task Force (IETF))
- Web Cryptography API Level 2: the SubtleCrypto interface (World Wide Web Consortium (W3C))
- Node.js 24 documentation: Web Crypto API (OpenJS Foundation (Node.js))
- Python 3.14 documentation: Cryptographic Services (hashlib, hmac, secrets) (Python Software Foundation)
- Cryptographic Module Validation Program (National Institute of Standards and Technology (NIST))
- crypto-js README: discontinued (crypto-js project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress