Skip to main content
Auth, Identity & Security
🔐 Auth & SecurityLesson 10 of 13

HTTPS, Encryption & Data Protection

Protect data in transit and at rest — HTTPS, TLS, and encryption basics.

HTTPS, Encryption & Data Protection

Stage 3 · Auth, Identity & Security · B.U.I.L.D. letter: L

You built the whole thing — auth, RBAC, sanitized inputs, hashed passwords — and then you deployed it over plain http://. Every password your users typed on airport Wi-Fi just went across the wire as readable text. Every session token. Every piece of PII. The guy at gate B12 with Wireshark was having a great afternoon. HTTPS is not optional polish. It is the envelope that every other security control assumes is sealed.


⚠️ The vibe trap

You vibed out a full-stack app, the AI scaffolded everything, and the dev server ran fine on http://localhost. When you deployed, you just copied the URL the host gave you — which happened to start with https:// — and assumed you were done. But your app still accepts unencrypted http:// requests on port 80, your server sends no HSTS header, and your database stores a PII field in plaintext because "it's encrypted in transit anyway." Three separate problems, each fixable in minutes, each catastrophic if ignored. And somewhere in your codebase there is a utility function that base64-encodes a value and a comment that says // encoded for security. It is not.


🔍 In Transit vs At Rest — Two Separate Problems

Mental model: "In transit" means data moving across a network — your user's browser to your server, your server to your database host, your API to a third-party service. "At rest" means data sitting somewhere — a database row, a file on disk, a backup, a log file. HTTPS solves in-transit exposure. It does nothing for what is stored. Encryption at rest solves stored exposure. It does nothing for the wire. You need both, and they are independent layers.

Browser ──[TLS/HTTPS]──► Your Server ──[TLS required]──► Database Host
                         ↓
                     DB writes sensitive field
                         ↓
                    ┌─────────────────────────────────────┐
                    │  at rest: plaintext? encrypted? hash?│
                    └─────────────────────────────────────└

Why: Most breach post-mortems combine both. The attacker intercepted a token in transit (no HTTPS on an internal link), then used it to query a database that stored SSNs and card numbers in plaintext (no encryption at rest). Patching one would have contained the blast radius. Patching both means neither alone is enough to win.

Common mistake: Thinking "my database is on a private network so I don't need encryption at rest." Insider threats, database backups stored on S3, misconfigured firewall rules, and SQL injection all reach the database directly — bypassing TLS entirely. Encrypt the field value, not just the connection.


🔐 Hashing vs Encryption vs Encoding — Three Different Things

These three words are used interchangeably in the wild and they are completely different operations with completely different security properties.

Mental model:

  • Encoding transforms data into a different representation for compatibility — base64, URL encoding, hex. No key. Fully reversible by anyone. Zero security value.
  • Encryption locks data with a key — AES, RSA. Reversible only with the key. The right tool when you need to recover the original value.
  • Hashing runs data through a one-way function — bcrypt, SHA-256, BLAKE2. Not reversible. The right tool when you only need to verify a value, not recover it.
import crypto from 'crypto';

const value = 'user-secret-token';

// ❌ ENCODING — anyone can decode this, it is not security
const encoded = Buffer.from(value).toString('base64');
// encoded = 'dXNlci1zZWNyZXQtdG9rZW4='
const decoded = Buffer.from(encoded, 'base64').toString('utf8');
// decoded = 'user-secret-token'  ← trivially reversed, no key needed

// ✅ HASHING — one-way, use for passwords, token verification, integrity checks
const hashed = crypto.createHash('sha256').update(value).digest('hex');
// hashed = '4e98...'  ← cannot be reversed to 'user-secret-token'

