Cryptography for Engineers

Mar 18, 2026
Computer Science

I used to think cryptography was someone else's problem. I'd use bcrypt for passwords, slap HTTPS on my domains, and call it secure. Then I had to debug a webhook signature verification that was silently failing, investigate why a JWT wasn't being accepted across services, and explain to a colleague why storing API keys in localStorage was a bad idea even though "they're encrypted." Each time, I realized I was using cryptographic primitives without understanding what they actually guaranteed—or didn't.

This post covers the cryptographic concepts that web engineers encounter daily. Not the math (no number theory, no group algebra), but the practical knowledge: what each primitive does, when to use it, and what can go wrong.


Hashing

A hash function takes input of any size and produces a fixed-size output (the hash, or digest). Cryptographic hash functions have three critical properties:

  1. Deterministic: Same input always produces the same hash.
  2. One-way: Given a hash, you can't recover the input (computationally infeasible).
  3. Collision-resistant: It's infeasible to find two different inputs with the same hash.
import { createHash } from "node:crypto"
 
const hash = createHash("sha256").update("hello world").digest("hex")
 
// 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9'

SHA-256 produces a 256-bit (32-byte) hash. Whether you hash "hello" or the entire works of Shakespeare, the output is always 64 hex characters.

Password Hashing

Regular hashes (SHA-256, MD5) are fast by design—they're built for verifying file integrity, not storing passwords. An attacker with a stolen database can compute billions of SHA-256 hashes per second, brute-forcing common passwords in minutes.

Password hashing uses intentionally slow algorithms that are expensive to compute:

  • bcrypt: Configurable work factor (cost). The standard for years.
  • scrypt: Memory-hard (requires lots of RAM), making GPU attacks harder.
  • Argon2: The current recommendation (winner of the Password Hashing Competition). Memory-hard and configurable.
import { hash, verify } from "@node-rs/argon2"
 
const hashed = await hash("user-password")
const isValid = await verify(hashed, "user-password")

Each algorithm includes a salt—random data mixed with the password before hashing. This means two users with the same password get different hashes, defeating precomputed rainbow table attacks.

Rules:

  • Never use MD5 or SHA-256 for passwords.
  • Never implement your own password hashing.
  • Always use a library that handles salting automatically.

HMAC — Hash-Based Message Authentication

HMAC combines a hash function with a secret key to produce an authentication code. It answers: "was this message created by someone who knows the secret key, and has it been tampered with?"

import { createHmac } from 'node:crypto'
 
const secret = 'webhook-signing-secret'
const payload = JSON.stringify({ event: 'payment.completed', amount: 100 })
 
const signature = createHmac('sha256', secret)
  .update(payload)
  .digest('hex')
 
// Verify: recompute the HMAC and compare
function verifyWebhook(payload: string, receivedSignature: string, secret: string) {
  const expected = createHmac('sha256', secret).update(payload).digest('hex')
  return timingSafeEqual(Buffer.from(expected), Buffer.from(receivedSignature))
}

Where HMAC is used:

  • Webhook signatures: Stripe, GitHub, and most APIs sign webhook payloads with HMAC. You verify the signature to confirm the request came from them.
  • JWT signing: HS256 (HMAC with SHA-256) is one of the JWT signing algorithms.
  • API authentication: Some APIs use HMAC-signed requests instead of bearer tokens.

Critical detail: Always use timing-safe comparison (crypto.timingSafeEqual) when comparing signatures. Regular string comparison (===) leaks information through timing—an attacker can determine how many bytes matched by measuring response time.


Symmetric Encryption

Symmetric encryption uses the same key to encrypt and decrypt. It's fast and suitable for encrypting large amounts of data.

