Skip to content
Huseyin Babal
Go back

HTTPS from the terminal: eleven checks with curl and openssl

The browser shows a padlock and hides the rest. curl and openssl show all of it: the protocol version, the key exchange, the certificates, and how many milliseconds each part takes. Below are eleven checks, each one a command and the output I got.

Setup: macOS 26.6, Homebrew curl 8.13 and OpenSSL 3.6. The curl that ships with macOS is built against a different TLS library and did not give me TLS 1.3 details, so install the Homebrew one and put it first in PATH:

brew install curl openssl@3
export PATH="$(brew --prefix curl)/bin:$(brew --prefix openssl@3)/bin:$PATH"

Most output was captured on 2 October 2026. Google rotates certificates and servers, so your dates, addresses and timings will differ.

The checks, in one table

QuestionWhat I saw
Which TLS version and key exchange does Google use?TLS 1.3, AES-256-GCM, X25519MLKEM768 (post-quantum hybrid)
What does the same server pick with TLS 1.2?Plain X25519, no post-quantum part
Who signed google.com’s certificate?WR2, signed by GTS Root R1, cross-signed by GlobalSign Root CA
How long is the certificate valid?84 days
Which names does it cover?google.com, youtube.com, youtu.be, android.com, goo.gl and more
Can I sign my own certificate?Yes, in four openssl commands
What does curl say about it?Error 60 until I pass my CA with --cacert
What do broken certificates look like?Three different error 60 messages on badssl.com
Is TLS 1.3 faster?About 14 to 18 ms against about 22 to 24 ms for TLS 1.2
Where do the 107 ms of one request go?Mostly into waiting for the first byte
Does a second request pay for the handshake again?No, curl reuses the connection

Check 1: one real connection

curl -v prints the handshake details on stderr. grep keeps the lines that matter.

curl -v -o /dev/null https://www.google.com 2>&1 | grep -E 'ALPN|SSL connection|^> (GET|Host)|^< HTTP'
* ALPN: curl offers h2,http/1.1
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519MLKEM768 / id-ecPublicKey
* ALPN: server accepted h2
> GET / HTTP/2
> Host: www.google.com
< HTTP/2 200 output

The SSL connection line has four parts. TLSv1.3 is the protocol. TLS_AES_256_GCM_SHA384 is the cipher suite that encrypts the data. X25519MLKEM768 is the key exchange: classic X25519 and ML-KEM-768 (NIST FIPS 203) combined, so an attacker has to break both. id-ecPublicKey means the certificate holds an elliptic-curve key.

The ALPN lines are the negotiation of the HTTP version, done inside the TLS handshake: curl offers HTTP/2 and HTTP/1.1, Google picks h2.

openssl s_client reports the same connection with different labels:

openssl s_client -connect google.com:443 -servername google.com </dev/null 2>/dev/null \
  | grep -E 'Protocol|Cipher is|Negotiated TLS1.3 group|Peer signature type'
Peer signature type: ecdsa_secp256r1_sha256
Negotiated TLS1.3 group: X25519MLKEM768
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Protocol: TLSv1.3output

Peer signature type is how the server proved it owns the certificate’s private key: an ECDSA signature on the P-256 curve.

Check 2: the same server over TLS 1.2

Force TLS 1.2 and ask openssl which temporary key the server used.

openssl s_client -connect google.com:443 -servername google.com -tls1_2 </dev/null 2>&1 \
  | grep -E 'Protocol|Cipher is|Peer Temp Key'
New, TLSv1.2, Cipher is ECDHE-ECDSA-CHACHA20-POLY1305
Protocol: TLSv1.2
Peer Temp Key: X25519, 253 bitsoutput

Same server, same day (16 October), and the key exchange is plain X25519. In my tests the post-quantum hybrid only appeared on TLS 1.3 connections. The TLS 1.2 cipher name also spells out the whole recipe in one string: ECDHE key exchange, ECDSA signature, ChaCha20-Poly1305 for the data. TLS 1.3 moved the key exchange and signature out of the cipher suite name, which is why its names are shorter.

Check 3: the certificate chain

-showcerts prints every certificate the server sends. s: is the subject, i: the issuer.