// ✅ ENCRYPTION — two-way with a key, use for PII you need to read back later
const KEY = crypto.randomBytes(32);   // store this securely, NOT in source code
const IV  = crypto.randomBytes(16);   // fresh IV per operation
const cipher = crypto.createCipheriv('aes-256-gcm', KEY, IV);
let encrypted = cipher.update(value, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag().toString('hex');
// Store: { encrypted, iv: IV.toString('hex'), authTag } — all three needed for decryption

Common mistake: Base64-encoding a token or a password "for security" and storing it. Base64 is transparent — any attacker who reads the column runs atob() and has the plaintext. If someone on your team (or an AI) added a comment like // base64 for safety to an auth field, that is a critical bug, not a style issue.


🌐 HTTPS and TLS — What It Actually Protects

TLS (Transport Layer Security) — the protocol behind https:// — does three things: encrypts the connection so no one on the path can read the bytes; authenticates the server via a certificate so you know you are talking to the real host; ensures integrity so bytes cannot be altered in transit without detection.

On plain http://, every router, ISP hop, and Wi-Fi access point between your user and your server can read and modify the entire request — headers, cookies, form fields, passwords, session tokens. One person running Wireshark on a coffee-shop network sees everything.

# ❌ What an attacker on the same Wi-Fi sees with plain HTTP:
POST /api/login HTTP/1.1
Host: myapp.com
Content-Type: application/json

{"email":"user@example.com","password":"hunter2"}
# ✅ What they see with HTTPS — TLS-encrypted payload, unreadable:
POST /api/login HTTP/1.1
Host: myapp.com
...
[encrypted bytes: ÿ3Ö╗ë▓µ?ÿò•¶...]

Why: Session cookies are the most valuable target. A single stolen cookie lets an attacker impersonate a user without ever knowing their password. Cookie theft over HTTP is called session hijacking, and it takes about 30 seconds with freely available tools.

Common mistake: Deploying HTTPS but leaving the login form served from an http:// URL, or loading a mixed-content asset (a script or form action) over HTTP from an otherwise HTTPS page. Browsers block mixed content today, but older setups and misconfigured redirects still slip through. Audit every form action, every <script src>, every fetch base URL.


🔒 Forcing HTTPS: Redirects and HSTS

Serving over HTTPS is not enough if your server also accepts HTTP. An attacker can strip TLS from the first request before the browser upgrades — this is an SSL stripping attack. Two controls close that gap: a redirect from HTTP to HTTPS, and the Strict-Transport-Security (HSTS) header that tells browsers to never try HTTP again.

// Express: redirect all HTTP → HTTPS before any route runs
app.use((req, res, next) => {
  if (req.headers['x-forwarded-proto'] !== 'https' && process.env.NODE_ENV === 'production') {
    return res.redirect(301, `https://${req.headers.host}${req.url}`);
  }
  next();
});

// HSTS header — send on every HTTPS response
app.use((req, res, next) => {
  // max-age=63072000  = 2 years (recommended minimum once stable)
  // includeSubDomains = cover api.yourapp.com, cdn.yourapp.com, etc.
  // preload           = submit to browser preload lists (locks you in — don't add until ready)
  res.setHeader(
    'Strict-Transport-Security',
    'max-age=63072000; includeSubDomains'
  );
  next();
});

Mental model: The redirect catches first-time visitors or stale bookmarks. HSTS is the upgrade — after a browser sees that header once, it will refuse to connect over HTTP for the duration of max-age, even if your server goes down or someone manipulates DNS. It is a client-side promise enforced by the browser itself.

Common mistake: Setting max-age=31536000 (1 year) and includeSubDomains, then realising you have an internal subdomain that only runs HTTP. Now every browser that cached the HSTS policy refuses to load it. Start with a short max-age (e.g., 3600) in staging, verify all subdomains work over HTTPS, then ramp to 2 years.


🗄️ Encryption at Rest — Protecting What's Stored

Not everything in your database needs encryption at rest — hashed passwords are already one-way, public data is public. The fields that need it are: PII (full names combined with SSNs, dates of birth, medical data, payment card numbers), security tokens you need to recover (API keys stored for a user, OAuth refresh tokens), and anything that would constitute a breach notification if it leaked.

// A thin helper using Node's built-in crypto — no extra dependencies
// In production you'd pull KEY from a secrets manager, never from env directly in code
import crypto from 'crypto';

const ALGORITHM = 'aes-256-gcm';
const KEY = Buffer.from(process.env.FIELD_ENCRYPTION_KEY, 'hex'); // 32-byte key, hex-encoded

export function encryptField(plaintext) {
  const iv = crypto.randomBytes(12); // 12 bytes is standard for GCM
  const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv);
  const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
  const authTag = cipher.getAuthTag();
  // Return a single storable string: iv:authTag:ciphertext (all hex)
  return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
}

export function decryptField(stored) {
  const [ivHex, authTagHex, encryptedHex] = stored.split(':');
  const iv        = Buffer.from(ivHex, 'hex');
  const authTag   = Buffer.from(authTagHex, 'hex');
  const encrypted = Buffer.from(encryptedHex, 'hex');
  const decipher  = crypto.createDecipheriv(ALGORITHM, KEY, iv);
  decipher.setAuthTag(authTag);
  return decipher.update(encrypted) + decipher.final('utf8');
}

// Usage — store encrypted SSN, retrieve plaintext when needed
const encryptedSsn = encryptField('123-45-6789');
await db.query('UPDATE users SET ssn_encrypted = $1 WHERE id = $2', [encryptedSsn, userId]);

const row = await db.query('SELECT ssn_encrypted FROM users WHERE id = $1', [userId]);
const plainSsn = decryptField(row.ssn_encrypted); // '123-45-6789'

