What HTTPS Actually Does
Every time your browser shows a padlock icon, a cryptographic handshake has already happened. HTTPS (HTTP Secure) is just HTTP running over TLS (Transport Layer Security). But "running over TLS" involves several distinct protections that are easy to confuse:
- Encryption: Data is scrambled so eavesdroppers can't read it
- Integrity: Data cannot be modified in transit without detection
- Authentication: You're talking to the server you think you are (not an impersonator)
All three are needed. Encryption without authentication is useless — an attacker could just intercept and re-encrypt your connection.
The TLS Handshake: Step by Step
Modern TLS 1.3 (2018) simplified the handshake significantly. Here's what happens when you visit https://example.com:
Client Server
| |
|──── ClientHello ──────────────────────→ |
| (TLS version, cipher suites, |
| random nonce, key share) |
| |
|←─── ServerHello ─────────────────────── |
| (chosen cipher, key share) |
|←─── {Certificate} ────────────────────── |
| (server's public key + chain) |
|←─── {CertificateVerify} ──────────────── |
| (signature proving server has key) |
|←─── {Finished} ───────────────────────── |
| |
|──── {Finished} ────────────────────────→ |
| |
|←══════ Encrypted HTTP traffic ════════→ |
In TLS 1.3, the handshake completes in 1 round trip (1-RTT). There's even a 0-RTT mode for session resumption (though it has replay attack tradeoffs).
Key Exchange: Diffie-Hellman
The clever part is how client and server establish a shared secret without ever transmitting it:
# Simplified Diffie-Hellman (ECDHE in practice)
# Public info: prime p, base g
# Server generates: private_s, sends public_S = g^private_s mod p
# Client generates: private_c, sends public_C = g^private_c mod p
#
# Shared secret = g^(private_s × private_c) mod p
# Server computes: public_C ^ private_s = same value
# Client computes: public_S ^ private_c = same value
# An eavesdropper who sees public_S and public_C cannot compute the secret
This is called Perfect Forward Secrecy (PFS): even if the server's private key is later compromised, past session keys cannot be decrypted.
TLS Certificates Explained
A TLS certificate binds a public key to a domain name, and is signed by a Certificate Authority (CA) that your browser trusts.
# Inspect a real certificate
openssl s_client -connect example.com:443 -showcerts 2>/dev/null | openssl x509 -text -noout
# Key fields:
# Subject: CN=example.com (the domain this cert is for)
# Issuer: CN=Let's Encrypt R3 (who signed it)
# Validity: Not Before / Not After
# Subject Alternative Names: DNS:example.com, DNS:www.example.com
# Public Key Algorithm: id-ecPublicKey (curve: prime256v1)
# Signature Algorithm: ecdsa-with-SHA256
Certificate Chain of Trust
Your browser doesn't directly trust every website's certificate. It trusts a set of Root CAs (bundled with the OS/browser). Intermediate CAs bridge the gap:
Root CA (DigiCert Global Root) → In browser's trust store
└── Intermediate CA (DigiCert TLS RSA) → Signed by Root
└── Your certificate (example.com) → Signed by Intermediate
When a browser connects, it verifies the entire chain up to a trusted root.
Let's Encrypt: Free Automated Certificates
# Install certbot
sudo apt install certbot python3-certbot-nginx
# Get certificate for your domain
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
# Auto-renewal (runs twice daily via cron/systemd)
sudo certbot renew --dry-run
# Certificate stored at:
# /etc/letsencrypt/live/yourdomain.com/fullchain.pem
# /etc/letsencrypt/live/yourdomain.com/privkey.pem
Configuring TLS in Nginx
server {
listen 443 ssl http2;
server_name example.com;
# Certificate and private key
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Modern TLS only
ssl_protocols TLSv1.2 TLSv1.3;
# Strong cipher suites (TLS 1.3 auto-selects)
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# Session resumption (performance)
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off; # Disable tickets to preserve PFS
# OCSP Stapling (faster cert validation)
ssl_stapling on;
ssl_stapling_verify on;
resolver 8.8.8.8 8.8.4.4 valid=300s;
# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY;
add_header Content-Security-Policy "default-src 'self'" always;
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
HSTS: HTTP Strict Transport Security
Once set, HSTS tells browsers to always use HTTPS, preventing downgrade attacks:
# Server response header
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
# max-age: how long to remember (2 years = 63072000 seconds)
# includeSubDomains: applies to all subdomains
# preload: submit to browser preload lists (hardcoded HTTPS)
The HSTS preload list means browsers use HTTPS even on the very first visit.
Certificate Transparency
Since 2018, all public CAs must log every certificate they issue to Certificate Transparency (CT) logs. This means:
- You can monitor for unauthorized certificates for your domain
- Browsers require SCTs (Signed Certificate Timestamps) in certificates
# Check CT logs for your domain
curl "https://crt.sh/?q=%.yourdomain.com&output=json" | jq '.[].name_value'
# Monitor for unexpected certificates (set up alerts with Facebook's Cert Transparency Monitor)
Common TLS Vulnerabilities
Mixed Content
<!-- ❌ Loading HTTP resource on HTTPS page → browser warning/block -->
<img src="http://cdn.example.com/image.png">
<!-- ✅ Always use relative URLs or HTTPS -->
<img src="https://cdn.example.com/image.png">
<img src="//cdn.example.com/image.png"> <!-- protocol-relative -->
Certificate Pinning (Mobile Apps)
// In React Native, pin expected certificate hash
// Prevents attacks even if a CA is compromised
import { fetch } from 'react-native-ssl-pinning';
fetch('https://api.yourapp.com/data', {
sslPinning: {
certs: ['sha256//EXPECTED_CERT_HASH=']
}
});
Weak Cipher Suites
# Test your server's TLS configuration
nmap --script ssl-enum-ciphers -p 443 yourdomain.com
# Or use online tool:
# https://www.ssllabs.com/ssltest/
# Look for:
# ❌ SSLv3, TLSv1.0, TLSv1.1 (deprecated)
# ❌ RC4, DES, 3DES (broken)
# ❌ Export-grade ciphers (FREAK attack)
# ✅ TLSv1.2 with AEAD ciphers, TLSv1.3
TLS in Node.js
import https from 'https';
import fs from 'fs';
import express from 'express';
const app = express();
// Read certificate files
const options = {
cert: fs.readFileSync('/path/to/fullchain.pem'),
key: fs.readFileSync('/path/to/privkey.pem'),
// Modern TLS settings
secureOptions: crypto.constants.SSL_OP_NO_SSLv2 |
crypto.constants.SSL_OP_NO_SSLv3 |
crypto.constants.SSL_OP_NO_TLSv1 |
crypto.constants.SSL_OP_NO_TLSv1_1,
minVersion: 'TLSv1.2',
};
https.createServer(options, app).listen(443);
// Verify remote certificate (for outbound requests)
const response = await fetch('https://api.example.com', {
// Node.js verifies certificates by default
// To debug (NEVER in production):
// dispatcher: new Agent({ rejectUnauthorized: false })
});
HTTP/2 and HTTP/3
HTTPS is required for HTTP/2, which brings significant performance improvements:
HTTP/1.1: One request per connection. Head-of-line blocking.
HTTP/2: Multiplexed streams. Server push. Header compression.
HTTP/3: Built on QUIC (UDP). Even better for mobile networks.
# Enable HTTP/2 in nginx (free with HTTPS)
listen 443 ssl http2;
# HTTP/3 (QUIC) - requires nginx 1.25+ or Cloudflare
listen 443 quic reuseport;
add_header Alt-Svc 'h3=":443"; ma=86400';
→ Use the Encryption Tool to explore symmetric and asymmetric encryption algorithms used in TLS.