Every time you log in, make a payment, or send a message, cryptography is silently protecting you. But most developers treat it as a black box — "just use HTTPS and bcrypt." This guide gives you a practical understanding of how encryption, hashing, and digital signatures actually work, with Python code for every concept and clear guidance on when to use what.
The Three Pillars of Cryptography
- Encryption: Scramble data so only authorized parties can read it. Reversible — you can decrypt to get the original data back.
- Hashing: Generate a fixed-size fingerprint of data. Not reversible — you can't get the original data from the hash. Used for integrity checks and passwords.
- Digital Signatures: Prove that a message was sent by a specific person and hasn't been tampered with. Combines hashing + asymmetric encryption.
Part 1: Encryption
Encryption transforms readable data (plaintext) into scrambled data (ciphertext) using a key. Only someone with the correct key can reverse the process (decrypt). There are two types:
AES-256: The Gold Standard for Symmetric Encryption
AES (Advanced Encryption Standard) with a 256-bit key is used by governments, banks, and every HTTPS connection. It's fast, secure, and battle-tested.
# pip install cryptography
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os
# ── AES-256-GCM (Authenticated Encryption) ────
# GCM mode provides BOTH encryption AND integrity verification
# If anyone tampers with the ciphertext, decryption fails
# Generate a random 256-bit key (store this securely!)
key = AESGCM.generate_key(bit_length=256)
print(f"Key (hex): {key.hex()}")
# Output: a 64-character hex string (32 bytes = 256 bits)
# Encrypt
plaintext = b"Patient record: John Doe, DOB 1990-01-15, Diagnosis: ..."
nonce = os.urandom(12) # 96-bit nonce (MUST be unique per encryption!)
aes = AESGCM(key)
ciphertext = aes.encrypt(nonce, plaintext, None)
print(f"Encrypted: {ciphertext[:20].hex()}...")
# Output: gibberish bytes — completely unreadable
# Decrypt
decrypted = aes.decrypt(nonce, ciphertext, None)
print(f"Decrypted: {decrypted.decode()}")
# Output: "Patient record: John Doe, DOB 1990-01-15, Diagnosis: ..."
# ── With Associated Data (AAD) ─────────────────
# AAD is authenticated but NOT encrypted — useful for metadata
# (e.g., patient ID is visible but tamper-proof)
aad = b"patient-id:12345"
ciphertext = aes.encrypt(nonce, plaintext, aad)
decrypted = aes.decrypt(nonce, ciphertext, aad) # Must provide same AAD
# If AAD is modified, decryption raises InvalidTag
# ⚠️ CRITICAL: Never reuse a nonce with the same key!
# AES-GCM with a repeated nonce is catastrophically broken.
# Always use os.urandom(12) for each encryption.
RSA: Asymmetric Encryption & Key Exchange
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
# Generate RSA key pair (2048-bit minimum, 4096 for high security)
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
public_key = private_key.public_key()
# ── Encrypt with public key, decrypt with private key ──
message = b"Top secret: the launch code is 12345"
# Anyone with the public key can encrypt
ciphertext = public_key.encrypt(
message,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
print(f"Encrypted ({len(ciphertext)} bytes): {ciphertext[:20].hex()}...")
# Only the private key holder can decrypt
decrypted = private_key.decrypt(
ciphertext,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
print(f"Decrypted: {decrypted.decode()}")
# ⚠️ RSA can only encrypt small data (< key size - padding)
# For large data: encrypt with AES, encrypt AES key with RSA
# This is called "hybrid encryption" — exactly how TLS works!
How TLS Uses Both (Hybrid Encryption)
Part 2: Hashing Algorithms
A hash function takes any input and produces a fixed-size output (the hash/digest). It's a one-way function — you can't reverse it to get the original data. Two key properties: the same input always produces the same hash, and even a tiny change in input produces a completely different hash.
import hashlib
# ── SHA-256 (Secure Hash Algorithm) ────────────
message = b"Hello, World!"
hash_value = hashlib.sha256(message).hexdigest()
print(f"SHA-256: {hash_value}")
# Output: dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f
# Change ONE character:
hash_value2 = hashlib.sha256(b"Hello, World?").hexdigest()
print(f"SHA-256: {hash_value2}")
# Output: 8945e5fdde8ac1e3a2db735cbc206e5ba97a835deab69e7e50bd2fc84e3a82f2
# Completely different! This is the "avalanche effect"
# ── Common hash algorithms ─────────────────────
algorithms = {
"MD5": hashlib.md5(message).hexdigest(),
"SHA-1": hashlib.sha1(message).hexdigest(),
"SHA-256": hashlib.sha256(message).hexdigest(),
"SHA-512": hashlib.sha512(message).hexdigest(),
"SHA-3": hashlib.sha3_256(message).hexdigest(),
"BLAKE2": hashlib.blake2b(message).hexdigest(),
}
for name, h in algorithms.items():
print(f"{name:10}: {h[:32]}... ({len(h)//2} bytes)")
# Output:
# MD5 : ed076287532e86365e841e92bfc50d8c... (16 bytes) ← BROKEN, don't use!
# SHA-1 : 0a0a9f2a6772942557ab5355d76af442... (20 bytes) ← BROKEN, don't use!
# SHA-256 : dffd6021bb2bd5b0af676290809ec3a5... (32 bytes) ← Standard choice
# SHA-512 : 374d794a95cdcfd8b35993185fef9ba3... (64 bytes) ← Extra security
# SHA-3 : 1af17a664e3fa8e419b8ba05c2a173... (32 bytes) ← Latest standard
# BLAKE2 : 021ced8799296ceca557832ab941a50b... (64 bytes) ← Fastest secure hash
| Algorithm | Output Size | Security | Use For |
|---|---|---|---|
| MD5 | 128-bit | BROKEN | Never for security. Only checksums for non-critical files. |
| SHA-1 | 160-bit | BROKEN | Legacy only. Git uses it but is migrating to SHA-256. |
| SHA-256 | 256-bit | Strong | Default choice. TLS certs, JWT signatures, integrity checks. |
| SHA-3 | 256-bit | Strong | Different design than SHA-2. Good if SHA-2 ever breaks. |
| BLAKE2 | 256/512-bit | Strong | Fastest secure hash. File integrity, MACs. |
| bcrypt/Argon2 | Variable | Designed for passwords | Password storage ONLY. Intentionally slow. |
Password Hashing: bcrypt, scrypt, Argon2
Never store passwords in plain text. Never use SHA-256 for passwords. Regular hash functions are too fast — an attacker can try billions of guesses per second. Password hashing algorithms are intentionally slow to make brute-force attacks impractical.
# ── bcrypt (most widely used) ──────────────────
# pip install bcrypt
import bcrypt
password = b"my_secure_password_123"
# Hash (with automatic salt generation)
hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))
print(f"bcrypt: {hashed.decode()}")
# Output: \$2b\$12\$LJ3m4ys3Lg.Hy5OwF5IkNe7Yjv6RmXKN6bLHFhMGCNmYq3xKp.VK
# Format: \$2b\$ROUNDS\$SALT+HASH
# Verify password
is_valid = bcrypt.checkpw(password, hashed)
print(f"Valid: {is_valid}") # True
is_valid = bcrypt.checkpw(b"wrong_password", hashed)
print(f"Valid: {is_valid}") # False
# ── Argon2 (winner of Password Hashing Competition) ──
# pip install argon2-cffi
from argon2 import PasswordHasher
ph = PasswordHasher(
time_cost=3, # Number of iterations
memory_cost=65536, # 64 MB of RAM per hash (prevents GPU attacks)
parallelism=4, # Use 4 threads
)
hashed = ph.hash("my_secure_password_123")
print(f"Argon2: {hashed}")
# Output: \$argon2id\$v=19\$m=65536,t=3,p=4\$SALT\$HASH
# Verify
try:
ph.verify(hashed, "my_secure_password_123")
print("Valid!")
except Exception:
print("Invalid!")
# ── WHY is SHA-256 bad for passwords? ──────────
# SHA-256: 10 BILLION hashes/second on a GPU
# bcrypt(12): ~13 hashes/second on the same GPU
# Argon2: ~3 hashes/second (also needs 64MB RAM per attempt!)
# Brute-forcing a 10-char password:
# SHA-256: hours
# bcrypt: centuries
# Argon2: longer than the universe
HMAC: Hash-Based Message Authentication
HMAC combines a hash function with a secret key to produce an authentication code. It proves both integrity (data wasn't tampered) and authenticity (sender knows the secret key).
import hmac
import hashlib
secret_key = b"my-webhook-secret-key"
message = b'{"event":"payment.completed","amount":100}'
# Create HMAC
signature = hmac.new(secret_key, message, hashlib.sha256).hexdigest()
print(f"HMAC-SHA256: {signature}")
# Verify HMAC (webhook receiver)
received_signature = signature # From X-Signature header
expected = hmac.new(secret_key, message, hashlib.sha256).hexdigest()
is_valid = hmac.compare_digest(received_signature, expected)
print(f"Valid: {is_valid}") # True
# hmac.compare_digest prevents timing attacks!
# Real-world uses of HMAC:
# 1. Webhook signatures (Stripe, GitHub, Slack)
# 2. JWT signatures (HS256 = HMAC-SHA256)
# 3. API request signing (AWS Signature V4)
# 4. Cookie integrity (prevent tampering)
Part 3: Digital Signatures
A digital signature proves: (1) the message was sent by a specific entity, and (2) the message hasn't been modified. It uses asymmetric keys: sign with private key, verify with public key.
from cryptography.hazmat.primitives.asymmetric import ec, utils
from cryptography.hazmat.primitives import hashes
# ── ECDSA (Elliptic Curve Digital Signature Algorithm) ──
# Faster and smaller than RSA signatures
private_key = ec.generate_private_key(ec.SECP256R1()) # P-256 curve
public_key = private_key.public_key()
message = b"Transfer \$1000 from account A to account B"
# Sign with private key (only the sender can do this)
signature = private_key.sign(
message,
ec.ECDSA(hashes.SHA256()),
)
print(f"Signature ({len(signature)} bytes): {signature[:20].hex()}...")
# Verify with public key (anyone can verify)
try:
public_key.verify(signature, message, ec.ECDSA(hashes.SHA256()))
print("Signature VALID - message is authentic and unmodified")
except Exception:
print("Signature INVALID - message was tampered with!")
# Tamper with the message and try again:
tampered = b"Transfer \$9999 from account A to account B"
try:
public_key.verify(signature, tampered, ec.ECDSA(hashes.SHA256()))
print("Valid")
except Exception:
print("INVALID - tampering detected!")
# Real-world uses of digital signatures:
# 1. TLS certificates (CA signs server certificates)
# 2. JWT (RS256 = RSA signature, ES256 = ECDSA signature)
# 3. Code signing (Apple, Microsoft sign their software)
# 4. Git commits (gpg signed commits)
# 5. Blockchain transactions (prove ownership without revealing private key)
The Complete Decision Guide
| I Need To... | Use This | Algorithm |
|---|---|---|
| Encrypt data at rest | Symmetric encryption | AES-256-GCM |
| Encrypt data in transit | TLS (hybrid) | ECDHE + AES-256-GCM |
| Store passwords | Password hash | Argon2id or bcrypt |
| Verify file integrity | Hash function | SHA-256 or BLAKE2 |
| Sign a JWT | Digital signature | RS256 (RSA) or ES256 (ECDSA) |
| Verify webhook payload | HMAC | HMAC-SHA256 |
| Exchange keys securely | Key exchange | ECDH (Diffie-Hellman) |
| Sign TLS certificates | Digital signature | RSA-2048 or ECDSA P-256 |
| Generate API tokens | CSPRNG | os.urandom() / secrets module |
Common Mistakes
Cryptography is the foundation of all software security. You don't need to implement algorithms from scratch — but you do need to choose the right tool for each job and use it correctly. Remember the three rules: AES for encrypting data, bcrypt/Argon2 for passwords, SHA-256 for integrity. Use established libraries, never reuse nonces, and keep your keys out of your code. That covers 95% of real-world cryptography needs.