Applied Cryptography for Developers Module 12 – TLS 1.3 and certificates
Using TLS safely in Node.js and Python
Configure TLS in Node.js and Python with verification on, read certificate errors by code, set up mutual TLS and apply current server guidance.
What you will learn
- Configure TLS clients and servers in Node.js and Python with verification on
- Diagnose certificate errors from their codes without disabling verification
- Set up mutual TLS with a private CA and keep authentication apart from authorization
- Apply current server guidance for protocol versions, key-exchange groups and HSTS
Before you start
What you’ll learn · Before you start
What you will learn
- Configure TLS clients and servers in Node.js and Python with verification on
- Diagnose certificate errors from their codes without disabling verification
- Set up mutual TLS with a private CA and keep authentication apart from authorization
- Apply current server guidance for protocol versions, key-exchange groups and HSTS
Before you start
On this page
A payment API works from a developer’s laptop only after someone sets rejectUnauthorized: false, and a Python worker works only with verify=False. Both lines ship. Using TLS safely in Node.js and Python starts by refusing that trade. The defaults of both languages already verify the server, and every error they raise names a cause that can be fixed without switching anything off.
This lesson reads the defaults of each language, diagnoses certificate errors from their codes, sets up mutual TLS with a private CA, and applies current guidance for versions, groups and HSTS. Every program runs on certificates made for the lesson, over connections held in memory, so no network is involved.
Both languages verify by default, and the switches that turn it off are the bug
Using TLS safely in Node.js means leaving rejectUnauthorized at its default, which is true. Then tls.connect checks the server’s chain against the CAs it was given and checks the name against servername, the Server Name Indication: The TLS extension in which a client names the host it wants, so that one address can serve several certificates. The same name is the one the client checks the certificate against. that the client also sends. Node.js keeps the protocol range from tls.DEFAULT_MIN_VERSION to tls.DEFAULT_MAX_VERSION. In Python, ssl.create_default_context() requires a certificate, checks the host name and sets a TLS 1.2 minimum. The two languages spell it differently and agree in substance. The program connects to an impostor, a server that holds a certificate for the right name from a CA the client has not been told to trust, once with the defaults and once with verification off:
tls/defaults.mjs
// defaults.mjs: what Node.js does with no options, and what switching verification off does. The impostor is a server that holds a
// certificate for the right name from a CA the client has never been told to trust.
import tls from 'node:tls';
import { session } from './pipe.mjs';
console.log(`defaults: minimum ${tls.DEFAULT_MIN_VERSION}, maximum ${tls.DEFAULT_MAX_VERSION}`);
const impostor = { serverChain: 'rogue_chain.pem', serverKey: 'rogue.key' };
const strict = await session(impostor);
console.log(`client with the defaults: ${strict.clientError ? `refused, ${strict.clientError}` : `connected, reply ${strict.reply}`}`);
const lax = await session({ ...impostor, client: { rejectUnauthorized: false } });
console.log(`client with rejectUnauthorized false: ${lax.clientError ? `refused, ${lax.clientError}` : `connected, reply ${lax.reply}`}`);
console.log(`with verification off the socket still records authorized ${lax.socket.authorized} (${lax.socket.authorizationError}), and the connection carried on`);
const honest = await session();
console.log(`client of the real server: connected, reply ${honest.reply}, authorized ${honest.socket.authorized}`); Output
defaults: minimum TLSv1.2, maximum TLSv1.3 client with the defaults: refused, SELF_SIGNED_CERT_IN_CHAIN client with rejectUnauthorized false: connected, reply pong with verification off the socket still records authorized false (SELF_SIGNED_CERT_IN_CHAIN), and the connection carried on client of the real server: connected, reply pong, authorized true
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node defaults.mjs
tls/pipe.mjs
// pipe.mjs: a TLS client and a TLS server that talk through memory, so the examples need no network. The server is a real
// tls.Server that is handed a connection by hand, which the Node.js documentation allows for any Duplex stream, and the client
// is tls.connect over the other end of the pipe. The certificates are in ./pki, made by make_pki.sh.
import tls from 'node:tls';
import { Duplex, PassThrough } from 'node:stream';
import { readFileSync } from 'node:fs';
export const read = (file) => readFileSync(new URL(`./pki/${file}`, import.meta.url));
// The identity a service gives itself: the URI entry of the subject alternative names, as in a SPIFFE-style name.
export const identityOf = (peer) => /URI:([^,\s]+)/.exec(peer.subjectaltname ?? '')?.[1];
function pipe() {
const toServer = new PassThrough();
const toClient = new PassThrough();
return [Duplex.from({ readable: toClient, writable: toServer }), Duplex.from({ readable: toServer, writable: toClient })];
}
// One connection. The client writes its request as soon as it thinks it is connected, and the server answers with whatever
// answer() returns. Under TLS 1.3 a client can finish its handshake before the server has judged the client's certificate, so
// only a reply shows that the connection was accepted.
export function session({ client = {}, server = {}, serverChain = 'server_chain.pem', serverKey = 'server.key', request = 'ping', answer = () => 'pong' } = {}) {
const [clientEnd, serverEnd] = pipe();
const result = { reply: null, clientError: null, serverVerdict: null, socket: null };
const listener = tls.createServer({ key: read(serverKey), cert: read(serverChain), ...server }, (socket) => {
const peer = socket.getPeerCertificate();
const named = Object.keys(peer).length > 0 ? (identityOf(peer) ?? peer.subject.CN) : null;
result.serverVerdict = socket.authorized ? `authorized as ${named}` : `not authorized (${named ? socket.authorizationError : 'no certificate presented'})`;
socket.on('data', (data) => socket.write(answer(socket, String(data))));
socket.on('error', () => {});
});
listener.on('tlsClientError', (error, socket) => {
result.serverVerdict = `refused (${error.code === 'ABORT_ERR' ? socket.authorizationError : error.code})`;
});
listener.emit('connection', serverEnd);
return new Promise((resolve) => {
const socket = tls.connect({ socket: clientEnd, servername: 'orders.example.test', ca: read('root.pem'), ...client });
result.socket = socket;
let done = false;
const finish = () => {
if (done) return;
done = true;
setImmediate(() => {
const outcome = { ...result };
socket.destroy();
listener.close();
resolve(outcome);
});
};
socket.on('secureConnect', () => socket.write(request));
socket.on('data', (data) => {
if (done) return;
result.reply = String(data);
result.negotiated = { protocol: socket.getProtocol(), cipher: socket.getCipher().name, group: socket.getEphemeralKeyInfo().name };
finish();
});
socket.on('error', (error) => {
if (done) return;
result.clientError = error.code;
result.clientMessage = error.message;
finish();
});
socket.on('close', finish);
});
} The defaults refuse the impostor with SELF_SIGNED_CERT_IN_CHAIN. With verification off the connection goes on, and the impostor receives whatever the client sends. Python does the same, and since Python 3.13 its defaults also apply strict X.509 checks:
tls/defaults.py
# defaults.py: what Python's ssl module does by default, and what switching verification off does. The impostor is a server that holds
# a certificate for the right name from a CA the client has not been told to trust.
import ssl
from membio import connect
ctx = ssl.create_default_context()
print("ssl.create_default_context():")
print(f" verify_mode {ctx.verify_mode.name}, check_hostname {ctx.check_hostname}, minimum_version {ctx.minimum_version.name}")
print(f" strict X.509 checks {bool(ctx.verify_flags & ssl.VERIFY_X509_STRICT)}, partial chains {bool(ctx.verify_flags & ssl.VERIFY_X509_PARTIAL_CHAIN)}")
impostor = dict(chain="rogue_chain.pem", key="rogue.key")
print()
try:
connect(**impostor)
print("impostor, client from create_default_context plus the CA: connected")
except ssl.SSLCertVerificationError as error:
print(f"impostor, client from create_default_context plus the CA: refused, {error.verify_message} ({error.verify_code})")
off = ssl.create_default_context()
off.check_hostname = False
off.verify_mode = ssl.CERT_NONE
session = connect(client=off, **impostor)
print(f"impostor, client with check_hostname False and CERT_NONE: connected, {session.version()}") Output
ssl.create_default_context(): verify_mode CERT_REQUIRED, check_hostname True, minimum_version TLSv1_2 strict X.509 checks True, partial chains True impostor, client from create_default_context plus the CA: refused, self-signed certificate in certificate chain (19) impostor, client with check_hostname False and CERT_NONE: connected, TLSv1.3
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 defaults.py
tls/membio.py
# membio.py: a TLS client and a TLS server in one process, talking through memory buffers instead of a network. connect() makes one
# handshake between a client context and a server that holds a certificate chain from ./pki (made by make_pki.sh), and it raises
# what a real socket would raise.
import ssl
from pathlib import Path
PKI = Path(__file__).parent / "pki"
def trusting_context():
"""The client context a careful program builds: the defaults, plus the root of the private CA."""
return ssl.create_default_context(cafile=str(PKI / "root.pem"))
def connect(chain="server_chain.pem", key="server.key", name="orders.example.test", client=None):
server_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
server_ctx.load_cert_chain(PKI / chain, PKI / key)
client_ctx = client or trusting_context()
c_in, c_out, s_in, s_out = (ssl.MemoryBIO() for _ in range(4))
client_side = client_ctx.wrap_bio(c_in, c_out, server_hostname=name)
server_side = server_ctx.wrap_bio(s_in, s_out, server_side=True)
pending = {"client": client_side, "server": server_side}
for _ in range(10):
for side in list(pending):
try:
pending[side].do_handshake()
del pending[side]
except ssl.SSLWantReadError:
pass
s_in.write(c_out.read())
c_in.write(s_out.read())
if not pending:
return client_side
raise RuntimeError("the handshake did not finish") | What is switched off | Node.js | Python |
|---|---|---|
| Chain and time checks | rejectUnauthorized: false, or the variable NODE_TLS_REJECT_UNAUTHORIZED=0 |
verify_mode = ssl.CERT_NONE, ssl._create_unverified_context(), or verify=False in requests |
| Name check | a checkServerIdentity that returns nothing |
check_hostname = False |
| Protocol floor | minVersion: 'TLSv1' |
minimum_version = ssl.TLSVersion.TLSv1 |
The three rows are three separate decisions. The first stops a client from checking who signed the certificate and whether it is valid, and the second lets a certificate that a trusted CA issued for one name serve another. A client with the first switch off is open to an impostor, as the programs showed, and one with only the second off is open to anyone who holds a certificate from a CA it trusts. The third lets a connection fall to versions that RFC 8996 deprecated.
Never ship the switch
Node.js documents the variable as making TLS, and HTTPS by extension, insecure. A test that needs a private CA should trust that CA explicitly and leave verification on.
Checkpoint
Check: Defaults and the switches that turn them off
Flashcards · 4 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What do the TLS defaults of Node.js and Python verify?
Node.js leaves rejectUnauthorized true, so tls.connect checks the server's chain against the CAs it was given and the name against servername, which is also sent as Server Name Indication. Python's ssl.create_default_context() requires a certificate, checks the host name and sets a TLS 1.2 minimum; since Python 3.13 its defaults also apply strict X.509 checks.
-
What happens when a client with verification off meets an impostor?
The impostor holds a certificate for the right name from a CA the client has not been told to trust. The defaults refuse it with SELF_SIGNED_CERT_IN_CHAIN; with verification off the connection goes on, and the impostor receives whatever the client sends.
-
Which settings switch off TLS checks in Node.js and Python?
Chain and time checks: Node.js rejectUnauthorized: false or NODE_TLS_REJECT_UNAUTHORIZED=0; Python verify_mode = ssl.CERT_NONE, ssl._create_unverified_context() or verify=False in requests. Name check: a checkServerIdentity that returns nothing, or check_hostname = False. Protocol floor: minVersion: 'TLSv1', or minimum_version = ssl.TLSVersion.TLSv1.
-
Why are the chain, name and version switches three separate decisions?
Without chain checks a client is open to an impostor. Without the name check it is open to anyone who holds a certificate from a CA it trusts, issued for another name. A low protocol floor lets a connection fall to versions that RFC 8996 deprecated. A test that needs a private CA should trust that CA explicitly and leave verification on.
Read a certificate error by its code, then fix its cause Pro: opens with a Pro, Premium or Ultimate pass
In this part:
- Python reports the same causes as verify codes
- Mutual TLS authenticates the caller, and the handler still decides
- Server settings: versions, groups, HSTS and what stays off
- Practise: lint the options of a TLS client
- Lint the options of a TLS client, and check an HSTS header
- Interview questions
- A teammate fixes a certificate error in staging with rejectUnauthorized set to false in Node.js, or verify=False in Python, and says it is only staging. Why is that not a fix, and what do you do instead?
- Forty internal services will call each other over mutual TLS. Design it: the CAs, the certificate lifetimes, where the identity goes, how a service decides what a caller may do, and how keys and certificates are rotated.
- After a partner rotates its certificate, your Node.js service logs UNABLE_TO_VERIFY_LEAF_SIGNATURE while your Python worker logs CERTIFICATE_VERIFY_FAILED with "unable to get local issuer certificate". Browsers open the partner's site. Triage it.
- You own the TLS settings of a public edge in front of a web application. Choose the protocol versions, the key-exchange groups, HSTS, and your position on Encrypted Client Hello and on key logging, and explain how you check that the settings are what you chose.
- Key takeaways
2 diagrams · 4 runnable examples (JavaScript, Python) · 1 exercise with sample tests · 3 interview answers · about 31 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Terms in this lesson
- certificate pinning
- Configuring a client to accept only a particular certificate or public key for a server, instead of any that a trusted CA could issue. It needs a backup key and a way to update.
- Encrypted Client Hello
- A TLS extension (RFC 9849) that encrypts the ClientHello, which hides the requested server name and the protocol list from observers on the network path.
- HTTP Strict Transport Security
- A response header (RFC 6797) that tells a browser to use only HTTPS for a name for a stated time, and not to let users click through certificate errors for it.
- hybrid key exchange
- A key exchange that combines a classical group with a post-quantum scheme, such as X25519MLKEM768 in TLS 1.3, so that breaking the classical part alone does not reveal the secret.
- Server Name Indication
- The TLS extension in which a client names the host it wants, so that one address can serve several certificates. The same name is the one the client checks the certificate against.
- TLS key log
- A file in which an endpoint records the secrets of its TLS sessions (the SSLKEYLOGFILE format, RFC 9850) so that tools can decrypt captured traffic. A debugging aid for test systems only.
References
- Node.js 24 documentation: TLS (SSL) (OpenJS Foundation (Node.js))
- Node.js 24 documentation: Command-line options (NODE_TLS_REJECT_UNAUTHORIZED) (OpenJS Foundation (Node.js))
- Python 3.14 documentation: ssl, TLS/SSL wrapper for socket objects (Python Software Foundation)
- Mozilla Server Side TLS guidelines, version 6.0 (Mozilla)
- OWASP Transport Layer Security Cheat Sheet (OWASP Foundation)
- RFC 6797: HTTP Strict Transport Security (HSTS) (Internet Engineering Task Force (IETF))
- RFC 9849: TLS Encrypted Client Hello (Internet Engineering Task Force (IETF))
- RFC 9850: The SSLKEYLOGFILE Format for TLS (section 1.1) (Internet Engineering Task Force (IETF))
- RFC 9846: The Transport Layer Security (TLS) Protocol Version 1.3 (Internet Engineering Task Force (IETF))
- RFC 9525: Service Identity in TLS (Internet Engineering Task Force (IETF))
- RFC 8996: Deprecating TLS 1.0 and TLS 1.1 (Internet Engineering Task Force (IETF))
- OWASP Pinning Cheat Sheet (OWASP Foundation)
- OpenSSL 4.0 manual: X509_STORE_CTX_get_error (verification errors) (The OpenSSL Project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress