Education › Security › Stage 1: Foundations

Cryptography in practice

Hashing, symmetric and asymmetric encryption, signatures, TLS and certificates — what to use and what never to hand-roll.

Beginner ~35 min read Module 2 of 16

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.

After this module you can
  • 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.

PrimitiveQuestion it answersUse 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.

Watch out

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.

Hashing and verifying a password with Argon2id. The library chooses the salt and encodes the parameters into the hash string.
python
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 True

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

Tip

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.

CLIENT TRUST STORE1. client key share2. key share, cert, sigpresents3. chain verifiedsigns4. derive5. encrypted + MACedClientbrowser or curlServerholds private keyCertificateapi.example.comSession keysderived on both sidesRoot CApre-installedIntermediate CAsigns the leaf
How a TLS 1.3 connection gets its three guarantees: ephemeral key shares give confidentiality with forward secrecy, the certificate chain to a trusted root gives server authentication, and the record MACs give integrity.
Inspecting a live certificate chain and its expiry from the command line.
bash
# 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 -sha256

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

Generating keys and secrets correctly with standard tools.
bash
# 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 copyall
Note

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

Verifying a webhook signature the way providers document it: HMAC over the raw body, constant-time comparison, timestamp window.
python
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.

Hands-on practice

Hash, sign, verify and inspect

  1. Install the Argon2 library (pip install argon2-cffi) and hash the same password twice. Confirm the two hashes differ (different salts) and that both verify.
  2. Time ph.verify with the default parameters, then create a PasswordHasher(time_cost=8) and time it again. Note the trade-off you are choosing between login latency and attacker cost.
  3. Write the webhook verifier from the lesson and a tiny sender that signs a body. Send a valid message, a tampered body, and a message with a timestamp 10 minutes old. All three outcomes should be what you expect.
  4. Run the openssl s_client command against three public sites and note the issuer and the expiry of each leaf certificate.
  5. Create the private CA and the server certificate from the lesson, start a local HTTPS server with them, and curl it once with --cacert ca.crt (works) and once without (fails). Read the failure message carefully.
  6. Generate a 32-byte secret with openssl rand -hex 32 and store it in your shell's secrets manager or a .env file that is git-ignored; confirm git status does not list it.
Cheat sheet

Cryptography in practice — at a glance

Main things to focus on

  • Hash = integrity fingerprint; MAC = origin with shared key; signature = origin verifiable by anyone
  • Encryption without authentication is broken; use AEAD (AES-GCM, ChaCha20-Poly1305) only
  • Passwords: Argon2id/scrypt/bcrypt with a per-password salt, never a fast hash, never encryption
  • TLS: verify the certificate chain and name; disabling verification removes authentication
  • Randomness and keys come from the OS and a KMS, never from code or timestamps
  • Verify signatures and MACs in constant time, pin the algorithm, check expiry and audience
  • Never reuse a nonce with the same key; never use ECB, MD5 or SHA-1 for security

Which primitive

SHA-256Fingerprint / checksum; proves nothing about who made it
HMAC-SHA256Authenticity with a shared secret: webhooks, cookies, signed URLs
AES-256-GCM / ChaCha20-Poly1305Authenticated symmetric encryption; unique nonce per message
X25519 / RSA-OAEPKey exchange or encrypting to a public key
Ed25519 / ECDSA P-256 / RSA-PSSSignatures: certificates, JWTs, commits, artifacts
Argon2id / scrypt / bcryptPassword hashing: slow, memory-hard, salted

OpenSSL and friends

openssl rand -hex 32256-bit secret from the OS random source
openssl s_client -connect HOST:443 -servername HOSTShow the presented certificate chain
openssl x509 -in FILE -noout -subject -issuer -datesRead a certificate's identity and validity
openssl x509 -noout -fingerprint -sha256Fingerprint for pinning or comparison
ssh-keygen -t ed25519Modern SSH / signing key pair
sha256sum FILEChecksum a download; compare with the published value

Passwords and comparisons

PasswordHasher().hash(pw)Argon2id with defaults; store the full encoded string
ph.verify(stored, candidate)Raises on mismatch; catch VerifyMismatchError
ph.check_needs_rehash(stored)Upgrade parameters at next successful login
hmac.compare_digest(a, b)Constant-time equality for secrets and MACs
secrets.token_urlsafe(32)Random token for sessions, reset links, API keys

TLS facts

TLS 1.3: 1-RTT, forward secrecyEphemeral keys; recordings cannot be decrypted later
chain: leaf <- intermediate <- rootRoot must be in the client trust store
SAN must match the hostnameName mismatch is a failure, not a warning to click through
ACME (Let's Encrypt)Automated issuance and renewal; expiry outages are self-inflicted
mTLSBoth sides present certificates; service identity inside the perimeter

Tokens and JWTs

pin alg in code, ignore header algPrevents none / HS256-with-public-key confusion attacks
check exp, iss, audExpired, wrong issuer or wrong audience must fail
payload is base64, not encryptedNever put secrets in a JWT payload
timestamp + nonceReplay protection for webhooks and signed requests

Common pitfalls

  • Using verify=False or -k to silence a certificate error in production code; you have removed authentication, not fixed anything.
  • Storing passwords with SHA-256 or, worse, encrypting them so they can be recovered.
  • Reusing a nonce or IV with the same key in GCM; one reuse leaks the authentication key.
  • Comparing a MAC with ==, which leaks timing information byte by byte.
  • Trusting the alg field inside a JWT instead of pinning the expected algorithm.
  • Hard-coding keys in source or baking them into container images; rotate them and load them from a secrets manager.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →