Cryptography Basics — Encryption, Hashing, and Signing
A developer's guide to cryptography: symmetric and asymmetric encryption, hashing, digital signatures, and key management with practical code examples.
Overview
Cryptography sits underneath almost every security control a developer touches: password storage, TLS sessions, signed JWTs, encrypted backups, webhook verification. Using it correctly is mostly about picking the right primitive for the job — encrypt what you need to read back, hash what you only need to compare, sign what others must verify — and then not improvising around the details like IVs, key storage, and algorithm choice.
This guide covers the four primitives every application developer actually uses — symmetric encryption, asymmetric encryption, hashing, and digital signatures — plus the two operational concerns that cause most real-world failures: key management and TLS configuration. Every example uses pyca/cryptography or Node’s built-in crypto module, the two libraries least likely to let you do something dangerous by accident. No mathematics required — when a decision depends on internals, we say so and give you the safe default.
When to Use
- You need to protect data at rest (database fields, files, backups) or in transit (API calls, service mesh)
- You are implementing authentication, session tokens, or signed webhooks
- You need to verify the integrity or origin of data — checksums, signatures, HMAC tags
- You are choosing between cryptographic libraries, algorithms, or key sizes
For a deeper dive into protecting stored data specifically — AES-256, KMS integration, envelope encryption — see Encryption at Rest: AES-256, KMS, Envelope Encryption.
Symmetric Encryption
The same key encrypts and decrypts. Symmetric ciphers are fast — AES-256 processes gigabytes per second on modern CPUs — so they carry almost all bulk encryption work. The trade-off is distribution: everyone who can decrypt must hold the key, which makes key management the hard part.
AES (Advanced Encryption Standard)
AES-256 in GCM mode is the right default for new systems. GCM is authenticated encryption: it produces a tag that detects tampering, so you get confidentiality and integrity in one step. CBC and CTR modes only provide confidentiality — bolt-on HMACs are required, and getting the order wrong (“encrypt-then-MAC” vs “MAC-then-encrypt”) has broken real systems.
from cryptography.fernet import Fernet
# Generate a key (Fernet = AES-128-CBC + HMAC, high-level safe API)
key = Fernet.generate_key()
cipher = Fernet(key)
# Encrypt
token = cipher.encrypt(b"sensitive data")
# Decrypt — raises InvalidToken if the ciphertext was tampered with
data = cipher.decrypt(token)
For lower-level control, use AESGCM directly. The IV (nonce) must be unique per key — 96-bit random nonces are fine until roughly 2³² messages per key; deterministic nonce reuse with the same key destroys GCM’s security completely.
| Mode | Use Case | Security |
|---|---|---|
| AES-GCM | Almost everything new | Authenticated encryption — picks itself |
| AES-CBC | Legacy compatibility only | Needs separate HMAC for integrity |
| AES-CTR | Streaming, rare | Needs careful IV handling |
| AES-ECB | Never | Leaks plaintext patterns — do not use |
Key Management
- Never hardcode keys or commit them to a repo; use a KMS (AWS KMS, GCP KMS, Azure Key Vault) or a secrets manager
- Rotate keys periodically and always on suspected compromise — design for rotation from day one
- Separate keys by environment and purpose: one key for dev DB fields, another for prod backups
- Store keys in a different trust boundary than the data they protect
Asymmetric Encryption
Two mathematically linked keys: anything the public key encrypts, only the private key decrypts; anything the private key signs, anyone with the public key can verify. Asymmetric crypto solves the key-distribution problem — you can publish the public half — but it’s thousands of times slower than AES and can only encrypt small payloads. That’s why real systems are hybrid: asymmetric crypto exchanges or protects a symmetric key, and the symmetric key does the bulk work.
RSA
RSA-2048 is the baseline; RSA-3072 for longer security margins. For encryption, always use OAEP padding — textbook RSA and PKCS#1 v1.5 padding have both produced exploitable oracles.
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
# Generate key pair
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()
# Encrypt with public key (small payloads only — typically a symmetric key)
encrypted = public_key.encrypt(
b"secret message",
padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None)
)
# Decrypt with private key
decrypted = private_key.decrypt(
encrypted,
padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None)
)
Elliptic Curve (ECDH/ECDSA)
Equivalent security with far smaller keys: a 256-bit EC key matches roughly a 3072-bit RSA key, and operations are faster. Prefer SECP256R1 (P-256) for compatibility, X25519/Ed25519 where you control both ends.
from cryptography.hazmat.primitives.asymmetric import ec
private_key = ec.generate_private_key(ec.SECP256R1())
public_key = private_key.public_key()
Hashing
A hash maps arbitrary input to a fixed-size fingerprint in one direction — there is no “decrypt”. Used for integrity checks, content addressing, and (with the right algorithm) password storage.
Secure Hashing Algorithms
| Algorithm | Output Size | Status |
|---|---|---|
| SHA-256 | 256 bits | Recommended general-purpose |
| SHA-3 | Variable | Recommended, diverse design |
| BLAKE2b / BLAKE3 | 256+ bits | Fast, modern |
| Argon2id | Variable | Password hashing — recommended |
| bcrypt | 184 bits | Password hashing — solid legacy |
| scrypt | Variable | Password hashing — memory-hard |
| MD5 | 128 bits | Broken — checksums only, never security |
| SHA-1 | 160 bits | Broken — do not use |
import hashlib
# SHA-256 for integrity checks and fingerprints
digest = hashlib.sha256(b"data").hexdigest()
# For passwords: use Argon2id, bcrypt, or scrypt — never plain SHA-256.
# Fast hashes are designed for speed; attackers can try billions of guesses per second.
Password Hashing
Passwords need deliberately slow, memory-hard functions. Argon2id is the current recommendation (OWASP, RFC 9106); bcrypt with cost factor ≥ 12 remains a solid, widely-supported choice.
import bcrypt
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))
# Verify — use the library's compare, not ==, to avoid timing leaks
ok = bcrypt.checkpw(password.encode(), hashed)
HMAC — Keyed Hashing
To verify both integrity and authenticity of a message you share a secret over — webhook payloads, signed URLs — use HMAC, not a plain hash. HMAC-SHA256 is the standard; a bare SHA-256(secret + message) is vulnerable to length-extension attacks.
import hmac
tag = hmac.new(secret_key, message, hashlib.sha256).digest()
# Verify with hmac.compare_digest(tag, received_tag) — constant-time compare
Digital Signatures
A signature proves a message came from a private key’s holder and wasn’t modified. JWTs, software releases, certificate chains, and signed webhooks all rely on this.
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.hazmat.primitives import hashes, serialization
# Sign with the private key
signature = private_key.sign(
message,
padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
hashes.SHA256()
)
# Verify with the public key — raises InvalidSignature on failure
public_key.verify(
signature,
message,
padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
hashes.SHA256()
)
For new systems, Ed25519 is simpler and safer than RSA-PSS: fixed parameters, faster, small signatures. RSA persists mainly for compatibility with JWT (RS256) and legacy PKI.
Transport Layer Security (TLS)
TLS is hybrid cryptography in action: asymmetric crypto (certificates + ECDHE) authenticates the server and negotiates session keys, then symmetric AES-GCM or ChaCha20 carries the actual traffic.
- Use TLS 1.3; disable 1.0 and 1.1 entirely and prefer 1.2 only for compatibility
- Enable HSTS so browsers refuse downgrade attempts
- Consider certificate pinning for mobile apps (weigh the operational cost carefully)
- Monitor certificate expiry at 30, 14, and 7 days — expired certs are the most common TLS outage
- Terminate TLS as close to the edge as practical, and encrypt internal hops too if your threat model includes lateral movement
Certificates and PKI Basics
A certificate binds a public key to an identity — a domain, an organization — through a chain of signatures ending at a Certificate Authority your client already trusts. When TLS verification fails, the problem is almost always in this chain, not in the crypto itself.
The pieces worth knowing:
- Leaf vs chain: the server presents its leaf certificate plus intermediates; the client verifies each signature up to a root CA in its trust store. A missing intermediate is the classic “works in Chrome, fails in curl” bug — browsers cache intermediates, other clients don’t.
- SAN (Subject Alternative Name): the certificate must list every hostname it protects.
example.comandapi.example.comneed separate entries or a wildcard. - Self-signed vs CA-issued: self-signed is fine for internal services where you control the trust store; anything public needs a real CA. Let’s Encrypt issues free, automated 90-day certificates — shorter lifetimes are a feature, not a bug, since rotation is automated.
- Revocation: OCSP stapling is the practical mechanism; CRLs still matter for internal PKI. If a key is compromised, rotate the certificate immediately — don’t wait for expiry.
Internal PKI deserves the same discipline: short-lived leaf certs, an automated issuer (Vault PKI, smallstep), and a clear story for what happens when an intermediate rotates.
Choosing the Right Primitive
Most “which algorithm” questions are really “which primitive” questions. Start from what you need to guarantee:
| Need | Primitive | Typical choice |
|---|---|---|
| Read data back later | Symmetric encryption | AES-256-GCM |
| Exchange keys / encrypt for a recipient | Asymmetric encryption | ECDH, RSA-OAEP |
| Compare without storing the original | Hashing | SHA-256; Argon2id for passwords |
| Prove a message wasn’t altered | MAC / signature | HMAC-SHA256 (shared secret), Ed25519 (public verify) |
| Combine integrity + secrecy | Authenticated encryption | AES-GCM, ChaCha20-Poly1305 |
The hybrid pattern underneath — asymmetric for key exchange, symmetric for data — is worth memorizing because it explains TLS, PGP, envelope encryption, and most key-management designs.
Common Mistakes
- Rolling your own crypto — home-grown constructions fail in ways unit tests never show. Use vetted libraries:
pyca/cryptography, libsodium, OpenSSL, Bouncy Castle, Nodecrypto. - Using ECB mode — identical plaintext blocks produce identical ciphertext; the penguin stays visible. GCM everywhere.
- Reusing IVs/nonces — catastrophic in CTR and GCM: same key + same nonce leaks plaintext relationships and lets attackers forge tags.
- Hashing passwords with SHA-256 — fast hashes are a gift to brute force. Argon2id or bcrypt only.
- Storing keys with the data — a database dump shouldn’t hand the attacker the keys too. Separate trust boundaries.
- Rolling your own
compare_digest—==on secrets leaks via timing. Use the library’s constant-time comparison. - Ignoring side channels — timing and error messages can leak more than the math protects; padding oracles (POODLE, Lucky13) exploited exactly this.
Troubleshooting
InvalidToken/InvalidTagon decrypt: the ciphertext, key, IV, or auth tag doesn’t match — data corrupted in storage, wrong key version after rotation, or tampering. Log which key version was used.Bad padding/ OAEP errors: decrypting with the wrong private key or wrong padding mode. Confirm the cert pair and the OAEP hash algorithm match on both ends.signature verification failedon JWTs: clock skew (exp/nbf), wrong algorithm (RS256vsHS256confusion is a classic attack), or stale JWKS cache. Refresh keys, checkiss/aud, pin the algorithm.- Handshake failures after enabling TLS 1.3: a middlebox or old client in the path. Enable 1.2+1.3 during migration and check
ClientHellowith a packet capture. cert expiredat 3am: no alerting on expiry. Put renewal 30 days out in the runbook and monitor the leaf and the chain.- Performance cliff under load: RSA signing in the hot path. Move to Ed25519/ECDSA, or sign once and cache.
Advanced Example: Envelope Encryption in a Fintech App
A common production design: a fintech app handling transactions and PII needs encryption at three layers — transit, storage, and field level for the most sensitive columns.
Layer Technology Purpose
------ ----------- -------
Transit TLS 1.3 Network communication
At rest (DB) AES-256-GCM Disk/volume encryption
Field-level AES-256-GCM + envelope Sensitive columns (PII)
Backups AES-256 + KMS Backup archives
Secrets KMS + rotation Key custody
Envelope encryption keeps the data key near the data: generate a random Data Encryption Key (DEK), encrypt the field with it, then encrypt the DEK itself with a KMS-held Key Encryption Key (KEK). The store keeps ciphertext + encrypted DEK; decryption asks KMS to unwrap the DEK first. Compromise of the database alone exposes nothing — the KEK never leaves the KMS.
// Envelope encryption with AWS KMS (Node.js)
const { KMSClient, EncryptCommand, DecryptCommand } = require("@aws-sdk/client-kms");
const crypto = require("crypto");
async function encryptField(plaintext, kmsKeyId) {
// 1. Generate a random 256-bit DEK and a unique 96-bit IV
const dek = crypto.randomBytes(32);
const iv = crypto.randomBytes(12);
// 2. Encrypt the field with the DEK (AES-256-GCM)
const cipher = crypto.createCipheriv("aes-256-gcm", dek, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
// 3. Encrypt the DEK under the KMS KEK
const kms = new KMSClient();
const response = await kms.send(new EncryptCommand({
KeyId: kmsKeyId,
Plaintext: dek,
}));
// 4. Store: encrypted DEK + IV + authTag + ciphertext
return {
encryptedDek: response.CiphertextBlob.toString("base64"),
iv: iv.toString("base64"),
authTag: authTag.toString("base64"),
ciphertext: ciphertext.toString("base64"),
};
}
async function decryptField(encrypted) {
// 1. Unwrap the DEK via KMS
const kms = new KMSClient();
const response = await kms.send(new DecryptCommand({
CiphertextBlob: Buffer.from(encrypted.encryptedDek, "base64"),
}));
const dek = response.Plaintext;
// 2. Decrypt the field
const decipher = crypto.createDecipheriv(
"aes-256-gcm", dek, Buffer.from(encrypted.iv, "base64")
);
decipher.setAuthTag(Buffer.from(encrypted.authTag, "base64"));
const plaintext = Buffer.concat([
decipher.update(Buffer.from(encrypted.ciphertext, "base64")),
decipher.final()
]);
return plaintext.toString("utf8");
}
Design takeaways:
- One DEK per record (or per field) limits blast radius; one KEK wraps them all
- The DEK only ever exists in plaintext inside process memory
- KMS rotates KEKs automatically and gives you an audit log of every unwrap
- The IV must be unique per DEK; storing it alongside ciphertext is fine — it’s not secret
Further Reading
- OWASP Cryptographic Storage Cheat Sheet — algorithm selection and storage rules maintained by OWASP
- NIST SP 800-38D — GCM Recommendation — the standard behind authenticated encryption modes
- libsodium documentation — the opinionated high-level API many teams adopt instead of composing primitives
- Companion code examples — runnable encryption, hashing, and KMS samples in the companion repository
Frequently Asked Questions
What's the difference between encryption and hashing?
Encryption is reversible — anyone with the key can read the original back. Hashing is one-way — you can only verify a guess by hashing it and comparing. Encrypt what you need later; hash what you only compare, like passwords (with Argon2id or bcrypt, not SHA-256).
Should I use AES-256 or AES-128?
Either is fine today; both resist brute force far beyond practical attack budgets. AES-256 adds margin against future advances and costs a bit more CPU. New systems usually pick AES-256-GCM and move on.
What is authenticated encryption?
Encryption that also detects tampering. AES-GCM produces an auth tag alongside the ciphertext; decryption fails loudly if anything was modified. Plain AES-CBC only hides the data — an attacker can still flip bits undetected unless you add an HMAC.
When do I use symmetric vs asymmetric encryption?
Symmetric (AES) for bulk data at rest — it's fast and simple but requires sharing the key. Asymmetric (RSA, ECDH) for key exchange and signatures — it solves distribution but is slow and payload-limited. Real systems combine both: asymmetric crypto protects or exchanges a symmetric key, which then does the heavy work. That hybrid pattern is exactly what TLS and envelope encryption do.
Why can't I just hash passwords with SHA-256?
SHA-256 is built for speed — GPUs try billions of candidates per second. Password hashing needs the opposite: deliberately slow, memory-hard functions like Argon2id or bcrypt that make each guess expensive. Same primitive family, opposite design goals.
Is it safe to write my own encryption format?
Almost never. The failures live in the details: IV reuse, padding oracles, missing authentication, key handling. High-level APIs like Fernet (Python), libsodium's secretbox, or AWS's Encryption SDK make the safe choice the default — use those before composing primitives yourself.
Related Resources
Secrets Management: Vault, Cloud Managers
A practical guide to secrets management: HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, and GCP Secret Manager with rotation, access control, and CI/CD integration.
GuideEncryption at Rest: AES-256, KMS, Envelope Encryption
Encrypt stored data correctly: AES-256-GCM, KMS-managed keys, envelope encryption, key rotation, and field-level encryption for databases and backups.
RecipePassword Hashing in Production
Securely hash and verify passwords using bcrypt, scrypt, and Argon2 with what works.
RecipeEncrypt and Decrypt Data with AES-GCM in Python
Encrypt sensitive data using AES-GCM with the cryptography library. Covers key derivation, nonce generation, authenticated encryption, and file encryption.
GuideOWASP Top 10: Explained with Mitigations
A developer-focused guide to the OWASP Top 10 security risks: how each vulnerability works, real-world examples, and practical mitigations for web applications.
RecipeManage Application Secrets with HashiCorp Vault and Python
Store, retrieve, and rotate application secrets securely using HashiCorp Vault with Python hvac client, dynamic secrets, and automatic lease renewal.