Why: AES-256-GCM is authenticated encryption — the authTag detects any tampering with the ciphertext. If the stored value is modified (database corruption, attacker who got write access), decryption throws rather than silently returning garbage or a forged value.

Common mistake: Using AES-CBC without an HMAC. CBC encrypts but does not authenticate — a padding oracle attack can decrypt ciphertext byte-by-byte without the key. Always use GCM, or use a library that makes the authenticated choice for you (libsodium, @noble/ciphers).


🔑 Key Management — The Key Is the Secret Now

Once you encrypt fields, your encryption key is the new password to every encrypted record. Key management is where DIY crypto implementations most often fail.

# Generate a strong 32-byte (256-bit) key, store the output as FIELD_ENCRYPTION_KEY
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# → e.g. a3f1c2b9...  (64-char hex string)

Never commit a key to source control. Never hardcode it in application code. Load it from a secrets manager (AWS Secrets Manager, Doppler, Vault) or at minimum from an environment variable that is only set in your deployment environment. Rotate keys periodically: decrypt old records with the old key, re-encrypt with the new key, retire the old key. Build this process before you need it — not after you suspect a key was exposed.

Common mistake: Storing the encryption key in the same database as the encrypted data. If an attacker dumps the database, they have both the ciphertext and the key. The key must live somewhere the attacker cannot reach in the same breach that exposes the ciphertext.


🧱 Secure Headers — A One-Line Nod to the Wider Picture

While you are setting HSTS, add the rest of the security header baseline. helmet in Express sets sensible defaults for X-Frame-Options (clickjacking), X-Content-Type-Options (MIME sniffing), Referrer-Policy, and gives you a simple API for Content-Security-Policy (CSP — covered in OWASP Pt 1). CSP alone blocks the entire class of XSS attacks by whitelisting which scripts are allowed to run.

// Install once: npm install helmet
import helmet from 'helmet';

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc:  ["'self'"],
      scriptSrc:   ["'self'"],           // no inline scripts, no CDN unless listed
      styleSrc:    ["'self'", "'unsafe-inline'"],  // tighten once you audit inline styles
      imgSrc:      ["'self'", 'data:', 'https:'],
      connectSrc:  ["'self'"],
      frameAncestors: ["'none'"],        // equivalent of X-Frame-Options: DENY
    },
  },
  hsts: {
    maxAge: 63072000,
    includeSubDomains: true,
  },
}));

Common mistake: Calling helmet() and then calling res.setHeader('Content-Security-Policy', ...) manually somewhere else to allow an inline script for one page. The manual override silently replaces helmet's policy for that response, opening the door back up. Use helmet's directives API consistently.


🛠️ Your Mission

Pick the app you have been building throughout this track and run through these in order.

  1. Check whether your deployed URL accepts http://. Type it manually with http:// in the browser. If it loads without redirecting, your redirect middleware is missing or not running in production — add it.
  2. Add the HSTS header. Start with max-age=3600 and verify nothing breaks on any subdomain, then increase to 63072000.
  3. Find every field in your database that stores PII (email is borderline; SSN, DOB, payment card are definite candidates). Pick at least one and add encrypt-on-write / decrypt-on-read using the encryptField/decryptField helper above.
  4. Search your codebase for base64 near any auth or user-data path. If you find it used as a "security" measure, replace it with the appropriate tool: hashing if you only need to verify, encryption if you need the value back.
  5. Run the full Security Audit Checklist for your project and check off every item under A02 Cryptographic Failures and A08 Software and Data Integrity Failures.

✅ You're Done When…

  • Every item under A02 Cryptographic Failures on the Security Audit Checklist is checked off, with evidence noted for each
  • http:// requests to your production app return a 301 redirect to https:// — verified by curl or browser devtools
  • Every HTTPS response includes Strict-Transport-Security: max-age=63072000; includeSubDomains
  • At least one PII field in your database is stored encrypted at rest using AES-256-GCM (or a vetted library equivalent) and decrypted only at read time in application code
  • You can explain out loud — without notes — the difference between hashing, encryption, and encoding, and name the correct tool for: storing a password, storing an SSN you need to display later, passing a binary blob in a URL
  • No encryption key, API key, or secret appears in source control — confirmed by git log -p | grep -i 'key\|secret\|password'
  • helmet() (or equivalent security headers) is applied globally and CSP is set to at least defaultSrc: ["'self'"]

➡️ Next: Threat Modeling. Build It Right, Or Don't Build It At All. 🏛️

Pre-launch security · partner tool

Built it? Now scan it. The HYVE Audit finds security holes before launch — $55, and your code never leaves your machine.

Run the audit ↗

Always-on rigor toolkit

🏛️ Build It Right, Or Don't Build It At All.