You will never implement a cipher, and you should not try. What you will do, constantly, is choose between primitives that look similar and behave very differently: a hash versus a MAC, encryption versus signing, a password hash versus a fast hash, TLS that verifies the server versus TLS that merely encrypts. Getting those choices right is most of applied cryptography for an engineer. This module covers the handful of primitives you actually use, what each one guarantees, the standard algorithms to reach for in 2026, and the mistakes that turn strong cryptography into decoration.
- Choose correctly between hashing, MACs, symmetric encryption, asymmetric encryption and signatures for a given need
- Store and verify passwords with a modern, slow, salted hash
- Explain how TLS establishes an encrypted, authenticated channel and how certificates chain to a trusted root
- Generate keys, certificates and random values with standard tools instead of improvising
- Recognise the classic misuse patterns: ECB mode, reused nonces, unverified certificates, MD5 for passwords, secrets in code
Five primitives, five guarantees
Almost every security property you need is built from five building blocks. Each one answers a different question, and using the wrong one is the root of most cryptographic bugs.
| Primitive | Question it answers | Use in 2026 |
|---|---|---|
| Hash (SHA-256) | Is this the same data? (integrity, fingerprint) | Checksums, content addressing, commit ids |
| MAC (HMAC-SHA256) | Did someone with the shared key produce this? | Webhook signatures, session cookies, API request signing |
| Symmetric encryption (AES-256-GCM, ChaCha20-Poly1305) | Can only key holders read this? | Data at rest, tokens, backups; both sides share a key |
| Asymmetric encryption / key exchange (X25519, RSA-OAEP) | Can I send a secret to someone whose public key I have? | TLS key exchange, envelope encryption, age/GPG |
| Signature (Ed25519, ECDSA P-256, RSA-PSS) | Did the holder of this private key produce it, and can anyone verify? | Certificates, JWTs, signed commits, artifact signing |
Two consequences follow. A plain hash proves nothing about who produced data, because anyone can compute it; if you need authenticity, you need a MAC (shared secret) or a signature (public/private pair). And encryption alone does not prevent tampering: someone can flip bits in ciphertext and you would decrypt garbage without noticing. That is why modern symmetric modes are authenticated encryption (AEAD), which encrypt and MAC in one operation, and why you should refuse to use anything else.
Never use MD5 or SHA-1 for anything security-related; both have practical collision attacks. Never use AES in ECB mode; identical plaintext blocks produce identical ciphertext blocks and patterns leak straight through. Never reuse a nonce with the same key in GCM or ChaCha20-Poly1305; a single reuse breaks the authentication.
Passwords are not data to encrypt
Passwords need a different kind of hash. Fast hashes such as SHA-256 let an attacker who steals your database try billions of guesses per second on a GPU. A password hashing function is deliberately slow and memory-hungry, and includes a random salt per password so that identical passwords hash differently and precomputed tables are useless. The current recommendations are Argon2id first, then scrypt, then bcrypt; all three are fine, PBKDF2 is acceptable where nothing else is available.
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher() # argon2id, sensible defaults: 64 MiB memory, 3 iterations
stored = ph.hash("correct horse battery staple")
# '$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>' -> save this whole string
def login(candidate: str) -> bool:
try:
ph.verify(stored, candidate)
except VerifyMismatchError:
return False
# parameters can be raised over time; rehash on login if they are now too weak
if ph.check_needs_rehash(stored):
pass # store ph.hash(candidate) in place of the old value
return TrueTwo related rules. Compare secrets with a constant-time comparison (hmac.compare_digest in Python, crypto.timingSafeEqual in Node) so that response time does not leak how many leading bytes matched. And never log, email or store a password in the clear — not even temporarily, not even in a debug message.
Encrypting passwords instead of hashing them is a classic mistake: whoever holds the key can read every password, and keys get copied. Hashing is one-way by design; the system never needs the original.
TLS and certificates, without the mystique
TLS gives a connection three things: confidentiality (an eavesdropper sees noise), integrity (modifications are detected) and server authentication (you are talking to the server the certificate names). Modern TLS 1.3 does this in one round trip: the client sends a key share, the server replies with its own key share, its certificate and a signature proving it holds the certificate's private key, and both sides derive the session keys with the exchanged shares. The private key never crosses the wire; a captured recording cannot be decrypted later because the session keys are ephemeral (forward secrecy).
The certificate is a signed statement: "this public key belongs to api.example.com, says this certificate authority, until this date." The client trusts it because the CA's certificate is signed by an intermediate that is signed by a root in the client's trust store. Break any link (expired, wrong name, unknown issuer) and the connection must fail. Clients that disable verification to make an error go away (verify=False, -k, InsecureSkipVerify) have turned TLS into encryption with a stranger.
# Show the chain the server presents and who issued each certificate
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates
# Verify against the system trust store; exit code is non-zero on failure
curl -sSI https://example.com/ >/dev/null && echo "chain ok"
# Fingerprint a certificate file (for pinning or comparing)
openssl x509 -in server.crt -noout -fingerprint -sha256For services you run, get certificates from a public CA via ACME (Let's Encrypt or your cloud provider's certificate manager) and let automation renew them; expired certificates remain one of the most common self-inflicted outages. Inside a cluster or between your own services, use mutual TLS (mTLS), where the client also presents a certificate, typically issued by a private CA or a service mesh. That turns "encrypted" into "encrypted and both sides know who the other is."
Keys, randomness and where things live
Cryptography is only as strong as the handling of its keys. The practical rules: generate keys and random values from the operating system's cryptographic source (/dev/urandom, secrets in Python, crypto.randomBytes in Node), never from a general-purpose random function or a timestamp. Keep private keys and symmetric keys out of source code and images; load them from a secrets manager or a cloud key management service (KMS) at runtime. Rotate keys on a schedule and immediately on suspicion, which means every consumer must handle more than one valid key at a time.
Cloud KMS services also enable envelope encryption: a data encryption key encrypts the data, and the KMS master key (which never leaves the hardware) encrypts the data key. You store the encrypted data key next to the data and ask the KMS to unwrap it when needed. Applications never see the master key, access is logged per call, and revoking access is one policy change. It is the standard way to encrypt large data at rest.
# 32 random bytes as hex, for an API secret or HMAC key
openssl rand -hex 32
# Modern signing key pair (Ed25519) for SSH or artifact signing
ssh-keygen -t ed25519 -C "deploy@example.com" -f deploy_key
# Private CA key and self-signed root, then a server certificate signed by it
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout ca.key -out ca.crt -days 3650 -subj "/CN=internal-ca"
openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout svc.key -out svc.csr -subj "/CN=api.internal"
openssl x509 -req -in svc.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out svc.crt -days 365 -copy_extensions copyallKey sizes that are fine today: AES-256, SHA-256, RSA 3072 or larger, ECDSA P-256, Ed25519, X25519. Post-quantum key exchange (ML-KEM) is arriving in TLS libraries and browsers; you adopt it by upgrading, not by changing your code.
Putting it together: tokens, signatures and the API
A JWT is a signed (sometimes encrypted) JSON document. The signature proves it was issued by the holder of the signing key and has not been altered; it does not make the contents secret, since the payload is only base64-encoded. Verify the signature with the expected algorithm pinned in code (never trust the alg header from the token), check exp, iss and aud, and keep lifetimes short. The same rules apply to any signed cookie or webhook: verify before parsing, reject on failure, and treat replay (a valid old message sent again) with timestamps or nonces.
import hmac
import hashlib
import time
SECRET = b"...from the secrets manager..."
def verify(raw_body: bytes, signature_header: str, sent_at: int) -> bool:
if abs(time.time() - sent_at) > 300: # 5-minute replay window
return False
expected = hmac.new(SECRET, f"{sent_at}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header)The theme of this module is choosing, not inventing. Hash to fingerprint, MAC or sign to prove origin, AEAD to keep secrets, TLS to protect the wire, a slow salted hash for passwords, the OS for randomness, a KMS for keys. If you find yourself writing a loop that XORs bytes, or building your own token format, stop and use the library that a thousand other teams already audit.