AES (Advanced Encryption Standard) is the dominant symmetric cipher. AES-256 uses a 256-bit key and is considered secure against all known attacks, including quantum computers (with Grover's algorithm, it offers 128-bit equivalent security).

import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
 
const key = randomBytes(32)  // 256-bit key
const iv = randomBytes(16)   // initialization vector
 
function encrypt(plaintext: string) {
  const cipher = createCipheriv('aes-256-gcm', key, iv)
  let encrypted = cipher.update(plaintext, 'utf8', 'hex')
  encrypted += cipher.final('hex')
  const tag = cipher.getAuthTag()
  return { encrypted, iv: iv.toString('hex'), tag: tag.toString('hex') }
}
 
function decrypt(encrypted: string, ivHex: string, tagHex: string) {
  const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivHex, 'hex'))
  decipher.setAuthTag(Buffer.from(tagHex, 'hex'))
  let decrypted = decipher.update(encrypted, 'hex', 'utf8')
  decrypted += decipher.final('utf8')
  return decrypted
}

AES-GCM (Galois/Counter Mode) provides both encryption and authentication—it detects if the ciphertext has been tampered with. Always prefer GCM over CBC or ECB.

The key management problem: Symmetric encryption requires both parties to share the same key. How do you share a key securely over an insecure channel? This is the problem asymmetric encryption solves.


Asymmetric Encryption

Asymmetric encryption uses a key pair: a public key (shared with everyone) and a private key (kept secret). Data encrypted with the public key can only be decrypted with the private key, and vice versa.

RSA and Elliptic Curve Cryptography (ECC) are the two main asymmetric systems:

RSAECC
Key size for equivalent security2048-4096 bits256-384 bits
SpeedSlowerFaster
UsageLegacy systems, still widely usedModern TLS, SSH, Bitcoin

Asymmetric encryption is slow—100-1000x slower than AES. It's never used to encrypt bulk data directly. Instead, it's used to exchange a symmetric key, which then encrypts the actual data. This hybrid approach is what TLS uses.


Digital Signatures

Digital signatures prove that a message was created by the holder of a private key and hasn't been modified. They're the asymmetric equivalent of HMAC.

import { sign, verify, generateKeyPairSync } from "node:crypto"
 
const { publicKey, privateKey } = generateKeyPairSync("ed25519")
 
const message = "Transfer $100 to Alice"
 
const signature = sign(null, Buffer.from(message), privateKey)
 
const isValid = verify(null, Buffer.from(message), publicKey, signature)
// true

Where digital signatures are used:

  • JWTs with RS256/ES256: The token is signed with the server's private key. Anyone with the public key can verify it—no shared secret needed.
  • Code signing: npm packages, Docker images, and OS updates are signed to prove authenticity.
  • Git commits: git commit -S signs with your GPG key.
  • TLS certificates: The certificate is signed by a Certificate Authority.

Key Exchange: Diffie-Hellman

The Diffie-Hellman key exchange allows two parties to establish a shared secret over a public channel without ever transmitting the secret itself.

The intuition: imagine mixing paint colors. Alice and Bob each have a secret color. They publicly share a common base color. Each mixes their secret with the base and sends the result to the other. Each then mixes their secret with what they received. Both arrive at the same final color—but an eavesdropper who saw the publicly exchanged mixtures can't reverse the mixing to find the secret colors.

Mathematically, this uses modular exponentiation (classic DH) or elliptic curve point multiplication (ECDH). The result is a shared secret that both parties can use as a symmetric encryption key.

ECDH (Elliptic Curve Diffie-Hellman) is the modern variant used in TLS 1.3. It provides the same security as classic DH with much smaller keys.


How TLS Actually Works

TLS (Transport Layer Security) is the protocol that makes HTTPS secure. Here's what happens during a TLS 1.3 handshake:

1. ClientHello: The client sends supported cipher suites, TLS version, and its ECDH public share (key share).

2. ServerHello: The server picks a cipher suite and sends its ECDH public share plus its certificate.

3. Key derivation: Both sides independently compute the shared secret using ECDH. From this, they derive symmetric encryption keys for the session.

4. Certificate verification: The client checks the server's certificate against its trusted Certificate Authority (CA) list. The certificate proves the server is who it claims to be.

5. Finished: Both sides send encrypted "finished" messages to confirm the handshake succeeded.

After the handshake, all data is encrypted with AES (symmetric) using the keys derived from the ECDH exchange. The asymmetric crypto is only used during the handshake—bulk encryption is always symmetric because it's orders of magnitude faster.

TLS 1.3 improvements over 1.2:

  • 1-RTT handshake (1.2 required 2 round trips).
  • 0-RTT resumption for repeat connections (client sends encrypted data with the first message).
  • Removed insecure algorithms (RSA key exchange, CBC mode, MD5, SHA-1).
  • Forward secrecy is mandatory (compromising the server's long-term key doesn't decrypt past sessions).

Certificates and the PKI Chain

A certificate binds a public key to an identity (a domain name). It's signed by a Certificate Authority (CA) that browsers trust.

Root CA (pre-installed in browsers/OS)
  └── Intermediate CA (signed by Root)
       └── Your Certificate (signed by Intermediate)
             Domain: example.com
             Public Key: [...]
             Valid: 2025-01-01 to 2026-01-01

When your browser connects to example.com:

  1. The server sends its certificate and the intermediate CA certificate.
  2. The browser verifies the chain: your cert was signed by the intermediate, which was signed by the root, which is trusted.
  3. If the chain is valid and the certificate covers example.com, the connection proceeds.

Let's Encrypt made TLS certificates free and automated. There's no excuse for not using HTTPS in 2026.

Certificate pinning: Some apps embed the expected certificate (or its hash) and reject connections with different certificates, even if they're signed by a trusted CA. This prevents man-in-the-middle attacks where an attacker has compromised a CA.


Common Mistakes

Rolling your own crypto. Don't invent encryption algorithms, don't implement AES from scratch, don't create your own key derivation. Use well-tested libraries (node:crypto, libsodium, @noble/ciphers).

Using MD5 or SHA-1 for anything security-related. Both have known collision vulnerabilities. Use SHA-256 minimum.

Storing secrets in client-side code. API keys in JavaScript bundles, secrets in localStorage, encryption keys in environment variables exposed to the browser—all are accessible to anyone who opens DevTools.

Encrypting without authenticating. Using AES-CBC without HMAC means an attacker can modify the ciphertext and you won't detect it. Use AES-GCM (authenticated encryption) or encrypt-then-MAC.

Reusing initialization vectors (IVs). An IV must be unique for every encryption operation with the same key. Reusing an IV with AES-CTR or AES-GCM completely breaks the security. Always generate a random IV.

Comparing signatures with ===. String comparison short-circuits on the first differing byte, leaking timing information. Always use crypto.timingSafeEqual.


The Pragmatic Takeaway

You don't need to understand the math behind elliptic curves to be a competent engineer. But you do need to understand the landscape of primitives and what each one guarantees:

  • Hashing (SHA-256): integrity verification, fingerprinting. One-way, no key.
  • Password hashing (Argon2, bcrypt): slow-by-design hashing for storing passwords.
  • HMAC: message authentication with a shared secret. Verifies integrity and authenticity.
  • Symmetric encryption (AES-GCM): fast encryption with a shared key. Use for data at rest and bulk encryption.
  • Asymmetric encryption (RSA, ECC): encryption with a key pair. Use for key exchange, not bulk data.
  • Digital signatures (Ed25519, ECDSA): prove authorship and integrity. The asymmetric version of HMAC.
  • TLS: combines all of the above into a protocol that secures HTTP, WebSockets, and everything else on the web.

The crypto primitives are solved problems. Your job isn't to implement them—it's to use them correctly. Know which primitive fits your need, use a trusted library, and never take shortcuts with key management. Most security vulnerabilities aren't broken crypto—they're misused crypto.