StackPractices
intermediate By Mathias Paulenko

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.

Topics: security

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.

ModeUse CaseSecurity
AES-GCMAlmost everything newAuthenticated encryption — picks itself
AES-CBCLegacy compatibility onlyNeeds separate HMAC for integrity
AES-CTRStreaming, rareNeeds careful IV handling
AES-ECBNeverLeaks 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

AlgorithmOutput SizeStatus
SHA-256256 bitsRecommended general-purpose
SHA-3VariableRecommended, diverse design
BLAKE2b / BLAKE3256+ bitsFast, modern
Argon2idVariablePassword hashing — recommended
bcrypt184 bitsPassword hashing — solid legacy
scryptVariablePassword hashing — memory-hard
MD5128 bitsBroken — checksums only, never security
SHA-1160 bitsBroken — 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.

TLS 1.3 handshake — client and server exchange hello messages, the server presents its certificate, keys are derived, then symmetric encrypted traffic flows.
  • 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.com and api.example.com need 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:

NeedPrimitiveTypical choice
Read data back laterSymmetric encryptionAES-256-GCM
Exchange keys / encrypt for a recipientAsymmetric encryptionECDH, RSA-OAEP
Compare without storing the originalHashingSHA-256; Argon2id for passwords
Prove a message wasn’t alteredMAC / signatureHMAC-SHA256 (shared secret), Ed25519 (public verify)
Combine integrity + secrecyAuthenticated encryptionAES-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, Node crypto.
  • 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 / InvalidTag on 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 failed on JWTs: clock skew (exp/nbf), wrong algorithm (RS256 vs HS256 confusion is a classic attack), or stale JWKS cache. Refresh keys, check iss/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 ClientHello with a packet capture.
  • cert expired at 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

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.