Web Security (OWASP Top 10:2025) Module 5 – A04:2025 Cryptographic Failures
Transport security: TLS, HSTS and mixed content
What TLS protects and what it leaves open, settings that hold, HSTS for the first request, mixed content, and clients that never skip certificate checks.
What you will learn
- Explain what TLS guarantees to one connection and what it leaves open
- Choose TLS versions, cipher suites and certificate practices that meet ASVS 5.0.0 chapter 12
- Check an HSTS header the way a browser reads it, and a domain against the preload rules
- Find mixed content in a page and fix it at the source
- Validate certificates in client code by trusting the right CA instead of switching verification off
Before you start
What you’ll learn · Before you start
What you will learn
- Explain what TLS guarantees to one connection and what it leaves open
- Choose TLS versions, cipher suites and certificate practices that meet ASVS 5.0.0 chapter 12
- Check an HSTS header the way a browser reads it, and a domain against the preload rules
- Find mixed content in a page and fix it at the source
- Validate certificates in client code by trusting the right CA instead of switching verification off
Before you start
On this page
A shop moved to HTTPS years ago, and every page shows the padlock. On café Wi-Fi, three gaps remain: a new
customer’s first visit still starts over plain HTTP, the checkout page loads one script from an http:// address,
and a nightly job calls the payment API with certificate checks switched off. Each gap is a connection without TLS, or
with its checks turned off: cleartext transmission (CWE-319, under A04:2025 Cryptographic Failures) and improper
certificate validation (CWE-295, under A07:2025 Authentication Failures).
You will see what TLS guarantees and what it leaves open, choose settings that hold, close the first request with a redirect and HSTS, find mixed content in a page, and watch eight TLS clients connect or refuse in a recorded run.
TLS gives one connection three guarantees, and only that one
Open https://shop.example/account and TLS: Transport Layer Security, the protocol under HTTPS. It gives one connection confidentiality, integrity and an authenticated server; TLS 1.2 and 1.3 are the versions still allowed, since RFC 8996 retired 1.0 and 1.1., the protocol under HTTPS, protects that one
connection. RFC 8446, which defines TLS 1.3, names what it provides: the server side is always authenticated, data
sent after the handshake is visible only to the two endpoints, and it cannot be changed without detection. So a café
network can carry the bytes but cannot read them, change them or pretend to be the shop.
Each guarantee rests on a check. The server proves that it holds the private key for a certificate naming
shop.example, and the browser follows its certificate chain: A server's certificate plus the intermediate certificates that link it to a root certificate authority the client already trusts. A client checks every signature in the chain, the dates and the host name before it sends anything. to a certificate
authority (CA) it already trusts; both sides agree on fresh keys for this connection; every record carries an
integrity tag. Step through one visit and watch what the network sees at each step:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Hello, with a name. The browser opens a TLS 1.3 connection and names the site it wants, shop.example, in its first message. That name and the server's address travel readable, so the network learns where you connect.
- A certificate and a key share. The server sends its certificate chain and its half of a fresh key exchange. Both sides now derive keys for this connection only, which are thrown away when it ends.
- Three checks. The browser checks that the chain ends at a CA it trusts, that the certificate is within its dates, and that it names shop.example. Only then does it send anything private.
- An encrypted request. The browser sends GET /account with its session cookie inside an encrypted record. The network sees how big the record is and when it was sent, but not a word of what it says.
- One byte changed. Someone on the path changes one byte of a record. Its integrity tag no longer matches, so the server rejects the record and ends the connection rather than act on altered data.
- What stays outside. TLS protected this connection only. The network still learned the name, the addresses and the sizes, the server still has to protect what it stores, and a client that skipped the three checks would have had none of this.
The last step is the one to keep: TLS protects data while it moves over this connection, and nothing else. It does not hide where you connect: the server’s address stays readable, and so does the host name in the first message, unless both ends support Encrypted Client Hello, which ASVS 5.0.0 asks for only at its highest level (12.1.5). RFC 8446 also says plainly that TLS does not hide the length of the data. It does nothing for the data once it has arrived: a database on the server holds it in the clear unless you protect it there, which the next lesson covers. And a client that skips the certificate checks gets none of the three guarantees, as the last section of this lesson shows.
Key idea
TLS gives one connection confidentiality, integrity and an authenticated server, and only while the client checks the certificate; the data before and after that connection needs protection of its own.
Checkpoint
Check: What TLS guarantees, and what it leaves open
Settings that hold: TLS 1.2 and 1.3, forward secrecy, automated certificates
Old versions of TLS are not a theoretical worry: RFC 8996 deprecated TLS 1.0 and 1.1 and says that neither may be used or negotiated. ASVS 5.0.0 requirement 12.1.1 asks that only current versions, such as 1.2 and 1.3, be enabled, with the newest preferred, and the A04:2025 prevention list asks for TLS 1.2 or newer with forward-secret cipher suites and no CBC mode. Walk through how the versions got here:
- SSL 3.0
Retired by RFC 7568 never enable
The last version of SSL. RFC 7568 deprecated it in 2015 and says it must not be used, and the OWASP TLS cheat sheet treats every SSL version as broken.
- TLS 1.0
RFC 2246, from 1999 never enable
The first version under the TLS name. RFC 8996 moved it to Historic status and says it must not be used or negotiated.
- TLS 1.1
RFC 4346, from 2006 never enable
A small revision of 1.0, retired by RFC 8996 together with it. Node.js clients refuse it with their default settings, as the run at the end of this lesson shows.
- TLS 1.2
RFC 5246, from 2008 allowed
Still allowed by ASVS 5.0.0 requirement 12.1.1. Keep it to forward-secret ECDHE key exchange with AEAD suites such as AES-GCM, and drop the CBC suites, as the A04:2025 text asks.
- TLS 1.3
RFC 8446, from 2018 preferred
Removed static RSA and Diffie-Hellman key exchange, so every public-key exchange is forward secret, and encrypts more of the handshake. Prefer it wherever both ends support it.
- RFC 8996
TLS 1.0 and 1.1 deprecated, 2021 rule
Best Current Practice 195 now forbids negotiating TLS 1.0 or 1.1 from any version, so a fallback to them is prohibited too. A server that still accepts them fails ASVS 12.1.1.
- Next
Post-quantum key exchange prepare
Hybrid groups such as X25519MLKEM768 combine a classical exchange with a post-quantum one. The A04:2025 text asks teams to prepare now so that high-risk systems are safe by the end of 2030.
The timeline has 7 events, from SSL 3.0 to Next.
Here is a configuration that meets those rules, one setting at a time:
| Setting | Use | Avoid | Source |
|---|---|---|---|
| Protocol versions | TLS 1.3, and TLS 1.2 where clients need it | SSL 3.0, TLS 1.0, TLS 1.1 | RFC 8996; ASVS 12.1.1 |
| Cipher suites | AEAD suites: AES-GCM, ChaCha20-Poly1305 | CBC, NULL, anonymous and export suites | A04:2025; OWASP TLS cheat sheet |
| Key exchange | ECDHE, such as X25519, and a hybrid post-quantum group where supported | Static RSA or Diffie-Hellman | RFC 8446; OWASP TLS cheat sheet |
| Certificates | Publicly trusted, every host name listed, renewed by ACME | Self-signed on public sites, renewal by hand | ASVS 12.2.2; RFC 8555 |
| Watching | Expiry alerts and Certificate Transparency logs | Hearing about it from users | RFC 6962 |
The key exchange row is forward secrecy: A property of a key exchange in which every connection uses fresh keys that are discarded afterwards, so a server key stolen later cannot decrypt traffic recorded earlier. Every public-key exchange in TLS 1.3 has it.: each connection uses fresh keys that are thrown away afterwards, so a server key stolen next year cannot decrypt traffic recorded today. TLS 1.3 made every public-key exchange forward secret by removing the static RSA and Diffie-Hellman suites. The OWASP cheat sheet adds a hybrid post-quantum group, X25519MLKEM768, which mixes a classical exchange with a post-quantum one, and the A04:2025 text asks teams to start preparing now, so that high-risk systems are safe by the end of 2030 at the latest.
Certificates now expire quickly on purpose. A CA/Browser Forum ballot adopted in 2025 shortens the longest validity of a publicly trusted TLS certificate from 398 days to 47 days, in steps that end in 2029, so renewal by hand stops being an option. ACME, RFC 8555, is the protocol that lets a server obtain and renew its certificates without anyone touching them. Two kinds of watching complete the setup: an alert well before any certificate expires, and a look at Certificate Transparency: Public, append-only logs of the TLS certificates that certificate authorities issue (RFC 6962). Watching them shows a certificate for your domain that you never asked for. logs, public records of the certificates CAs issue, where a certificate for your domain that you never asked for shows up.
SSL Certificate Checker Check a domain you run: certificate trust, the redirect to HTTPS, HSTS and preload status, mixed content and its certificates in CT logs.Checkpoint
Check: TLS versions, suites and certificates
The first request is the weak point, and HSTS closes it
The weakest moment of an HTTPS site is the request before HTTPS. A customer types shop.example into the address
bar, and a browser that has never visited the site sends http://shop.example/ in plain text. The server should
answer with a permanent redirect to https://shop.example/ on the same host, as RFC 6797 section 7.2 advises, but
that answer travels over plain HTTP as well, and hstspreload.org points out that someone on the path can rewrite the
redirect to keep the browser on plain HTTP. HSTS: HTTP Strict Transport Security: a response header that tells a browser to reach a host only over HTTPS for max-age seconds and to refuse certificate errors there without a click-through. Browsers ignore it when it arrives over plain HTTP. More in “HTTP security headers: a baseline that holds” closes the gap for every visit after the
first:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Typed without https. The customer types shop.example. A browser that has never seen the site sends http://shop.example/ in plain text, which anyone on the path can read and change.
- The redirect. The server answers with a permanent redirect to https://shop.example/, on the same host. That answer is plain HTTP too, so someone on the path could rewrite it and keep the browser on HTTP.
- HTTPS and the header. The browser follows the redirect over HTTPS. The response carries Strict-Transport-Security with max-age=31536000 and includeSubDomains, and the browser stores the policy for a year.
- The next visit. A week later the customer clicks an http:// link to the shop. The browser rewrites it to https:// before sending anything, so no plain request leaves the device.
- Errors are final. On a host with a stored policy, a certificate error ends the connection, and the browser offers no way to click through. A warning that people learn to dismiss is gone.
- Only the first visit was exposed. HSTS could not protect step 1, because the browser had not seen the header yet. The preload list builds the policy into browsers so that even the first visit uses HTTPS, at the cost of a slow way back off the list.
RFC 6797 sets the rules a browser follows. It ignores Strict-Transport-Security that arrives over plain HTTP, reads
only the first such header, requires max-age, and treats a directive that appears twice as an invalid header. On a
host it knows, it ends the connection at any certificate error, with no way to click through. hstspreload.org adds a
rule of its own for the list: every redirect served over HTTPS must carry the header itself. The program below reads eight headers
the way a browser does, then checks each one against the preload list’s submission rules:
// readHsts() reads a Strict-Transport-Security header the way RFC 6797 tells a browser to: only over HTTPS, max-age
// required, each directive at most once, values optionally quoted. preloadReady() then applies the submission rules
// of hstspreload.org to the policy and to two facts about the site: where its plain-HTTP redirect goes, and whether
// every subdomain answers over HTTPS.
function readHsts(value, overHttps) {
if (!overHttps) return { ok: false, why: 'sent over plain HTTP, so the browser ignores it' };
const seen = new Map();
for (const part of value.split(';')) {
const text = part.trim();
if (!text) continue;
const [rawName, ...rest] = text.split('=');
const name = rawName.trim().toLowerCase();
if (seen.has(name)) return { ok: false, why: `${name} appears twice, so the header is invalid` };
seen.set(name, rest.length ? rest.join('=').trim().replace(/^"(.*)"$/, '$1') : true);
}
const maxAge = seen.get('max-age');
if (maxAge === undefined) return { ok: false, why: 'no max-age, which the header requires' };
if (!/^\d+$/.test(String(maxAge))) return { ok: false, why: `max-age "${maxAge}" is not a number of seconds` };
return { ok: true, maxAge: Number(maxAge), subdomains: seen.has('includesubdomains'), preload: seen.has('preload') };
}
function preloadReady(policy, site) {
const missing = [];
if (!site.redirectsToHttpsSameHost) missing.push('redirect to HTTPS on the same host');
if (!site.allSubdomainsHttps) missing.push('HTTPS on every subdomain');
if (policy.maxAge < 31536000) missing.push('max-age of a year or more');
if (!policy.subdomains) missing.push('includeSubDomains');
if (!policy.preload) missing.push('preload');
return missing;
}
const duration = (s) => (s < 3600 ? `${s / 60} minutes` : s < 86400 ? `${s / 3600} hours` : `${Math.round(s / 86400)} days`);
const ready = { redirectsToHttpsSameHost: true, allSubdomainsHttps: true };
const responses = [
['the baseline', 'max-age=31536000; includeSubDomains', true, ready],
['asking to be preloaded', 'max-age=63072000; includeSubDomains; preload', true, ready],
['the same, over plain HTTP', 'max-age=63072000; includeSubDomains; preload', false, ready],
['first stage, quoted value', 'max-age="300"; includeSubDomains', true, ready],
['no max-age', 'includeSubDomains; preload', true, ready],
['two max-age directives', 'max-age=31536000; max-age=0', true, ready],
['switching the policy off', 'max-age=0', true, ready],
['redirect goes to www first', 'max-age=63072000; includeSubDomains; preload', true, { ...ready, redirectsToHttpsSameHost: false }],
];
for (const [name, value, overHttps, site] of responses) {
const policy = readHsts(value, overHttps);
console.log(`${name}: ${value}`);
if (!policy.ok) {
console.log(` ignored: ${policy.why}`);
continue;
}
if (policy.maxAge === 0) {
console.log(' removes the policy the browser stored for this host');
continue;
}
const scope = policy.subdomains ? 'this host and every subdomain' : 'this host only';
console.log(` HTTPS only for ${duration(policy.maxAge)}, ${scope}`);
const missing = preloadReady(policy, site);
console.log(` preload: ${missing.length ? `not ready, missing ${missing.join(', ')}` : 'meets every rule'}`);
} Output
the baseline: max-age=31536000; includeSubDomains HTTPS only for 365 days, this host and every subdomain preload: not ready, missing preload asking to be preloaded: max-age=63072000; includeSubDomains; preload HTTPS only for 730 days, this host and every subdomain preload: meets every rule the same, over plain HTTP: max-age=63072000; includeSubDomains; preload ignored: sent over plain HTTP, so the browser ignores it first stage, quoted value: max-age="300"; includeSubDomains HTTPS only for 5 minutes, this host and every subdomain preload: not ready, missing max-age of a year or more, preload no max-age: includeSubDomains; preload ignored: no max-age, which the header requires two max-age directives: max-age=31536000; max-age=0 ignored: max-age appears twice, so the header is invalid switching the policy off: max-age=0 removes the policy the browser stored for this host redirect goes to www first: max-age=63072000; includeSubDomains; preload HTTPS only for 730 days, this host and every subdomain preload: not ready, missing redirect to HTTPS on the same host
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node hsts_check.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Three of the eight do nothing at all: the header sent over plain HTTP, the one without max-age and the one with
max-age twice. max-age=0 is not a mistake but the off switch: it removes the policy the browser stored. Only one
header meets every preload rule, and the last case shows a rule that catches teams out: a plain-HTTP redirect that
goes straight to www.shop.example skips the HTTPS response of the base domain, which is where the header must be
seen. The rollout itself, from five minutes up to a year, is in the security headers lesson, and the
HSTS preload list: A list of domains built into browsers that are reached only over HTTPS, even on the first visit. Joining needs a year of max-age, includeSubDomains and preload, and leaving it reaches users only through browser updates. More in “HTTP security headers: a baseline that holds” stays a separate decision, because its maintainers recommend HSTS but no
longer recommend preloading.
One directive
Press Edit, add ; preload to the baseline header in the first case and run it again. The preload line now says it
meets every rule, because the baseline already has a year of max-age and includeSubDomains.
Checkpoint
Check: The first request and HSTS
One http: script can undo an HTTPS page
A padlock says the page itself arrived over TLS. It promises nothing about what the page loads next. MDN calls those
requests mixed content: Parts of an HTTPS page, such as scripts, stylesheets, images or frames, that the page asks the browser to load over plain HTTP. Browsers upgrade images, audio and video to HTTPS and block the rest.: parts of an HTTPS page that are fetched over plain HTTP, where
someone on the path can read or change them. A script is the worst case, because it can change anything on the page.
Current browsers sort these requests into two groups: images, audio and video are upgraded to https by the browser,
while scripts, stylesheets, frames, objects and images listed in srcset are blocked. The scanner below lists the
http: addresses of one product page and what a browser does with each:
// findMixedContent() lists the http: addresses an HTTPS page asks the browser to load, and what a current browser does
// with each, following MDN's two groups: images, audio and video are upgraded to https (except from an IP address,
// which cannot be upgraded and is blocked); scripts, stylesheets, frames, objects and srcset images are blocked. A
// link is not mixed content, but it leaves HTTPS when clicked. The scan reads plain HTML with regular expressions,
// which is enough for a first inventory of your own templates, not a parser for HTML in general.
const UPGRADABLE = new Set(['img src', 'audio src', 'video src', 'source src']);
const BLOCKABLE = new Set(['script src', 'link href', 'iframe src', 'object data', 'img srcset']);
function findMixedContent(html) {
const found = [];
for (const [, tag, attrs] of html.matchAll(/<([a-z]+)\b([^>]*)>/gi)) {
for (const [, name, value] of attrs.matchAll(/([a-z-]+)\s*=\s*"([^"]*)"/gi)) {
const where = `${tag.toLowerCase()} ${name.toLowerCase()}`;
const urls = name.toLowerCase() === 'srcset' ? value.split(',').map((c) => c.trim().split(/\s+/)[0]) : [value];
for (const url of urls.filter((u) => /^http:\/\//i.test(u))) {
const host = /^http:\/\/([^/:?#]+)/i.exec(url)[1];
let action = 'not a request the page makes';
if (where === 'a href') action = 'not mixed content: a link that leaves HTTPS';
else if (UPGRADABLE.has(where)) action = /^[\d.]+$|^\[/.test(host) ? 'blocked: an IP address is not upgraded' : 'upgraded to https';
else if (BLOCKABLE.has(where)) action = 'blocked';
found.push({ where, url, action });
}
}
}
return found;
}
const page = `
<link rel="stylesheet" href="http://cdn.shop.example/site.css">
<script src="http://cdn.shop.example/cart.js"></script>
<script src="https://cdn.shop.example/search.js"></script>
<img src="http://cdn.shop.example/logo.png" alt="Shop logo">
<img src="http://192.0.2.10/banner.png" alt="Sale banner">
<img src="https://cdn.shop.example/hero.png" srcset="http://cdn.shop.example/hero-2x.png 2x" alt="Tea gardens">
<video src="http://media.shop.example/intro.mp4"></video>
<iframe src="http://reviews.example/widget"></iframe>
<a href="http://blog.shop.example/">Our blog</a>`;
const found = findMixedContent(page);
for (const f of found) console.log(`${f.where.padEnd(11)}${f.url.padEnd(38)}${f.action}`);
const count = (prefix) => found.filter((f) => f.action.startsWith(prefix)).length;
console.log(`\n${count('upgraded')} upgraded, ${count('blocked')} blocked, ${count('not')} link`);
console.log(`With upgrade-insecure-requests, all ${count('upgraded') + count('blocked')} requests go out over https; fix the templates anyway.`); Output
link href http://cdn.shop.example/site.css blocked script src http://cdn.shop.example/cart.js blocked img src http://cdn.shop.example/logo.png upgraded to https img src http://192.0.2.10/banner.png blocked: an IP address is not upgraded img srcset http://cdn.shop.example/hero-2x.png blocked video src http://media.shop.example/intro.mp4 upgraded to https iframe src http://reviews.example/widget blocked a href http://blog.shop.example/ not mixed content: a link that leaves HTTPS 2 upgraded, 5 blocked, 1 link With upgrade-insecure-requests, all 7 requests go out over https; fix the templates anyway.
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node mixed_content.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Five of the seven requests are blocked, so the page loads without its stylesheet, its cart script, a banner, the large hero image and the reviews frame: in current browsers, mixed content mostly shows up as a broken page. The banner is blocked although it is an image, because its host is an IP address, which browsers do not upgrade. The link to the blog is not mixed content at all; it simply leaves HTTPS when clicked.
| Fix | When to use it |
|---|---|
Change http:// to https://, or to a relative address, where the link is written |
Always: templates, stored HTML, configuration |
Send Content-Security-Policy: upgrade-insecure-requests |
While the sources are being fixed: the browser requests every subresource over HTTPS |
| Serve the file from your own HTTPS host, or drop it | When the other server has no HTTPS |
Mark every cookie Secure |
Always: a cookie without it travels with any plain-HTTP request, the first one included |
The last row is the cookie form of the same mistake. A session cookie without Secure goes out with any plain-HTTP
request to the site, such as the very first request before the redirect, and the cookies lesson covers Secure and
the __Host- prefix in full.
Checkpoint
Check: Mixed content and Secure cookies
A client that skips certificate checks trusts anyone
Browsers check certificates without being asked. Code you write is a TLS client too: the job that calls a payment
API, a service that sends webhooks, a script that runs curl. There the checks can be switched off with one option,
and teams do it when a certificate from a private CA fails: rejectUnauthorized: false or
NODE_TLS_REJECT_UNAUTHORIZED=0 in Node.js, verify=False in Python’s Requests, curl -k. Node’s documentation
calls the variable insecure and strongly discourages it, and the Requests documentation says that with verify=False
the client accepts any certificate. A client checks three things before it sends a byte:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Default client, lab CA. The real server's certificate was signed by the lab CA, which is not on Node's bundled list of public CAs. The chain check fails, and the client refuses, line 1 of the run.
- Verification off. With rejectUnauthorized set to false the client skips every check and sends its request to whoever answered. Lines 2 and 3 connected to the real server and to the impostor alike.
- Trust the lab CA. Giving the client the lab CA with the ca option makes the chain check pass for certificates that CA signed. The dates are fine and the name is shop.example, so line 4 connects with every check on.
- The impostor. The impostor's certificate names shop.example too, but it is signed by itself, not by the lab CA. The chain check fails, and line 5 refuses.
- The wrong name. A client that asked for api.example gets a certificate that names only shop.example. The chain and the dates pass, the name check fails, and line 6 refuses.
- An expired certificate. A certificate signed by the lab CA whose last valid day has passed fails the dates check, line 7. Checking the chain alone would have let it through.
The program below makes a lab CA, a server certificate signed by it, an impostor’s self-signed certificate and an expired one, all in memory for each run, then connects eight clients through an in-memory pair of streams. The second tab is the helper that builds the certificates:
JavaScript (tls_checks.mjs) · handshake/tls_checks.mjs
// Eight TLS connections to a server for shop.example, each with client settings a team might write. Every server
// certificate is made in memory for this run (certs.mjs), and client and server talk through an in-memory pair of
// streams, so nothing touches the network. Each line says whether the client connected or refused, and why.
import { duplexPair } from 'node:stream';
import tls from 'node:tls';
import { makeCert } from './certs.mjs';
const labCa = makeCert({ subject: 'Lab Test CA', ca: true });
const real = makeCert({ subject: 'shop.example', issuer: labCa, hosts: ['shop.example'] });
const impostor = makeCert({ subject: 'shop.example', hosts: ['shop.example'] }); // signed by itself, not by the lab CA
const expired = makeCert({ subject: 'shop.example', issuer: labCa, hosts: ['shop.example'], validFromDays: -60, validToDays: -1 });
function connect(server, clientOptions) {
return new Promise((done) => {
const [serverSide, clientSide] = duplexPair();
const context = tls.createSecureContext({ key: server.key, cert: server.cert });
new tls.TLSSocket(serverSide, { isServer: true, secureContext: context }).on('error', () => {});
const client = tls.connect({ socket: clientSide, servername: 'shop.example', ...clientOptions });
client.on('secureConnect', () => {
done(`connected over ${client.getProtocol()}, verified: ${client.authorized ? 'yes' : 'no'}`);
client.destroy();
});
client.on('error', (e) => done(`refused: ${e.code}`));
});
}
const cases = [
['default client, real server', real, {}],
['verification off, real server', real, { rejectUnauthorized: false }],
['verification off, impostor', impostor, { rejectUnauthorized: false }],
['lab CA trusted, real server', real, { ca: [labCa.cert] }],
['lab CA trusted, impostor', impostor, { ca: [labCa.cert] }],
['lab CA trusted, asked for api.example', real, { ca: [labCa.cert], servername: 'api.example' }],
['lab CA trusted, expired certificate', expired, { ca: [labCa.cert] }],
['lab CA trusted, client stuck on TLS 1.1', real, { ca: [labCa.cert], minVersion: 'TLSv1', maxVersion: 'TLSv1.1' }],
];
for (const [i, [name, server, options]] of cases.entries()) {
console.log(`${i + 1}. ${name.padEnd(42)}${await connect(server, options)}`);
} Output
1. default client, real server refused: UNABLE_TO_VERIFY_LEAF_SIGNATURE 2. verification off, real server connected over TLSv1.3, verified: no 3. verification off, impostor connected over TLSv1.3, verified: no 4. lab CA trusted, real server connected over TLSv1.3, verified: yes 5. lab CA trusted, impostor refused: DEPTH_ZERO_SELF_SIGNED_CERT 6. lab CA trusted, asked for api.example refused: ERR_TLS_CERT_ALTNAME_INVALID 7. lab CA trusted, expired certificate refused: CERT_HAS_EXPIRED 8. lab CA trusted, client stuck on TLS 1.1 refused: ERR_SSL_NO_PROTOCOLS_AVAILABLE
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node tls_checks.mjs
JavaScript (certs.mjs) · handshake/certs.mjs
// makeCert(): a throwaway test certificate, built in memory for one run of tls_checks.mjs and never stored. It writes
// the few X.509 fields a TLS client checks (issuer, validity dates, subject, the names it covers, whether it is a CA)
// in ASN.1 DER and signs them with an ECDSA P-256 key. Real certificates come from a CA through ACME; this exists only
// so the lab needs no key file on disk.
import { generateKeyPairSync, randomBytes, sign } from 'node:crypto';
const len = (n) => (n < 128 ? [n] : n < 256 ? [0x81, n] : [0x82, n >> 8, n & 255]);
const tlv = (tag, ...parts) => {
const body = Buffer.concat(parts);
return Buffer.concat([Buffer.from([tag, ...len(body.length)]), body]);
};
const seq = (...parts) => tlv(0x30, ...parts);
const oid = (dotted) => {
const [a, b, ...rest] = dotted.split('.').map(Number);
const bytes = [40 * a + b];
for (let v of rest) {
const chunk = [v & 127];
while ((v = Math.floor(v / 128))) chunk.unshift((v & 127) | 128);
bytes.push(...chunk);
}
return tlv(0x06, Buffer.from(bytes));
};
const integer = (buf) => tlv(0x02, buf[0] & 0x80 ? Buffer.concat([Buffer.from([0]), buf]) : buf);
const utcTime = (date) => tlv(0x17, Buffer.from(`${date.toISOString().replace(/[-:T]/g, '').slice(2, 14)}Z`));
const commonName = (cn) => seq(tlv(0x31, seq(oid('2.5.4.3'), tlv(0x0c, Buffer.from(cn)))));
const ECDSA_WITH_SHA256 = seq(oid('1.2.840.10045.4.3.2'));
const DAY = 86_400_000;
export function makeCert({ subject, issuer = null, hosts = [], ca = false, validFromDays = -1, validToDays = 30 }) {
const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
const extensions = [];
if (ca) extensions.push(seq(oid('2.5.29.19'), tlv(0x01, Buffer.from([0xff])), tlv(0x04, seq(tlv(0x01, Buffer.from([0xff]))))));
if (hosts.length) extensions.push(seq(oid('2.5.29.17'), tlv(0x04, seq(...hosts.map((h) => tlv(0x82, Buffer.from(h)))))));
const serial = randomBytes(8);
serial[0] &= 0x7f;
const now = Date.now();
const tbs = seq(
tlv(0xa0, integer(Buffer.from([2]))),
integer(serial),
ECDSA_WITH_SHA256,
commonName(issuer ? issuer.subject : subject),
seq(utcTime(new Date(now + validFromDays * DAY)), utcTime(new Date(now + validToDays * DAY))),
commonName(subject),
publicKey.export({ type: 'spki', format: 'der' }),
...(extensions.length ? [tlv(0xa3, seq(...extensions))] : []),
);
const signature = sign('sha256', tbs, issuer ? issuer.privateKey : privateKey);
const der = seq(tbs, ECDSA_WITH_SHA256, tlv(0x03, Buffer.from([0]), signature));
const cert = `-----BEGIN CERTIFICATE-----\n${der.toString('base64').match(/.{1,64}/g).join('\n')}\n-----END CERTIFICATE-----\n`;
return { subject, cert, privateKey, key: privateKey.export({ type: 'pkcs8', format: 'pem' }) };
} The default client refused the real server, because Node trusts its bundled list of public CAs and the lab CA is not
on it. Switching verification off made the connection work, and line 3 shows the price: the same client connected to
the impostor just as readily, and an impostor is exactly what someone on the path would present. The fix is line 4:
give the client the CA it should trust through the ca option, which replaces the default list for that connection,
and every check stays on. Lines 5 to 7 are those checks doing their work, and line 8 is a client stuck on TLS 1.1,
which Node’s defaults refuse.
Switching verification off to fix a private CA
Wrong: rejectUnauthorized: false, verify=False, curl -k, or NODE_TLS_REJECT_UNAUTHORIZED=0 in a deployment
file. Right: hand that one client the CA it should trust, with ca in Node.js, verify="/path/to/ca.pem" in
Requests or --cacert in curl. The wrong version connects to any server that answers; the right one connects only
to servers the CA vouched for.
The same holds inside your own network. ASVS 5.0.0 asks for TLS on internal connections as well (12.3.1 and 12.3.3), for clients that validate certificates (12.3.2), and, for internally issued certificates, for callers that trust only those internal CAs (12.3.4). A network you own is still a network that others can reach.
Key idea
A TLS client is only as safe as its checks: trust the right CA for each connection instead of switching verification off, and an impostor is refused like any other wrong certificate.
Checkpoint
Check: Certificate checks in your own clients
Practise: an HSTS policy and a TLS client that checks
Write three functions: the Strict-Transport-Security value for a policy, the response a server sends over plain
HTTP and over HTTPS, and the options a team’s settings turn into for a TLS client. The sample tests run in your
browser:
Exercise · Easy · JavaScript
An HSTS policy, a redirect and a TLS client that checks
Three functions in transport.mjs decide how a small shop uses TLS. The starter gets each one wrong. Fix all three:
hstsValue(policy)returns the value of theStrict-Transport-Securityheader for{ maxAge, includeSubDomains, preload }:max-age=<seconds>, then; includeSubDomainswhen it is set, then; preloadwhen it is set. It throws anErrorwhenmaxAgeis not a whole number of seconds from 0 up, and whenpreloadis asked for withoutincludeSubDomainsor with amaxAgeunder 31536000 (one year).respond(request, policy)answers a request{ scheme, host, path }, wherepathincludes any query string. Over plain HTTP it returns{ status: 301, headers: { Location: 'https://' + host + path } }, a redirect on the same host and noStrict-Transport-Securityheader. Over HTTPS it returns{ status: 200, headers: { 'Strict-Transport-Security': hstsValue(policy) } }.clientOptions(settings)turns a team's settings{ host, ca, minVersion, allowInvalid }into options for Node'stls.connect:{ host, servername: host, minVersion, rejectUnauthorized: true }, withminVersion'TLSv1.2'when the settings give none, andca: [settings.ca]only when the settings name a CA certificate. It throws anErrorwhenallowInvalidis true and whenminVersionis'TLSv1'or'TLSv1.1': the fix for a private CA is to trust that CA, never to switch the checks off.
Starter code · transport.mjs
// Starter: each function has the mistake a team might make. Fix all three.
export function hstsValue(policy) {
return `max-age=${policy.maxAge}`;
}
export function respond(request, policy) {
return { status: 200, headers: { 'Strict-Transport-Security': hstsValue(policy) } };
}
export function clientOptions(settings) {
return { host: settings.host, rejectUnauthorized: !settings.allowInvalid };
} The sample tests · transport.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { clientOptions, hstsValue, respond } from './transport.mjs';
const year = { maxAge: 31536000, includeSubDomains: true };
test('the header names max-age and the directives that are set', () => {
assert.equal(hstsValue({ maxAge: 300 }), 'max-age=300');
assert.equal(hstsValue(year), 'max-age=31536000; includeSubDomains');
assert.equal(hstsValue({ maxAge: 0 }), 'max-age=0', 'zero is the off switch, and valid');
});
test('max-age must be a whole number of seconds', () => {
assert.throws(() => hstsValue({ maxAge: -1 }), undefined, 'a negative number');
assert.throws(() => hstsValue({ maxAge: 1.5 }), undefined, 'a fraction');
assert.throws(() => hstsValue({ maxAge: '300' }), undefined, 'a string');
});
test('preload needs a year of max-age and includeSubDomains', () => {
assert.equal(hstsValue({ ...year, preload: true }), 'max-age=31536000; includeSubDomains; preload', 'exactly one year is enough');
assert.equal(hstsValue({ maxAge: 63072000, includeSubDomains: true, preload: true }), 'max-age=63072000; includeSubDomains; preload');
assert.throws(() => hstsValue({ maxAge: 31535999, includeSubDomains: true, preload: true }), undefined, 'a second short of a year');
assert.throws(() => hstsValue({ maxAge: 31536000, preload: true }), undefined, 'without includeSubDomains');
});
test('plain HTTP gets a redirect on the same host, and no HSTS header', () => {
const r = respond({ scheme: 'http', host: 'shop.example', path: '/cart?id=7' }, year);
assert.equal(r.status, 301);
assert.equal(r.headers.Location, 'https://shop.example/cart?id=7');
assert.equal(r.headers['Strict-Transport-Security'], undefined, 'browsers ignore it over plain HTTP');
});
test('HTTPS responses carry the header', () => {
const r = respond({ scheme: 'https', host: 'shop.example', path: '/' }, year);
assert.equal(r.status, 200);
assert.equal(r.headers['Strict-Transport-Security'], 'max-age=31536000; includeSubDomains');
});
test('client options keep every check on and trust the named CA', () => {
assert.deepEqual(clientOptions({ host: 'api.partner.example' }), { host: 'api.partner.example', servername: 'api.partner.example', minVersion: 'TLSv1.2', rejectUnauthorized: true });
const withCa = clientOptions({ host: 'api.partner.example', ca: 'PARTNER-CA-PEM', minVersion: 'TLSv1.3' });
assert.deepEqual(withCa, { host: 'api.partner.example', servername: 'api.partner.example', minVersion: 'TLSv1.3', rejectUnauthorized: true, ca: ['PARTNER-CA-PEM'] });
assert.throws(() => clientOptions({ host: 'api.partner.example', allowInvalid: true }), undefined, 'never switch the checks off');
assert.throws(() => clientOptions({ host: 'api.partner.example', minVersion: 'TLSv1.1' }), undefined, 'TLS 1.1 is deprecated');
assert.throws(() => clientOptions({ host: 'api.partner.example', minVersion: 'TLSv1' }), undefined, 'TLS 1.0 is deprecated');
}); A hint
Check the policy before you build the string: Number.isInteger(maxAge) && maxAge >= 0 first, then the two preload rules. In respond, decide on the scheme first, so the plain-HTTP answer never reaches the line that adds the header.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Finish with the self-check below. It starts with the transport idea where your answers are weakest and moves on as they come out right:
Practice that adapts to you
Practise this lesson
10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what does HTTPS protect, and what does it leave open?
HTTPS is HTTP over TLS, and TLS gives one connection three guarantees: the server is authenticated by a certificate that chains to a trusted CA and names the host, the data is readable only by the two endpoints, and any change on the way is detected. It leaves several things open. The server’s address and the host name in the first message are visible, and so are the size and timing of the traffic. It protects nothing before the connection, such as a first visit over plain HTTP, which is why sites redirect and send HSTS, and nothing after it: data stored on the server needs its own protection. And a client that switches certificate checks off loses all three guarantees, because it will talk to an impostor as readily as to the real server.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to senior
A partner's API presents a certificate from the partner's own private CA. A teammate made the integration work by setting rejectUnauthorized to false. What do you change, and how do you stop it from coming back?
-
Mid to senior
A content site with 4,000 articles moved to HTTPS, and the browser console shows mixed content on many pages, mostly images and embeds written into the stored article HTML. How do you fix it, and what do you do while the fix runs?
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What does TLS guarantee for one connection, and what does it not cover?
The server is authenticated, data sent after the handshake is visible only to the two endpoints, and it cannot be changed without detection (RFC 8446). It does not hide the server's address or the host name in the first message unless both ends use Encrypted Client Hello, it does not hide the length of the data, and it does nothing for data before or after the connection.
-
Which TLS settings hold today?
Versions: TLS 1.3, and 1.2 where clients need it (RFC 8996 deprecated 1.0 and 1.1; ASVS 5.0.0 12.1.1). Ciphers: AEAD suites such as AES-GCM and ChaCha20-Poly1305, never CBC, NULL, anonymous or export suites. Key exchange: ECDHE such as X25519, with a hybrid post-quantum group where supported, never static RSA. Certificates: publicly trusted, every host name listed, renewed by ACME, with expiry alerts and Certificate Transparency monitoring.
-
What is forward secrecy?
Each connection uses fresh keys that are thrown away afterwards, so a server key stolen next year cannot decrypt traffic recorded today. TLS 1.3 made every public-key exchange forward secret by removing the static RSA and Diffie-Hellman suites.
-
Why is renewing TLS certificates by hand ending?
A CA/Browser Forum ballot adopted in 2025 shortens the longest validity of a publicly trusted TLS certificate from 398 days to 47 days, in steps that end in 2029. ACME (RFC 8555) lets a server obtain and renew certificates without anyone touching them. Alert well before expiry, and watch Certificate Transparency logs for certificates you never asked for.
-
Why is the first plain-HTTP request the weak point, and what does HSTS do about it?
A browser that has never visited sends http://shop.example/ in plain text, and someone on the path can rewrite the redirect to keep it on HTTP. After one HTTPS response with Strict-Transport-Security, the browser goes straight to HTTPS and ends the connection at any certificate error, with no click-through. Redirect on the same host, and send the header on every HTTPS response.
-
Which HSTS headers does a browser ignore, and what is max-age=0?
It ignores the header over plain HTTP, reads only the first such header, requires max-age, and treats a directive that appears twice as an invalid header. max-age=0 is the off switch that removes the stored policy. For the preload list every redirect served over HTTPS must carry the header too, so a redirect from the base domain straight to www skips the response where it must be seen.
-
What is mixed content, and how do you fix it?
Parts of an HTTPS page fetched over plain HTTP. Current browsers upgrade images, audio and video (except on an IP address) and block scripts, stylesheets, frames, objects and srcset images, so it mostly shows up as a broken page. Fix the link where it is written (https, or a relative address); upgrade-insecure-requests helps meanwhile; serve the file yourself or drop it; and mark every cookie Secure.
-
What do you do when a client must trust a private CA, and why not switch verification off?
Give that one client the CA it should trust (ca in Node.js, verify="/path/to/ca.pem" in Requests, --cacert in curl) and leave every check on. rejectUnauthorized: false, verify=False, curl -k and NODE_TLS_REJECT_UNAUTHORIZED=0 connect to any server that answers, an impostor included. ASVS 5.0.0 asks for TLS and validating clients on internal connections too (12.3.1 to 12.3.4).
Key takeaways
- TLS gives one connection confidentiality, integrity and an authenticated server; host names, sizes and the data at either end stay outside it.
- Enable only TLS 1.2 and 1.3 with AEAD suites and forward-secret key exchange, and let ACME renew certificates whose lifetimes are falling to 47 days.
- The first plain-HTTP request is the weak point: redirect it on the same host, send HSTS on every HTTPS response, and treat preloading as a separate decision.
- Mixed content is fixed where links are written;
upgrade-insecure-requestshelps while that happens, and every cookie needsSecure. - A client that switches certificate checks off connects to an impostor too; trust the right CA for that connection and keep every check on.
Terms in this lesson
- certificate chain
- A server's certificate plus the intermediate certificates that link it to a root certificate authority the client already trusts. A client checks every signature in the chain, the dates and the host name before it sends anything.
- Certificate Transparency
- Public, append-only logs of the TLS certificates that certificate authorities issue (RFC 6962). Watching them shows a certificate for your domain that you never asked for.
- forward secrecy
- A property of a key exchange in which every connection uses fresh keys that are discarded afterwards, so a server key stolen later cannot decrypt traffic recorded earlier. Every public-key exchange in TLS 1.3 has it.
- mixed content
- Parts of an HTTPS page, such as scripts, stylesheets, images or frames, that the page asks the browser to load over plain HTTP. Browsers upgrade images, audio and video to HTTPS and block the rest.
- TLS
- Transport Layer Security, the protocol under HTTPS. It gives one connection confidentiality, integrity and an authenticated server; TLS 1.2 and 1.3 are the versions still allowed, since RFC 8996 retired 1.0 and 1.1.
References
- A04:2025 Cryptographic Failures (OWASP Foundation)
- A07:2025 Authentication Failures (CWE-295 and CWE-297) (OWASP Foundation)
- RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3 (IETF (RFC Editor))
- RFC 8996: Deprecating TLS 1.0 and TLS 1.1 (IETF (RFC Editor))
- RFC 5246: The Transport Layer Security (TLS) Protocol Version 1.2 (IETF (RFC Editor))
- RFC 7568: Deprecating Secure Sockets Layer Version 3.0 (IETF (RFC Editor))
- RFC 6797: HTTP Strict Transport Security (HSTS) (IETF (RFC Editor))
- HSTS Preload List Submission (Chromium project (hstspreload.org))
- ASVS 5.0.0, V12 Secure Communication (OWASP Foundation)
- Transport Layer Security Cheat Sheet (OWASP Foundation)
- Mixed content (MDN Web Docs (Mozilla))
- CSP: upgrade-insecure-requests (MDN Web Docs (Mozilla))
- Strict-Transport-Security header (MDN Web Docs (Mozilla))
- TLS (SSL), Node.js v24 documentation (OpenJS Foundation (Node.js))
- Command-line API: NODE_TLS_REJECT_UNAUTHORIZED, Node.js v24 documentation (OpenJS Foundation (Node.js))
- Requests: SSL Cert Verification (Requests project (Python Software Foundation))
- curl man page (--insecure, --cacert) (curl project)
- RFC 8555: Automatic Certificate Management Environment (ACME) (IETF (RFC Editor))
- RFC 6962: Certificate Transparency (IETF (RFC Editor))
- Ballot SC-081v3: Introduce schedule of reducing validity and data reuse periods (CA/Browser Forum)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress