What is HTTPS/TLS — and Why Does Every Other Security Mechanism Depend On It?
HTTPS is HTTP running over TLS (Transport Layer Security) — the same request/response protocol, but every byte on the wire is encrypted, integrity-checked, and tied to a verified server identity before a single header is exchanged. "SSL" is the name everyone still uses out of habit; the actual protocol has been TLS since 1999, and every version of SSL itself (2.0, 3.0) is now considered broken and disabled by every modern browser and server.
This matters to every other page in this section. A password sent over
plain HTTP is readable by anyone on the network path. A JWT in an
Authorization header is a bearer token — whoever has the
bytes has the access, no password needed. A session cookie is exactly
the same. None of the authentication mechanisms covered elsewhere in
this Bible provide any protection on their own if the channel carrying
them is not encrypted — TLS is not one security feature among several,
it is the precondition that makes the others meaningful.
// BEFORE — checkout form posts over plain HTTP
@PostMapping("/checkout")
public ResponseEntity<OrderConfirmation> checkout(@RequestBody CheckoutRequest request) {
// The session cookie, the JWT in the Authorization header, and the card
// details in the request body all travel as plaintext bytes on the wire.
// Any router, ISP, or Wi-Fi eavesdropper between browser and server can
// read every one of them — no exploit required, just a packet capture.
return orderService.process(request);
}
// AFTER — the channel itself is the control, enforced at the security layer
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.requiresChannel(channel -> channel.anyRequest().requiresSecure())
.headers(headers -> headers.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31536000)))
.build();
// Now the same checkout endpoint rejects plain HTTP outright, and every
// subsequent visit is told by HSTS to never even attempt HTTP again.
}
The TLS Handshake — How Two Strangers Agree on a Secret
TLS solves a specific bootstrapping problem: the browser and the server have never spoken before and share no secret, yet they need to agree on a symmetric encryption key strong enough to protect an entire session — without an eavesdropper who sees every message being able to derive that same key. Asymmetric cryptography (the server's certificate key pair) solves the bootstrap; a symmetric cipher does the actual bulk encryption, because asymmetric operations are far too slow to encrypt every request and response.
/*
* TLS 1.3 handshake (1 round trip — the current standard, RFC 8446):
*
* Client → Server : ClientHello (supported cipher suites, a key share)
* Server → Client : ServerHello (chosen cipher suite, its own key share,
* its certificate, a "Finished" message)
* — both sides can now derive the same symmetric session key
* from the exchanged key shares (Diffie-Hellman)
* Client → Server : Finished — handshake complete, application data now flows,
* already encrypted with the session key
*
* TLS 1.2 needed 2 round trips for the equivalent negotiation, and permitted
* legacy cipher suites (RC4, static RSA key exchange with no forward secrecy)
* that TLS 1.3 removed outright. This is why "downgrade attacks" — tricking
* a client into negotiating TLS 1.0/1.1 or a weak cipher — were a real class
* of vulnerability against 1.2 deployments and are largely closed by 1.3
* refusing to offer the weak options at all.
*/
Modern TLS uses ephemeral Diffie-Hellman key exchange (ECDHE) — a fresh key pair is generated for every single handshake and discarded immediately after. Even if the server's long-term certificate private key is later compromised, an attacker who recorded encrypted traffic months ago still cannot decrypt it, because the actual session keys were never derived from — or recoverable from — that long-term key. This is why the older, static RSA key-exchange cipher suites (where the session key was derivable from the certificate's private key) are disabled by default in every current server and browser.
Certificates and the Chain of Trust
A TLS certificate (X.509 format) binds a public key to a domain name and is itself signed by a Certificate Authority (CA). The browser doesn't trust your certificate directly — it trusts a short list of root CAs baked into the OS or browser, and verifies a signature chain from your certificate up to one of those roots.
/*
* The chain, leaf to root:
*
* shop.example.com certificate ← the "leaf" cert, issued to your domain
* │ signed by
* ▼
* Intermediate CA certificate ← issued by the CA, rarely the root itself
* │ signed by
* ▼
* Root CA certificate ← pre-trusted, shipped with the OS/browser
*
* A server must send the leaf AND the intermediate certificate(s) — the root
* is never sent, since the client already has it. Forgetting to include the
* intermediate is one of the most common TLS misconfigurations: it works
* fine in browsers that cache the intermediate from a previous visit to any
* site using the same CA, and fails mysteriously for API clients, mobile
* apps, and curl, which don't have that cache.
*/
| Certificate type | Validation performed by CA | Typical use |
|---|---|---|
| DV (Domain Validated) | Proves control of the domain only (DNS record or HTTP file) | Default for most sites — Let's Encrypt issues DV certificates, free, auto-renewable |
| OV (Organization Validated) | Domain control plus verified legal business identity | Corporate sites wanting the organization name embedded in the cert |
| EV (Extended Validation) | Rigorous legal and operational identity checks | Largely obsolete — browsers stopped giving EV certs distinct UI treatment years ago, so the extra cost rarely buys anything today |
The encryption strength of a self-signed certificate is identical to
a CA-issued one — same algorithms, same key sizes. What's missing is
the chain of trust: nobody vouches that the public key actually
belongs to shop.example.com rather than to whoever is
currently sitting in the middle of the connection. Self-signed
certificates are fine for local development and internal
service-to-service traffic on a network you control (see mTLS
below); they are never appropriate for a public-facing endpoint,
because the browser's "not trusted" warning trains users to click
through security warnings — which is precisely the habit that makes
real phishing attacks against them succeed later.
Configuring HTTPS in Spring Boot
Two legitimate topologies exist, and conflating them is the source of most production TLS confusion.
Topology A — Spring Boot terminates TLS directly
# application.yml — the embedded Tomcat/Netty server holds the certificate
server:
port: 8443
ssl:
enabled: true
key-store: classpath:keystore.p12
key-store-password: ${KEYSTORE_PASSWORD} # never hardcode — inject via environment/secrets manager
key-store-type: PKCS12
key-alias: shop-example-com
protocol: TLS
enabled-protocols: TLSv1.3,TLSv1.2 # explicitly exclude TLS 1.0/1.1
Topology B — TLS terminates upstream (load balancer / reverse proxy)
## nginx / ALB / Cloudflare terminates HTTPS; the app receives plain HTTP
## on a private network segment the public internet cannot reach directly.
## This is the far more common production setup — certificate rotation,
## OCSP stapling, and cipher policy are centralized at the edge instead of
## being redeployed with every service.
# application.yml — tell Spring the original request was HTTPS even though
# the connection it actually received was plain HTTP from the load balancer
server:
forward-headers-strategy: framework # trusts X-Forwarded-Proto / X-Forwarded-For
Once the load balancer decrypts the request, the hop from load
balancer to application server is a separate connection — and if
that hop runs over plain HTTP because "it's all internal," it is
only as safe as the network it runs on. On a shared cloud VPC, a
compromised neighboring workload or a misconfigured security group
can put an attacker on that internal segment. Two acceptable fixes:
run the internal hop over TLS as well (re-encryption at the load
balancer), or use mutual TLS between internal
services so both ends authenticate each other, not just encrypt.
Also: server.forward-headers-strategy=framework means
Spring will trust an X-Forwarded-Proto header from
whatever sent the request — if that setting is enabled on a
server reachable directly from the public internet (bypassing the
load balancer), a client can forge X-Forwarded-Proto:
https and trick the app into treating an actually-insecure
connection as secure, silently defeating requiresSecure()
and Secure-flagged cookies. Only enable it on servers that are
network-unreachable except through the trusted proxy.
Enforcing HTTPS and HSTS at the application layer
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
// Reject any request that didn't arrive over HTTPS (or a trusted
// X-Forwarded-Proto: https from the load balancer)
.requiresChannel(channel -> channel.anyRequest().requiresSecure())
// HSTS tells the browser to never attempt plain HTTP for this domain
// again, for the given max-age — this closes the window where a
// user typing "example.com" gets an initial plaintext request
// that a network attacker could intercept before any redirect.
.headers(headers -> headers
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.preload(true)
.maxAgeInSeconds(31536000) // 1 year — the realistic minimum for HSTS preload list eligibility
)
)
.build();
}
}
Mutual TLS (mTLS) — When the Server Also Needs to Verify the Client
Standard TLS only authenticates the server to the client — the browser
verifies it's really talking to shop.example.com, but the
server accepts connections from anyone. mTLS adds a
second certificate check in the other direction: the client also
presents a certificate, and the server verifies it against a trusted CA
before accepting the connection. This is the right tool for
service-to-service traffic where both parties are known in advance —
the classic e-commerce example is your order service calling an
internal payment or inventory service, or a payment provider's webhook
endpoint verifying that a callback genuinely originated from the
provider's infrastructure and not from a forged request.
# application.yml — require and verify a client certificate
server:
ssl:
enabled: true
key-store: classpath:server-keystore.p12
key-store-password: ${KEYSTORE_PASSWORD}
trust-store: classpath:client-truststore.p12 # the CA that signs YOUR internal services' client certs
trust-store-password: ${TRUSTSTORE_PASSWORD}
client-auth: need # NEED = reject the connection outright without a valid client cert
A valid client certificate proves which service is calling,
not what that service is allowed to do. In a payment flow,
mTLS correctly establishes that the caller really is the internal
inventory-service and not an impostor on the network —
but the inventory service should still enforce its own authorization
rules on top (does this caller have permission to decrement stock
for this specific warehouse?). Treat the client certificate's
identity the same way you'd treat an authenticated principal
anywhere else in this section: it answers "who," not "what."
Common Misconfigurations
| Misconfiguration | Symptom | Fix |
|---|---|---|
| Missing intermediate certificate | Works in most browsers, fails in curl/mobile apps/some API clients | Serve the full chain (leaf + intermediate), not just the leaf |
| Mixed content | Page loads over HTTPS but an image/script/stylesheet is fetched via a hardcoded http:// URL — the browser blocks or warns |
Use protocol-relative or explicit https:// URLs for every asset, never hardcode http:// |
| Expired certificate | Full-page browser interstitial warning, all traffic effectively stops | Automate renewal (Let's Encrypt/certbot with a cron/systemd timer) and monitor expiry with alerting well before the 90-day window closes |
Trusting X-Forwarded-Proto from an untrusted network |
requiresSecure() and Secure cookies silently pass even over a real plaintext connection |
Only enable forward-headers-strategy on servers unreachable except via the trusted proxy |
| Legacy protocol/cipher still enabled | Passes casual testing, fails a security scan (SSLv3, TLS 1.0/1.1, RC4, static RSA key exchange) | Explicitly set enabled-protocols: TLSv1.3,TLSv1.2 and let the modern JDK/server defaults choose ciphers rather than hand-picking a legacy list |
Best Practices and Common Pitfalls
✅ Do
- Enforce HTTPS at the security layer (
requiresSecure()), don't rely on "we just don't link to the HTTP version" - Enable HSTS with a realistic
max-age(1 year) andincludeSubDomainsonce you're confident every subdomain also serves HTTPS - Automate certificate renewal and alert on expiry well in advance — a manual renewal process will eventually be forgotten
- Serve the full certificate chain (leaf + intermediate), and verify it with an external tool, not just your own browser's cache
- Use mTLS for internal service-to-service calls where both sides' identities matter, not just encryption
- Explicitly restrict to TLS 1.2/1.3 and let the platform's modern cipher defaults apply, rather than hand-maintaining a cipher list
❌ Don't
- Don't hardcode
http://in any asset URL on an HTTPS page — mixed content gets blocked and is a code smell that active-content injection was possible - Don't trust
X-Forwarded-Protofrom a server reachable directly from the public internet — it lets a client spoof "this connection was secure" over a plaintext request - Don't treat "TLS terminates at the load balancer" as "the data is protected end-to-end" — the internal hop is a separate connection with its own trust requirements
- Don't use a self-signed certificate on a public-facing endpoint — it trains users to click through trust warnings
- Don't assume mTLS replaces application-level authorization — a valid client certificate answers "who," never "what they're allowed to do"
Interview Questions
Q: What problem does TLS solve that plain HTTP doesn't?
Plain HTTP sends every byte — headers, cookies, tokens, request and
response bodies — as readable plaintext across every network hop
between client and server. TLS encrypts that traffic, verifies the
server's identity via its certificate, and detects tampering in transit.
Without it, any authentication mechanism carried over the connection —
a password, a session cookie, a JWT — is exposed to anyone who can
observe the network path.
Q: What is a certificate chain, and why does a server need to send more than just its own certificate?
A certificate chain links the server's certificate (the "leaf") up
through one or more intermediate CA certificates to a root CA that the
browser already trusts. The server must send the leaf and the
intermediate certificate(s) — the root is never sent, since the client
already has it pre-installed. Omitting the intermediate is a common
misconfiguration that happens to work in browsers that cached it from a
prior visit elsewhere, but fails for API clients that have no such
cache.
Q: What is HSTS and what attack does it protect against?
HTTP Strict Transport Security is a response header that tells the
browser to never attempt a plain HTTP connection to this domain again
for a specified duration. It protects against SSL-stripping attacks,
where a network attacker intercepts a user's very first plaintext
request (before any redirect to HTTPS can happen) and proxies the rest
of the session over HTTP while presenting HTTPS to the user.
Q: Your load balancer terminates TLS and forwards requests to application servers over plain HTTP inside the VPC. Is this acceptable, and what would make you change it?
It's a common and often acceptable topology, but it relies entirely on
the internal network being a genuine trust boundary — no untrusted
workloads can reach that segment. On shared infrastructure, a
compromised neighboring container, a misconfigured security group, or
an internal actor changes that calculus. I'd re-encrypt the internal
hop (TLS all the way to the application server) or adopt mTLS for
service-to-service traffic once the blast radius of a network compromise
includes systems I don't fully control — a payment or PII-handling
service is exactly where I'd stop treating "it's internal" as
sufficient.
Q: Explain why TLS 1.3 reduced the handshake to one round trip, and why downgrade attacks were a real risk against TLS 1.2 deployments.
TLS 1.3 requires the client to send its key share in the very first
message (ClientHello), so the server can respond with its
own key share, certificate, and a Finished message in a single reply —
both sides derive the shared secret from that one exchange. TLS 1.2
negotiated the cipher suite and key exchange method in a separate step
before deriving keys, and — critically — still offered legacy cipher
suites (RC4, static RSA key exchange with no forward secrecy) for
backward compatibility. An attacker positioned on the network path could
manipulate the negotiation to force both sides down to the weakest
mutually supported option. TLS 1.3 closes this by removing the weak
options from the protocol entirely rather than just discouraging their
use.
Q: A Spring Boot service sets server.forward-headers-strategy=framework so it can trust X-Forwarded-Proto from its load balancer. What goes wrong if that same service is also reachable directly, bypassing the load balancer?
Any client that reaches the service directly can forge an
X-Forwarded-Proto: https header on a genuinely plaintext
HTTP request. The framework will treat the connection as secure — Spring
Security's requiresSecure() check passes, cookies marked
Secure get set and sent over that "internally trusted"
plaintext connection, and the entire channel-security guarantee is
silently defeated. The fix isn't in application code at all: the
application server must be network-unreachable except through the
trusted proxy — a security group, firewall rule, or private subnet that
makes the direct path physically impossible, not just discouraged.