HTTPS and TLS Explained: How Secure Connections Actually Work
Every time you log into your bank, buy something on Amazon, or send a message through a web app, your data travels across dozens of servers, routers, and cables you don't control. Without protection, anyone sitting on the same coffee-shop Wi-Fi network could read your password in plain text. HTTPS isn't a luxury — it's the foundational layer that makes the modern web safe enough to use for anything that matters.
The problem HTTPS solves is three-fold: confidentiality (nobody else can read the data), integrity (nobody can silently tamper with the data in transit), and authentication (you're actually talking to the real server, not an attacker pretending to be it). HTTP alone solves none of these. It sends everything as plain text, and there's no verification of who you're talking to. TLS — Transport Layer Security — is the protocol that wraps HTTP to solve all three problems at once.
By the end of this article you'll understand exactly what happens during a TLS handshake, why certificate authorities exist and what they're really doing, how symmetric and asymmetric encryption work together for performance, and what common misconfigurations look like in the real world. You'll also be ready to answer the HTTPS questions that come up in system design interviews.
The TLS Handshake: What Happens Before a Single Byte of Real Data Is Sent
Before your browser and a server exchange any real data, they perform a TLS handshake — a negotiation phase that accomplishes authentication, cipher selection, and key exchange. This happens in milliseconds, but it's doing serious work.
Here's the sequence in plain terms. Your browser sends a 'ClientHello' — essentially saying 'here are the TLS versions I support and the cipher suites I can use.' The server replies with a 'ServerHello', choosing the best mutually supported options, and also sends its certificate. The certificate contains the server's public key and is signed by a Certificate Authority (CA) the browser already trusts.
Next comes the key exchange. In modern TLS 1.3, both sides use Diffie-Hellman to independently compute the same shared secret without ever sending that secret over the wire. This is the part that feels like magic — two parties can derive an identical key through public messages alone. Once both sides have the shared secret, they derive symmetric encryption keys from it and the handshake is done.
From this point forward, all data is encrypted with those symmetric keys using a cipher like AES-256-GCM. Symmetric encryption is used here — not asymmetric — because it's orders of magnitude faster, and now that the handshake is done, both sides have the same key.
// TLS 1.3 Handshake — annotated step by step // Each arrow shows the direction of the message Client Server | |--- ClientHello -------------------------------->| | [Supported TLS versions: 1.2, 1.3] | | [Supported cipher suites: AES-256-GCM, ...] | | [Client's DH key share] | | [Random nonce: client_random] | | | |<-- ServerHello ----------------------------------| | [Chosen TLS version: 1.3] | | [Chosen cipher: AES-256-GCM-SHA384] | | [Server's DH key share] | | [Random nonce: server_random] | | | |<-- Certificate ----------------------------------| | [Server's public key] | | [Signed by: DigiCert CA] | | [Domain: example.com] | | [Valid until: 2025-12-31] | | | |<-- CertificateVerify ----------------------------| | [Proof server owns the private key] | | | |<-- Finished (encrypted) ------------------------| | [Handshake MAC — confirms nothing tampered] | | | |--- Finished (encrypted) ------------------------>| | | // Both sides now independently computed: // shared_secret = DH(client_private, server_public) // = DH(server_private, client_public) // // From shared_secret + client_random + server_random: // client_write_key, server_write_key are derived // // All application data from here is encrypted with AES-256-GCM | |=== Application Data (encrypted) =============>| | GET /account HTTP/1.1 | | Host: example.com | | Cookie: session=abc123 | | | |<=== Application Data (encrypted) ==============| | HTTP/1.1 200 OK | | Content-Type: application/json |
// In a real Wireshark capture you'd see:
//
// Frame 4: TLSv1.3 Record Layer: Handshake Protocol: Client Hello
// Frame 5: TLSv1.3 Record Layer: Handshake Protocol: Server Hello
// Frame 6: TLSv1.3 Record Layer: Handshake Protocol: Certificate
// Frame 7: TLSv1.3 Record Layer: Handshake Protocol: Certificate Verify
// Frame 8: TLSv1.3 Record Layer: Handshake Protocol: Finished
// Frame 9: TLSv1.3 Record Layer: Handshake Protocol: Finished
// Frame 10: TLSv1.3 Record Layer: Application Data <-- real data starts here
//
// Total handshake in TLS 1.3: 1 round trip (vs 2 in TLS 1.2)
// That's why upgrading to TLS 1.3 visibly improves page load time.
Certificates and Certificate Authorities: The Web's Trust Infrastructure
The TLS handshake proves the connection is encrypted — but encrypted to WHO? This is where certificates come in. A certificate is a digitally signed document that says: 'this public key belongs to example.com, and I, DigiCert, vouch for that.'
Your operating system and browser ship with a pre-installed list of ~150 trusted root Certificate Authorities. When a server presents its certificate, your browser checks the CA's digital signature on it. If the signature is valid and the CA is in the trusted list, the browser accepts the certificate. This chain of trust is called the PKI — Public Key Infrastructure.
Certificates have a few critical fields worth knowing. The Subject Alternative Name (SAN) lists the exact domains the cert is valid for. The validity period is typically 398 days or less. The public key inside is used only during the handshake key exchange — not for bulk encryption.
Let's Encrypt changed the game in 2016 by making certificate issuance free and automated via the ACME protocol. Before that, certs cost money and required manual renewal. Today there's no excuse for running a site on HTTP.
One important concept: certificate pinning. Some high-security apps (banking apps, for example) hard-code the expected certificate or public key into the client. This protects against a compromised CA issuing a fraudulent cert for your domain. It's powerful but operationally painful — if you rotate your cert, your app breaks until you push an update.
#!/bin/bash # Inspect the TLS certificate of any live server from the command line. # This is what your browser does automatically — we're just making it visible. TARGET_DOMAIN="example.com" TARGET_PORT="443" echo "=== Fetching TLS certificate for ${TARGET_DOMAIN} ===" # openssl s_client opens a raw TLS connection # </dev/null feeds it an empty stdin so it doesn't hang waiting for input # 2>/dev/null suppresses the verbose handshake noise openssl s_client \ -connect "${TARGET_DOMAIN}:${TARGET_PORT}" \ -servername "${TARGET_DOMAIN}" \ </dev/null 2>/dev/null \ | openssl x509 -noout -text \ | grep -A2 -E 'Issuer:|Subject:|Not Before|Not After|Subject Alternative' echo "" echo "=== Check if certificate is currently valid ===" # This exits with code 0 if the cert is valid, non-zero if expired or untrusted EXPIRY=$(openssl s_client \ -connect "${TARGET_DOMAIN}:${TARGET_PORT}" \ -servername "${TARGET_DOMAIN}" \ </dev/null 2>/dev/null \ | openssl x509 -noout -enddate) echo "Expiry: ${EXPIRY}" # Parse the expiry date and check days remaining EXPIRY_DATE=$(echo "${EXPIRY}" | cut -d= -f2) EXPIRY_EPOCH=$(date -d "${EXPIRY_DATE}" +%s 2>/dev/null || date -j -f "%b %d %T %Y %Z" "${EXPIRY_DATE}" +%s) NOW_EPOCH=$(date +%s) DAYS_REMAINING=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 )) if [ "${DAYS_REMAINING}" -lt 30 ]; then echo "WARNING: Certificate expires in ${DAYS_REMAINING} days — renew soon!" else echo "Certificate is healthy: ${DAYS_REMAINING} days remaining." fi
Issuer: C=US, O=DigiCert Inc, CN=DigiCert Global G2 TLS RSA SHA256 2020 CA1
Subject: C=US, ST=California, L=Los Angeles, O=Verisign Inc., CN=example.com
Not Before: Jan 13 00:00:00 2024 GMT
Not After : Feb 13 23:59:59 2025 GMT
X509v3 Subject Alternative Name:
DNS:example.com, DNS:www.example.com
=== Check if certificate is currently valid ===
Expiry: notAfter=Feb 13 23:59:59 2025 GMT
Certificate is healthy: 187 days remaining.
Symmetric vs Asymmetric Encryption: Why TLS Uses Both and How They Fit Together
This is the part most explanations skip, and it's the key to truly understanding TLS.
Asymmetric encryption (like RSA or elliptic curve) uses a key pair: a public key anyone can see, and a private key only you hold. Data encrypted with the public key can only be decrypted with the private key. It's brilliant for key exchange and signatures — but it's computationally expensive. Encrypting a 1MB response body with RSA would be agonizingly slow.
Symmetric encryption (like AES) uses a single shared key for both encryption and decryption. It's extremely fast — modern CPUs have AES hardware instructions and can encrypt gigabytes per second. The problem? Both parties need the same key, and how do you share a secret key without someone intercepting it?
TLS's elegant answer: use asymmetric cryptography to safely establish a shared secret, then use that shared secret to derive symmetric keys for all actual data transfer. You get the security guarantees of asymmetric crypto for the key exchange, and the performance of symmetric crypto for everything else.
The Diffie-Hellman key exchange is the specific mechanism TLS 1.3 uses. It's remarkable because both parties can compute an identical shared secret by exchanging only public information. Even if an attacker records every message, they can't compute the shared secret without solving the discrete logarithm problem — which is computationally infeasible with modern key sizes.
Forward secrecy is a property this gives you: even if the server's private key is compromised in the future, recorded past sessions can't be decrypted, because each session used its own ephemeral DH key pair that was discarded after the handshake.
# Simplified Diffie-Hellman demonstration # This uses toy numbers to show the CONCEPT. # Real TLS 1.3 uses elliptic curve DH with 256-bit keys. import random def compute_public_value(generator, private_key, prime_modulus): """ Each party computes: public_value = generator^private_key mod prime_modulus This is the 'one-way function' — easy to compute, hard to reverse. """ return pow(generator, private_key, prime_modulus) def compute_shared_secret(their_public_value, my_private_key, prime_modulus): """ Each party computes: shared_secret = their_public^my_private mod prime_modulus The magic: both parties compute the SAME value without ever sharing private keys. """ return pow(their_public_value, my_private_key, prime_modulus) # --- Parameters agreed upon in public (sent in ClientHello/ServerHello) --- # In real TLS these are standardized curves like X25519, not small integers PRIME_MODULUS = 23 # A small prime for demonstration (real: 2^255 - 19 for X25519) GENERATOR = 5 # A primitive root of the prime (publicly known) print(f"Public parameters: prime={PRIME_MODULUS}, generator={GENERATOR}") print(f"(These are sent openly in the TLS Hello messages)") print() # --- Browser (Client) side --- browser_private_key = 6 # NEVER sent over the wire — kept secret forever browser_public_value = compute_public_value(GENERATOR, browser_private_key, PRIME_MODULUS) print(f"Browser private key (secret): {browser_private_key}") print(f"Browser public value (sent): {browser_public_value}") # Sent in ClientHello print() # --- Server side --- server_private_key = 15 # NEVER sent over the wire — discarded after handshake in TLS 1.3 server_public_value = compute_public_value(GENERATOR, server_private_key, PRIME_MODULUS) print(f"Server private key (secret): {server_private_key}") print(f"Server public value (sent): {server_public_value}") # Sent in ServerHello print() # --- Each side independently computes the SAME shared secret --- # Browser computes: server_public ^ browser_private mod prime browser_computed_secret = compute_shared_secret(server_public_value, browser_private_key, PRIME_MODULUS) # Server computes: browser_public ^ server_private mod prime server_computed_secret = compute_shared_secret(browser_public_value, server_private_key, PRIME_MODULUS) print(f"Browser computed shared secret: {browser_computed_secret}") print(f"Server computed shared secret: {server_computed_secret}") print(f"Do they match? {browser_computed_secret == server_computed_secret}") print() # --- Attacker's perspective (they saw everything sent over the wire) --- # Attacker knows: prime=23, generator=5, browser_public=8, server_public=19 # To find browser_private, attacker must solve: 5^x mod 23 = 8 # This is the Discrete Logarithm Problem — infeasible with real 256-bit keys print("Attacker knows publicly:") print(f" prime={PRIME_MODULUS}, generator={GENERATOR}") print(f" browser_public={browser_public_value}, server_public={server_public_value}") print("Attacker cannot compute the shared secret without solving discrete log.") print(f"In real TLS with X25519, this requires more compute than exists on Earth.")
(These are sent openly in the TLS Hello messages)
Browser private key (secret): 6
Browser public value (sent): 8
Server private key (secret): 15
Server public value (sent): 19
Browser computed shared secret: 2
Server computed shared secret: 2
Do they match? True
Attacker knows publicly:
prime=23, generator=5
browser_public=8, server_public=19
Attacker cannot compute the shared secret without solving discrete log.
In real TLS with X25519, this requires more compute than exists on Earth.
HTTPS in Practice: Real-World Configuration, Pitfalls, and What to Check
Understanding TLS theoretically is half the battle. The other half is knowing what good HTTPS configuration looks like in practice and what common mistakes silently undermine your security.
HTTP Strict Transport Security (HSTS) is the header that tells browsers 'never connect to this domain over HTTP, even if the user types http://, for the next X seconds.' Without HSTS, a user typing 'example.com' makes an initial plain HTTP request before being redirected to HTTPS. That initial request is a window for a SSL-stripping attack. With HSTS and a long max-age (at least 1 year), browsers skip the HTTP step entirely.
Mixed content is another common real-world problem. A page served over HTTPS that loads a JavaScript file or image over HTTP is 'mixed content.' Modern browsers block active mixed content (scripts, iframes) entirely and warn about passive mixed content (images). Your HTTPS padlock disappears and your users' security is degraded — often without the developer realising.
Cipher suite selection matters more than most developers realize. Leaving deprecated ciphers enabled — like RC4, 3DES, or CBC-mode ciphers without Encrypt-then-MAC — exposes users to attacks like BEAST and POODLE even over TLS. Use Mozilla's SSL Configuration Generator to get battle-tested Nginx or Apache config rather than writing cipher strings yourself.
Finally, OCSP stapling is worth knowing. Browsers need to check if a certificate has been revoked. Without stapling, the browser must make a separate request to the CA's OCSP server during every TLS handshake — adding latency and leaking browsing data to the CA. With OCSP stapling, the server fetches the OCSP response and staples it to the TLS handshake, solving both problems.
# Production-grade Nginx TLS configuration # Based on Mozilla Intermediate compatibility profile # Supports: TLS 1.2 and TLS 1.3 (drops TLS 1.0 and 1.1 entirely) server { listen 443 ssl; # Listen for HTTPS connections listen [::]:443 ssl; # IPv6 as well http2 on; # Enable HTTP/2 (requires TLS) server_name example.com www.example.com; # --- Certificate configuration --- # Let's Encrypt managed cert (Certbot updates these paths automatically) ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; # Diffie-Hellman parameters for TLS 1.2 key exchange # Generate with: openssl dhparam -out /etc/nginx/dhparam.pem 2048 ssl_dhparam /etc/nginx/dhparam.pem; # --- TLS version enforcement --- # Drop TLS 1.0 and 1.1 entirely — both are deprecated and vulnerable ssl_protocols TLSv1.2 TLSv1.3; # --- Cipher suites (TLS 1.2 only — TLS 1.3 ciphers are non-negotiable) --- # Forward-secrecy enabled ciphers only, ordered by preference # CHACHA20 first for mobile clients without AES hardware acceleration ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305; ssl_prefer_server_ciphers off; # Let client pick in TLS 1.3 # --- Session resumption (performance) --- # Allows clients to resume TLS sessions without a full handshake ssl_session_timeout 1d; ssl_session_cache shared:SSL_CACHE:10m; # 10MB cache, ~40k sessions ssl_session_tickets off; # Off = better forward secrecy # --- OCSP Stapling --- # Server pre-fetches revocation status and bundles it with the handshake # Saves one round trip to the CA on every new connection ssl_stapling on; ssl_stapling_verify on; ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem; resolver 1.1.1.1 8.8.8.8 valid=300s; # Where to resolve OCSP server address resolver_timeout 5s; # --- Security headers (applied to all responses) --- add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; # ^ 2-year HSTS. includeSubDomains covers *.example.com. preload submits to browser preload lists. add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options SAMEORIGIN always; add_header Content-Security-Policy "upgrade-insecure-requests" always; # ^ Upgrades any accidental http:// resource loads to https:// — kills mixed content location / { proxy_pass http://localhost:3000; # Your app runs internally on plain HTTP proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto https; # Tell your app it's behind HTTPS } } # --- HTTP to HTTPS redirect (catches http:// requests) --- server { listen 80; listen [::]:80; server_name example.com www.example.com; # Permanent redirect — browser caches this after first visit return 301 https://$host$request_uri; }
# nginx -t && nginx -s reload
#
# Verify your configuration grade at: https://www.ssllabs.com/ssltest/
# Expected result with this config:
#
# SSL Labs Report for example.com
# ================================
# Overall Rating: A+
# Certificate: 100/100
# Protocol Support: 100/100 (TLS 1.2, 1.3 only)
# Key Exchange: 100/100 (Forward secrecy on all suites)
# Cipher Strength: 90/100
#
# HSTS: Yes (2 years, includeSubDomains, preload)
# OCSP Stapling: Yes
# Forward Secrecy: Yes (all browsers)
#
# Verified with: curl -I https://example.com
# HTTP/2 200
# strict-transport-security: max-age=63072000; includeSubDomains; preload
# x-content-type-options: nosniff
# x-frame-options: SAMEORIGIN
| Aspect | TLS 1.2 | TLS 1.3 |
|---|---|---|
| Handshake round trips | 2 RTT (full), 1 RTT (resumed) | 1 RTT (full), 0 RTT (resumed with 0-RTT feature) |
| Key exchange mechanism | RSA or DHE (RSA still allowed) | ECDHE only — forward secrecy mandatory |
| Cipher suites available | 37 options including weak ones like RC4, 3DES | 5 options — all strong, all AEAD |
| Forward secrecy | Optional (depends on cipher chosen) | Mandatory on every connection |
| Vulnerable to known attacks | BEAST, POODLE, CRIME if misconfigured | None of the above — they're all designed out |
| Performance | Baseline | ~100ms faster per connection due to 1-RTT handshake |
| Industry support (2024) | Widely supported, being phased out | Recommended default — supported by 96%+ of browsers |
| Server config complexity | Must disable weak ciphers manually | Cipher selection is automatic — fewer knobs to misset |
🎯 Key Takeaways
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Serving mixed content after enabling HTTPS — Symptom: The padlock disappears or shows a warning triangle; browser console shows 'Mixed Content: The page was loaded over HTTPS, but requested an insecure resource' — Fix: Audit all resource URLs (images, scripts, fonts, API calls) and change them to https://. Add 'Content-Security-Policy: upgrade-insecure-requests' as a fallback header, and use a relative protocol URL (//) or fully qualified https:// for all assets. Tools like 'Why No Padlock?' can scan your site automatically.
- ✕Mistake 2: Forgetting to renew certificates and causing a hard outage — Symptom: Users see 'Your connection is not private — NET::ERR_CERT_DATE_INVALID' and your site is completely inaccessible; Let's Encrypt certs expire in 90 days — Fix: Never manage cert renewal manually. Use Certbot with a systemd timer or cron (certbot renew runs daily and only renews when within 30 days of expiry). Set up monitoring: check expiry with 'openssl s_client' in a cron alert, or use a service like UptimeRobot that alerts you at 30 and 14 days before expiry.
- ✕Mistake 3: Trusting that HTTPS means the site is safe — Symptom: No error, just a valid padlock on a phishing site — Fix: HTTPS proves the connection to the server is encrypted and that the domain name in the certificate matches the URL. It says nothing about whether the owner of that domain is trustworthy. A phishing site can have a valid Let's Encrypt certificate for 'paypa1.com'. Always validate the full domain name, not just the padlock. Train users: the padlock means 'private channel', not 'safe website'.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.