openssl s_client -connect google.com:443 -servername google.com -showcerts </dev/null 2>/dev/null \
  | grep -E '^ *[0-9] s:|^ *i:'
 0 s:CN=*.google.com
   i:C=US, O=Google Trust Services, CN=WR2
 1 s:C=US, O=Google Trust Services, CN=WR2
   i:C=US, O=Google Trust Services LLC, CN=GTS Root R1
 2 s:C=US, O=Google Trust Services LLC, CN=GTS Root R1
   i:C=BE, O=GlobalSign nv-sa, OU=Root CA, CN=GlobalSign Root CAoutput

Read it as links: each issuer on one line is the subject of the next certificate. Certificate 2 is a version of Google’s root signed by GlobalSign, an older root. A client whose trust store already has GTS Root R1 stops at certificate 1. A client that only has GlobalSign can still finish the path through certificate 2.

GlobalSign Root CA itself is not in the list. A root is trusted because the client already has it in its own trust store, so a server has no reason to send it.

Check 4: the dates

openssl s_client -connect google.com:443 -servername google.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates
subject=CN=*.google.com
issuer=C=US, O=Google Trust Services, CN=WR2
notBefore=Sep 10 19:21:53 2026 GMT
notAfter=Dec  3 19:21:52 2026 GMToutput

84 days. When I ran it again on 16 October I got a different certificate, valid from 18 September to 11 December: also 84 days, and also issued by WR2. Two weeks apart, the same name was served with two different certificates.

Public certificates are getting shorter for everyone. Under CA/Browser Forum ballot SC-081 the cap went from 398 to 200 days in March 2026. It drops to 100 days in March 2027 and to 47 in March 2029. Renewing by hand stops being practical well before the end of that schedule, which is why ACME clients such as certbot exist.

For a monitoring script, -checkend answers “does this expire within N seconds?” and sets the exit code:

openssl s_client -connect google.com:443 -servername google.com </dev/null 2>/dev/null \
  | openssl x509 -noout -enddate -checkend 2592000
notAfter=Dec 11 20:00:10 2026 GMT
Certificate will not expireoutput

2592000 is 30 days. Exit code 0 means more than 30 days are left, 1 means it expires sooner.

Check 5: which names the certificate covers

The Subject Alternative Name extension is the list browsers actually check. The common name (CN=) is ignored for matching by modern clients.

openssl s_client -connect google.com:443 -servername google.com </dev/null 2>/dev/null \
  | openssl x509 -noout -ext subjectAltName | tr ',' '\n' \
  | grep -E 'DNS:(google\.com|youtube\.com|android\.com|goo\.gl|youtu\.be)$'
 DNS:android.com
 DNS:goo.gl
 DNS:google.com
 DNS:youtu.be
 DNS:youtube.comoutput

The grep keeps five names to keep the output short. Without it, the certificate I got on 16 October lists 65 names.

Check 6: a certificate authority in four commands

Create a root key and a self-signed root certificate, then a key and a signing request for the server, then sign it. P-256 keys keep everything small.

mkdir tls-demo && cd tls-demo

openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
  -keyout ca.key -out ca.crt -days 365 -subj "/CN=Demo Root CA"

openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
  -keyout server.key -out server.csr -subj "/CN=localhost"

echo "subjectAltName = DNS:localhost" > san.ext

openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
  -days 30 -extfile san.ext -out server.crt

openssl verify -CAfile ca.crt server.crt
Certificate request self-signature ok
subject=CN=localhost
server.crt: OKoutput

san.ext is the step people skip. Without it the certificate has only CN=localhost, and curl and browsers reject it for a name mismatch even though the chain is fine.

Check 7: curl and an unknown CA

Serve the certificate with openssl’s built-in test server and call it.

openssl s_server -accept 4443 -cert server.crt -key server.key -www &
curl -sS https://localhost:4443/
curl: (60) SSL certificate problem: unable to get local issuer certificateoutput

The chain is valid, but it ends at a root curl does not have. Pass the root explicitly:

curl --cacert ca.crt -s -o /dev/null -w '%{http_code}  verify=%{ssl_verify_result}\n' https://localhost:4443/
kill %1
200  verify=0output

ssl_verify_result 0 means no verification error. --cacert replaces the trust store for this one call. Adding ca.crt to the system keychain would make every program on the machine trust it, which is the same thing company proxies do with their own root.

Check 8: three broken certificates

