- Jun 6, 2026
- 5 min read
Checking HTTPS Like a Visitor: Certificates, Redirects and TLS Versions in Node
A security score that only counts response headers misses the problems a
visitor actually notices. Nobody sees a missing Referrer-Policy. Everybody
sees a full-page browser warning because a certificate expired last Tuesday.
So the security part of my audit tool
(webaudit.jalcocertech.com) checks the
transport first, the way a browser would: is there HTTPS, is the certificate
valid, how long until it expires, which TLS version, and does plain HTTP send
you to HTTPS. All of it is one small Node script, scripts/tls-check.mjs,
that prints JSON for the shell script that scores security.
The TLS handshake
Node’s tls module gives everything needed in one connection:
const socket = tls.connect({
host: hostname,
port: tlsPort,
servername: hostname, // SNI: which certificate the server should present
rejectUnauthorized: false, // connect anyway, then inspect
timeout: 8000,
}, () => {
const cert = socket.getPeerCertificate();
const validTo = cert?.valid_to ? new Date(cert.valid_to) : null;
resolve({
https: true,
certValid: socket.authorized,
certError: socket.authorized ? null : String(socket.authorizationError || 'unknown'),
daysLeft: validTo ? Math.floor((validTo - Date.now()) / 86_400_000) : null,
tlsVersion: socket.getProtocol(),
});
socket.end();
});
Three details matter:
servername(SNI). Most sites share an IP with other sites. Without SNI the server may present a default certificate for a different name, and the check would report a mismatch that a browser never sees.rejectUnauthorized: false, thensocket.authorized. If Node refused invalid certificates outright, an expired one would look identical to “no HTTPS at all”. Connecting anyway and readingauthorizationError(CERT_HAS_EXPIRED,DEPTH_ZERO_SELF_SIGNED_CERT,ERR_TLS_CERT_ALTNAME_INVALID…) tells them apart.- The URL’s own port. Most sites use 443, but if the submitted URL says
https://example.com:8443, that’s the port a browser would use.
Old TLS: identify it, don’t call it broken
Modern Node refuses TLS 1.0 and 1.1 by default. A server that only speaks those versions fails the handshake, and my first version reported it as “HTTPS does not work”. That’s wrong in an embarrassing way: the site does load in some browsers, so the owner would rightly distrust the whole report.
The fix is a second attempt that allows legacy versions, used only to find out what the server speaks:
const extra = legacy ? { minVersion: 'TLSv1', ciphers: 'DEFAULT:@SECLEVEL=0' } : {};
let t = await probeTls(host, port);
if (!t.https && t.error !== 'timeout' && t.error !== 'ECONNREFUSED' && t.error !== 'ENOTFOUND') {
const legacy = await probeTls(host, port, true);
if (legacy.https) t = legacy;
}
Now the finding is precise: “The server negotiates TLSv1.1, which is outdated.” Timeouts, refused connections and unknown hostnames skip the retry: those really are “no HTTPS here”.
The redirect check
Plain http:// should send visitors to https://. The probe asks without
following redirects:
const res = await fetch(`http://${hostname}/`, { redirect: 'manual', signal: AbortSignal.timeout(8000) });
const location = res.headers.get('location') || '';
return { status: res.status, redirectsToHttps: res.status >= 300 && res.status < 400 && location.startsWith('https://') };
The interesting part is what happens when the request fails:
| Outcome | Meaning | Score |
|---|---|---|
3xx to https:// | Correct | full points |
| 200 over plain HTTP | Insecure pages served | 0 + a finding |
| Connection refused | Nothing listens on port 80 — nothing insecure is served | full points |
| Timeout / other error | Can’t tell | full points, marked unknown |
The last row is a deliberate choice. A timeout can be my network, their firewall or a blip. Penalising it would mean accusing a site of something I didn’t observe — and in a sales tool, one false accusation costs more credibility than a missed point.
The decision logic
flowchart TD
A[Submitted URL] --> B[TLS handshake with SNI on the URL port]
B -->|connected| C{Certificate authorized?}
B -->|refused / timeout / unknown host| N[no_https: high]
B -->|other handshake error| L[Retry allowing TLS 1.0 and 1.1]
L -->|connects| C
L -->|fails| N
C -->|no| I[invalid_certificate: high]
C -->|yes| E{Days to expiry}
E -->|below 0| X[certificate_expired: high]
E -->|0 to 14| W[certificate_expiring: high]
E -->|more than 14| V{TLS version}
V -->|1.0 or 1.1| O[outdated_tls: med]
V -->|1.2 or 1.3| OK[transport fine]
A --> H[GET http:// without following redirects]
H -->|3xx to https| HR[ok]
H -->|200 over http| NR[no_https_redirect: med]
H -->|refused| HR
H -->|timeout| U[unknown, not penalised]
60 points, and issues with severities
Transport is worth 60 of the 100 security points; response headers are the other 40:
const points = {
httpsValid: t.https && t.certValid ? 30 : 0,
redirect: r.redirectsToHttps === false ? 0 : 15,
expiry: t.daysLeft !== null && t.daysLeft !== undefined && t.daysLeft > 14 ? 10 : 0,
tlsVersion: ['TLSv1.2', 'TLSv1.3'].includes(t.tlsVersion) ? 5 : 0,
};
Alongside the points, the script emits structured issues — { id, msg, severity } — so the result screen can rank them with the SEO and trust
findings: an expiring certificate is high, a missing redirect is
medium. It also emits a longer sentence per finding for reports, like
“The certificate expires in 6 day(s) — the site will show a security warning
if it isn’t renewed.”
Testing against badssl.com
badssl.com hosts deliberately broken endpoints, which makes it ideal for this:
| Endpoint | Expected | Result |
|---|---|---|
expired.badssl.com | invalid certificate, expired | ✅ CERT_HAS_EXPIRED, expired |
self-signed.badssl.com | invalid certificate | ✅ DEPTH_ZERO_SELF_SIGNED_CERT |
wrong.host.badssl.com | invalid certificate | ✅ ERR_TLS_CERT_ALTNAME_INVALID |
tls-v1-0.badssl.com:1010 | outdated TLS, not “broken” | ✅ TLSv1 (after the legacy retry) |
tls-v1-1.badssl.com:1011 | outdated TLS | ✅ TLSv1.1 |
http.badssl.com | no redirect | ✅ no_https_redirect |
The TLS 1.0 and 1.1 rows only passed after two fixes the tests exposed: using the URL’s port instead of always 443, and the legacy retry. Without a set of known-bad endpoints I’d have shipped both bugs.
Takeaways
- Check what visitors see first (certificate, expiry, redirect) and headers second.
- Connect with
rejectUnauthorized: falseand readauthorized/authorizationError, so “invalid” and “absent” stay distinct. - Always send SNI and respect the URL’s port.
- Retry with legacy TLS only to name the problem, never to score it as fine.
- Don’t penalise what you couldn’t observe. Unknown is not a failure.
- Test against endpoints that are broken on purpose.