Encryption at rest vs. in transit — what actually protects what, and what's the failure mode if you only have one?
TLS and at-rest encryption defend against two completely different attackers, and having only one leaves the other's favorite attack wide open.
LEARN BY DOINGPut your intuition to work3 steps
Encryption at rest vs. in transit — what actually protects what, and what's the failure mode if you only have one?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Two different attackers, two different defenses
Encryption in transit (TLS) protects data while it's moving across a network you don't fully control — between a browser and your server, between two of your own services across a cloud provider's internal network, between your app and a third-party API. The attacker it defends against sits on the wire: someone sniffing packets on public Wi-Fi, a compromised router, a misconfigured load balancer somewhere between hops, a malicious exit node.
Encryption at rest protects data while it's sitting still — on a disk, in a database file, in a backup, in a snapshot. The attacker it defends against has gotten access to storage rather than the network: a stolen laptop, a misconfigured storage bucket, a decommissioned hard drive that wasn't wiped, a database backup that leaked, an insider with filesystem access but no valid application credentials.
These are genuinely different threat models, and neither one substitutes for the other:
| Encryption in transit (TLS) | Encryption at rest | |
|---|---|---|
| Protects data | While moving across a network | While stored on disk/backup |
| Defends against | Network eavesdropping, MITM | Stolen storage, leaked backups, filesystem access |
| Fails to protect against | A breached database, a stolen backup file | Traffic sniffing, a MITM proxy |
| Typical mechanism | TLS/HTTPS handshake, certificates | Disk/volume encryption, field-level encryption, KMS-wrapped keys |
#The failure mode of TLS-only
If you encrypt every connection but store data in plaintext, a database breach — SQL injection, a leaked backup, a stolen disk — hands the attacker everything in cleartext with zero additional work. This is the single most common real-world combination: teams get HTTPS everywhere (rightly treated as table stakes) and stop there, leaving the actual data store as an unencrypted plaintext dump the moment anyone gets past the network perimeter. Every major breach involving "millions of plaintext passwords or national ID numbers found in a leaked database" is this failure mode.
#The failure mode of at-rest-only
Less common in practice, but just as real: if the database itself is fully encrypted at rest but the connection between your application and that database (or between your API and its clients) is plain HTTP, anyone positioned on the network path reads every credential and every row as it flies past, encryption-at-rest be damned — the data was decrypted the moment it left storage to be sent over the wire. This shows up in internal-network setups where teams assume "it's inside our private network, so it's safe" and skip TLS between microservices, which is a dangerous assumption the moment an attacker gets a single foothold inside that network.
#Real configuration: TLS in transit
A minimal, current TLS configuration for an Nginx-fronted service — the details that actually matter in a real audit are the protocol floor and the cipher list, not just "HTTPS is on":
server {listen 443 ssl;server_name api.example.com;ssl_certificate /etc/ssl/certs/api.example.com.fullchain.pem;ssl_certificate_key /etc/ssl/private/api.example.com.key;# Reject the old, broken protocol versions outrightssl_protocols TLSv1.2 TLSv1.3;# Prefer AEAD ciphers with forward secrecy; drop RC4, 3DES, and static RSA key exchangessl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;ssl_prefer_server_ciphers on;# Tell browsers to never even try plain HTTP for this host againadd_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;location / {proxy_pass http://127.0.0.1:8080;}}server {listen 80;server_name api.example.com;return 301 https://$host$request_uri; # redirect any stray plaintext request}
Restricting to TLS 1.2/1.3 rules out the protocol versions with known cryptographic weaknesses (POODLE against SSLv3, BEAST/CRIME-adjacent issues in older TLS 1.0). The cipher list is scoped to ECDHE key exchange specifically for forward secrecy — if a private key is ever compromised in the future, previously captured traffic still can't be decrypted, because each session negotiated its own ephemeral key. Strict-Transport-Security closes the gap where a user's first request might still go out over plain HTTP before a redirect fires.
#Real configuration: field-level encryption at rest
Full-disk or database-level encryption (transparent data encryption in Postgres/MySQL, volume-level encryption) protects against a stolen disk, but it decrypts everything transparently for any authenticated query — it does nothing extra for a specific highly sensitive column. Field-level encryption addresses that by encrypting the value itself before it's ever written to a row, using an application-managed key (commonly itself wrapped by a KMS, so the raw key never sits in application config):
const crypto = require('crypto');// dataKey would itself be fetched from a KMS at startup, not hardcoded —// this function only handles the actual field encryption/decryptionfunction encryptField(plaintext, dataKey) {const iv = crypto.randomBytes(12); // AES-GCM needs a fresh, random IV per encryptionconst cipher = crypto.createCipheriv('aes-256-gcm', dataKey, iv);const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);const authTag = cipher.getAuthTag(); // GCM's integrity check — detects tampering on decrypt// store iv + authTag alongside the ciphertext; none of these three are secret on their ownreturn Buffer.concat([iv, authTag, ciphertext]).toString('base64');}function decryptField(stored, dataKey) {const raw = Buffer.from(stored, 'base64');const iv = raw.subarray(0, 12);const authTag = raw.subarray(12, 28);const ciphertext = raw.subarray(28);const decipher = crypto.createDecipheriv('aes-256-gcm', dataKey, iv);decipher.setAuthTag(authTag);// throws if authTag doesn't match — ciphertext was tampered with or the key is wrongreturn Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');}// usage: only the sensitive column is encrypted before the row is writtenconst ssnCiphertext = encryptField(user.ssn, dataKey);await db.query('INSERT INTO users (id, name, ssn_encrypted) VALUES ($1, $2, $3)', [user.id,user.name,ssnCiphertext,]);
AES-256-GCM is chosen specifically because it's authenticated encryption — it detects if the ciphertext was tampered with (decipher.final() throws on a mismatched auth tag), which a plain AES-CBC encryption would silently fail to catch. The IV must be unique per encryption with GCM — reusing an IV with the same key is a catastrophic break that can leak the plaintext — which is why it's generated fresh with crypto.randomBytes every call rather than reused or derived predictably.
🧠 Check your understanding
0/3 · 0/3 answered1. A company uses HTTPS for every client connection but stores its production database completely unencrypted. An attacker exploits a SQL injection bug to dump the database. What went wrong?
2. Why does AES-256-GCM require a unique, randomly generated IV for every single encryption operation with the same key?
3. A database has full-disk encryption enabled, but an attacker compromises the application server and can run authenticated queries with the app's normal database credentials. What does disk-level encryption at rest do for this attacker?