badssl.com keeps broken certificates online for testing clients.

curl -sS https://expired.badssl.com/
curl -sS https://wrong.host.badssl.com/
curl -sS https://self-signed.badssl.com/
curl: (60) SSL certificate problem: certificate has expired
curl: (60) SSL: no alternative certificate subject name matches target hostname 'wrong.host.badssl.com'
curl: (60) SSL certificate problem: self-signed certificateoutput

All three are exit code 60, so a script that only checks the exit code cannot tell them apart. The message can: expired dates, a missing SAN entry, or a chain that ends at a certificate nobody trusts. -k would make all three “work”, and it disables exactly the check that failed.

Check 9: TLS 1.2 against TLS 1.3

curl’s time_connect is when TCP was ready and time_appconnect is when TLS finished, both counted from the start, so subtracting one from the other gives the handshake time.

for i in 1 2 3; do curl --tls-max 1.2 -s -o /dev/null -w 'TLS 1.2  tcp=%{time_connect}  tls=%{time_appconnect}\n' https://www.google.com; done
for i in 1 2 3; do curl --tlsv1.3   -s -o /dev/null -w 'TLS 1.3  tcp=%{time_connect}  tls=%{time_appconnect}\n' https://www.google.com; done
TLS 1.2  tcp=0.011102  tls=0.117446
TLS 1.2  tcp=0.010225  tls=0.034364
TLS 1.2  tcp=0.010840  tls=0.032984
TLS 1.3  tcp=0.009499  tls=0.023786
TLS 1.3  tcp=0.010548  tls=0.028259
TLS 1.3  tcp=0.011232  tls=0.026321output
RunTLS 1.2 handshakeTLS 1.3 handshake
1106.3 ms (network hiccup, ignored)14.3 ms
224.1 ms17.7 ms
322.1 ms15.1 ms

TCP took about 10 ms, which is one round trip to this Google server. TLS 1.2 needs about two round trips before data can flow, TLS 1.3 about one, and the numbers line up with that. Three runs are a small sample, so run it ten times on your own network before quoting a number.

Check 10: where the time of one request goes

dig google.com | grep -E 'IN\s+A\s|Query time'
curl -s -o /dev/null -w 'dns=%{time_namelookup}  tcp=%{time_connect}  tls=%{time_appconnect}  first-byte=%{time_starttransfer}  total=%{time_total}\n' https://www.google.com
google.com.		72	IN	A	172.217.171.110
;; Query time: 4 msec
dns=0.002775  tcp=0.012095  tls=0.028537  first-byte=0.086848  total=0.107017output
StepEnds atTakes
DNS lookup2.8 ms2.8 ms
TCP handshake12.1 ms9.3 ms
TLS handshake28.5 ms16.4 ms
Waiting for the first byte86.8 ms58.3 ms
Rest of the response107.0 ms20.2 ms

DNS was fast because the answer was cached nearby (the 72 in the dig output is the remaining TTL in seconds). The biggest slice is the wait between sending the request and getting the first byte, which is mostly the server building the page. Connection setup, DNS through TLS, is under 30 ms of the 107.

The response also tells the client about HTTP/3:

curl -sI https://www.google.com | grep -i alt-svc
alt-svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000output

h3=":443" means HTTP/3 over QUIC on UDP port 443, and ma=2592000 says the hint is valid for 30 days. My Homebrew curl is built without HTTP/3 support, so it stays on HTTP/2; a browser would switch on its next connection.

Check 11: the second request

Give curl the same URL twice in one call. It keeps the connection open between them.

curl -s -o /dev/null -o /dev/null \
  -w 'tcp=%{time_connect}  tls=%{time_appconnect}  new-connections=%{num_connects}\n' \
  https://www.google.com https://www.google.com
tcp=0.009827  tls=0.025541  new-connections=1
tcp=0.000000  tls=0.000000  new-connections=0output

The second request opened no new connection, so it paid nothing for TCP or TLS. This is why keep-alive and connection pools matter more than handshake tuning for most services: a pooled client pays the 25 ms once, not on every call.

What I would take from this

Sources


Share this post:

Previous Post
Postgres vs MariaDB on two dedicated CPUs: the setup, the settings and every run
Next Post
One index, 467x faster: reading a PostgreSQL B-tree from the inside