smtplib will not check the certificate unless you ask
Shipped
This release added an evening job that emails a generated report at 19:00, with the charts attached inline. Delivery is plain smtplib from a scheduled process, no interactive session anywhere in the path.
A pre-release security review caught the mailer connecting with no SSLContext. That reads as harmless, because the code was already using SMTP_SSL on port 465, which sounds like the secure one. It is not harmless. Python’s SMTP client will happily encrypt a connection to a server it has made no attempt to identify, and the statement right after the handshake is usually login().
This guide builds a small mailer that verifies properly, then writes the test that catches it when it does not. The test is the part worth stealing: the obvious one passes whether verification is on or off.
What the standard library actually does
Two facts, and the second explains the first.
smtplib takes an optional context argument on both transports. The documentation for SMTP_SSL and starttls() describes what a context is for and points at the ssl module’s security considerations, but it never says what you get when you leave it out. That silence is where the trap lives.
The CPython source answers it. Both SMTP_SSL.__init__ and starttls() contain the same two lines:
if context is None:
context = ssl._create_stdlib_context()
And _create_stdlib_context is an alias:
_create_stdlib_context = _create_unverified_context
It builds a context with cert_reqs=CERT_NONE and check_hostname=False. Encrypted, and authenticated against nothing.
The verifying one is a different factory. ssl.create_default_context() sets verify_mode to CERT_REQUIRED and enables check_hostname, and it is never what you get by omission.
The reason is historical, and PEP 476 states it plainly: it enabled certificate verification by default for HTTP clients and stopped there. The PEP says it “only proposes requiring this level of validation for HTTP clients, not for other protocols such as SMTP.” Self-signed certificates were common on mail servers in 2014, so turning verification on would have broken working deployments. That decision never got revisited, so a smtplib call written today inherits it.
Meanwhile RFC 8314 tells clients the opposite: mail user agents “MUST validate TLS server certificates,” and it prefers implicit TLS on port 465 over STARTTLS because it is simpler to get right. Your code has to close that gap itself.
Build a mailer that verifies
One function returns the context, and both transports take it. Making it a function rather than an inline call is what lets the test reach it later.
# mailer.py
from __future__ import annotations
import smtplib
import ssl
from dataclasses import dataclass
from email.message import EmailMessage
from email.utils import formatdate
#: A hung socket in an unattended job pins the process until something else
#: kills it. Fail instead, and let the retry slot take the next attempt.
SMTP_TIMEOUT_S = 30
@dataclass(frozen=True)
class MailConfig:
host: str
port: int
user: str
password: str
to: tuple[str, ...]
from_addr: str
def tls_context() -> ssl.SSLContext:
"""A certificate-verifying TLS context.
ssl.create_default_context() is the one that sets verify_mode to
CERT_REQUIRED and turns check_hostname on. Passing None to smtplib
gets you the unverified stdlib context instead.
"""
return ssl.create_default_context()
def build_message(subject: str, body: str, cfg: MailConfig) -> EmailMessage:
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = cfg.from_addr
msg["To"] = ", ".join(cfg.to)
msg["Date"] = formatdate(localtime=True)
msg.set_content(body)
return msg
def send(msg: EmailMessage, cfg: MailConfig) -> None:
context = tls_context()
if cfg.port == 465:
# Implicit TLS: the handshake happens inside __init__, so the context
# has to be supplied at connect time.
with smtplib.SMTP_SSL(cfg.host, cfg.port, timeout=SMTP_TIMEOUT_S,
context=context) as smtp:
smtp.login(cfg.user, cfg.password)
smtp.send_message(msg)
else:
# STARTTLS: connect in the clear, then upgrade. The upgrade carries the
# context, and login comes after it, never before.
with smtplib.SMTP(cfg.host, cfg.port, timeout=SMTP_TIMEOUT_S) as smtp:
smtp.starttls(context=context)
smtp.login(cfg.user, cfg.password)
smtp.send_message(msg)
Two details in send carry weight. The context reaches SMTP_SSL through the constructor and SMTP through starttls(), because those are the two different moments the handshake happens. And login() sits after the upgrade on the STARTTLS branch. Reversed, the password crosses a cleartext socket before the connection is secured.
Test the context, not the outcome
Here is the test almost everyone writes first:
def test_send_delivers_the_message(fake_smtp):
mailer.send(build_message("hi", "body", CFG), CFG)
assert fake_smtp.instances[0].sent is not None
It passes with context=context. It also passes with the argument deleted. It cannot distinguish a verified connection from an unverified one, because “the message went out” is true either way. Every assertion about the outcome has this property, which is why the defect survived a green suite.
The assertion has to be about the context object itself. Start with a fake transport that records what it was handed:
# test_mailer.py
import ssl
import pytest
import mailer
from mailer import MailConfig, build_message
CFG = MailConfig(
host="smtp.example.com", port=465, user="me@example.com",
password="secret", to=("me@example.com",), from_addr="me@example.com",
)
class FakeSMTP:
"""Records the transport's inputs without opening a socket."""
instances: list["FakeSMTP"] = []
def __init__(self, host, port, timeout=None, context=None):
self.host, self.port, self.timeout = host, port, timeout
# SMTP_SSL receives the context here; the STARTTLS path leaves this
# None and supplies it at upgrade time instead.
self.connect_context = context
self.starttls_context = None
self.order: list[str] = []
FakeSMTP.instances.append(self)
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def starttls(self, context=None):
self.starttls_context = context
self.order.append("starttls")
def login(self, user, password):
self.order.append("login")
def send_message(self, msg):
self.order.append("send")
@pytest.fixture
def fake_smtp(monkeypatch):
FakeSMTP.instances.clear()
monkeypatch.setattr(mailer.smtplib, "SMTP_SSL", FakeSMTP)
monkeypatch.setattr(mailer.smtplib, "SMTP", FakeSMTP)
return FakeSMTP
def msg():
return build_message("subject", "body", CFG)
Now four assertions that can actually fail:
def test_tls_context_verifies_certificates_and_hostnames():
ctx = mailer.tls_context()
assert ctx.check_hostname is True
assert ctx.verify_mode is ssl.CERT_REQUIRED
def test_context_is_not_the_unverified_stdlib_default():
# Pin the distinction against the exact thing smtplib would have used.
unverified = ssl._create_stdlib_context()
assert (unverified.check_hostname, unverified.verify_mode) == (False, ssl.CERT_NONE)
ctx = mailer.tls_context()
assert (ctx.check_hostname, ctx.verify_mode) != (False, ssl.CERT_NONE)
def test_implicit_tls_passes_a_verifying_context_at_connect(fake_smtp):
mailer.send(msg(), CFG)
conn = fake_smtp.instances[0]
assert conn.connect_context is not None, "SMTP_SSL got context=None"
assert conn.connect_context.check_hostname is True
assert conn.connect_context.verify_mode is ssl.CERT_REQUIRED
def test_credentials_never_cross_an_unupgraded_socket(fake_smtp):
cfg = MailConfig(**{**CFG.__dict__, "port": 587})
mailer.send(msg(), cfg)
conn = fake_smtp.instances[0]
assert conn.starttls_context is not None, "starttls() got context=None"
assert conn.starttls_context.verify_mode is ssl.CERT_REQUIRED
assert conn.order == ["starttls", "login", "send"]
The last one checks ordering as well as presence, because a login() ahead of the upgrade is a separate bug with the same consequence.
Verify it against a real server
Unit tests prove you passed a context. They do not prove the context is enforced at handshake time, and that is worth confirming once with positive and negative controls. Save this next to mailer.py:
# verify_tls.py
import socket
import ssl
from mailer import tls_context
HOST, PORT = "smtp.gmail.com", 465
ctx = tls_context()
print(f"context: check_hostname={ctx.check_hostname} verify_mode={ctx.verify_mode!r}")
with socket.create_connection((HOST, PORT), timeout=15) as s:
with ctx.wrap_socket(s, server_hostname=HOST) as t:
print(f"real hostname : handshake OK, {t.version()}")
try:
with socket.create_connection((HOST, PORT), timeout=15) as s:
with ctx.wrap_socket(s, server_hostname="not-really-gmail.example.com"):
print("wrong hostname : ACCEPTED, verification is NOT enforced")
except ssl.SSLCertVerificationError as e:
print(f"wrong hostname : rejected, {str(e)[:58]}")
Run it:
python verify_tls.py
context: check_hostname=True verify_mode=<VerifyMode.CERT_REQUIRED: 2>
real hostname : handshake OK, TLSv1.3
wrong hostname : rejected, [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed
The second line proves the context works against the server you actually use. The third proves it rejects one it should not trust.
Now break it on purpose. Change tls_context() to return ssl._create_stdlib_context(), which is what smtplib would have used for you, and run the script again:
context: check_hostname=False verify_mode=<VerifyMode.CERT_NONE: 0>
real hostname : handshake OK, TLSv1.3
wrong hostname : ACCEPTED, verification is NOT enforced
The middle line does not move. A connection to the right server succeeds identically either way, which is precisely why this defect is invisible in normal operation, and why the negative control is the only line worth watching.
Gotchas
Port 465 sounds like the safe one, so nobody audits it. The trap is that SMTP_SSL and starttls() fail the same way, and the mental model that STARTTLS is the risky path leaves the implicit-TLS branch unreviewed. Symptom: none. The connection succeeds, the mail arrives, the logs are clean. The escape is to treat “which transport” as unrelated to “is it verified,” and pass an explicit context to both.
A green test suite is not evidence here. This is the one that bit this release. The mailer reached a feature branch with a passing suite covering the MIME tree, the config resolution and the transport selection, and not one assertion in it could fail on an unverified connection. Counting the pre-fix test file against the released one is the tidiest measure of the gap: zero references to verify_mode before, six after. Symptom: a review finds the defect and you discover the suite was never watching. The escape is the mutation check. Delete context=context, run the tests, and confirm something goes red. If nothing does, the test you just wrote is decorative.
Unattended jobs remove the human who would have noticed. An interactive client gives someone a chance to see a certificate warning. A scheduled job at 19:00 with a retry at 20:00 has nobody watching, and it re-attempts the same connection on a schedule. That turns a one-time interception into a recurring one. When the job authenticates, assume the credential is the target, not the payload.
_create_stdlib_context is private, and pinning against it is a deliberate trade. The second test above imports an underscore-prefixed CPython function, which can change without a deprecation cycle. That is the cost of asserting on the exact thing you must not be. If you would rather not depend on a private name, assert the positive properties only and accept that the test no longer names the failure mode it exists to prevent.
Sources
- PEP 476: Enabling certificate verification by default for stdlib http clients — scopes verification to HTTP clients and excludes SMTP explicitly
- CPython
Lib/smtplib.py— theif context is Nonefallback in bothSMTP_SSL.__init__andstarttls() - CPython
Lib/ssl.py—_create_stdlib_contextaliased to_create_unverified_context - Python
ssldocumentation —create_default_context()setsCERT_REQUIREDand enablescheck_hostname - Python
smtplibdocumentation — thecontextparameter onSMTP_SSLandstarttls() - RFC 8314 — clients MUST validate server certificates; implicit TLS preferred over STARTTLS
Changelog
- release: 0.51.0 — evening brief email (dev → main) (#202) (a2156d2)