• 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, then socket.authorized. If Node refused invalid certificates outright, an expired one would look identical to “no HTTPS at all”. Connecting anyway and reading authorizationError (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:

OutcomeMeaningScore
3xx to https://Correctfull points
200 over plain HTTPInsecure pages served0 + a finding
Connection refusedNothing listens on port 80 — nothing insecure is servedfull points
Timeout / other errorCan’t tellfull 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:

EndpointExpectedResult
expired.badssl.cominvalid certificate, expired✅ CERT_HAS_EXPIRED, expired
self-signed.badssl.cominvalid certificate✅ DEPTH_ZERO_SELF_SIGNED_CERT
wrong.host.badssl.cominvalid certificate✅ ERR_TLS_CERT_ALTNAME_INVALID
tls-v1-0.badssl.com:1010outdated TLS, not “broken”✅ TLSv1 (after the legacy retry)
tls-v1-1.badssl.com:1011outdated TLS✅ TLSv1.1
http.badssl.comno 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: false and read authorized / 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.