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

Cryptography: Three Core Concepts
🔐EncryptionProtect confidentiality
🗃HashingVerify integrity
SignaturesProve authenticity
  • 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:

Symmetric vs Asymmetric Encryption
🔑 Symmetric (One Key)
🔑Same key encrypts and decrypts
Fast (1000x faster than asymmetric)
📦Good for: bulk data encryption
Problem: how to share the key safely?
🎯AES-256, ChaCha20
VS
🔐 Asymmetric (Key Pair)
🔑Public key encrypts, private key decrypts
🐢Slow (for small data only)
📦Good for: key exchange, signatures
No key sharing problem!
🎯RSA, ECDSA, Ed25519

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)

TLS: Asymmetric Key Exchange + Symmetric Data Encryption
Client(Browser)
Key Exchange(Asymmetric)
Server(HTTPS)
1 Server sends RSA/ECDH public key (in certificate)
Both sides derive shared AES key (Diffie-Hellman)
2 All data encrypted with AES-256-GCM (fast!)
3 Response also AES encrypted

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
Hash Algorithm Comparison
Algorithm Output Size Security Use For
MD5128-bitBROKENNever for security. Only checksums for non-critical files.
SHA-1160-bitBROKENLegacy only. Git uses it but is migrating to SHA-256.
SHA-256256-bitStrongDefault choice. TLS certs, JWT signatures, integrity checks.
SHA-3256-bitStrongDifferent design than SHA-2. Good if SHA-2 ever breaks.
BLAKE2256/512-bitStrongFastest secure hash. File integrity, MACs.
bcrypt/Argon2VariableDesigned for passwordsPassword 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

What to Use When
I Need To... Use This Algorithm
Encrypt data at restSymmetric encryptionAES-256-GCM
Encrypt data in transitTLS (hybrid)ECDHE + AES-256-GCM
Store passwordsPassword hashArgon2id or bcrypt
Verify file integrityHash functionSHA-256 or BLAKE2
Sign a JWTDigital signatureRS256 (RSA) or ES256 (ECDSA)
Verify webhook payloadHMACHMAC-SHA256
Exchange keys securelyKey exchangeECDH (Diffie-Hellman)
Sign TLS certificatesDigital signatureRSA-2048 or ECDSA P-256
Generate API tokensCSPRNGos.urandom() / secrets module

Common Mistakes

Cryptography Mistakes to Avoid
Using MD5 or SHA-1 for anything security-related
Both are broken. Collisions can be generated in seconds. Use SHA-256+.
Hashing passwords with SHA-256
Too fast! Use bcrypt or Argon2 — designed to be slow and memory-hard.
Rolling your own crypto
Use established libraries (cryptography, NaCl/libsodium). Custom implementations will have bugs.
Reusing nonces/IVs with AES-GCM
Catastrophic. Always use os.urandom() for each encryption operation.
Comparing signatures with == instead of hmac.compare_digest()
String comparison leaks timing info. Use constant-time comparison for all security checks.
Hardcoding encryption keys in source code
Use environment variables, KMS (AWS/GCP), or HashiCorp Vault. Never commit keys to git.

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.