Francis Bergin


Testing TLS validation in smtplib with smtps-proxy

Posted on April 30, 2026. Edited on June 03, 2026.

Python’s smtplib will happily negotiate TLS without validating the server certificate unless you pass an explicit SSL context. I verified this with a local SMTP proxy and confirmed that credentials can be intercepted.

This behavior is not a new discovery. The risks around unauthenticated TLS in Python standard library clients have been discussed for years in Python issues, documentation, PEPs, and multiple articles. There have also been many assigned CVEs for incorrect usage across projects. This post is meant as a demonstration of the problem and fix specifically in the SMTP STARTTLS case.

Security Advisories

As part of this investigation, I identified and reported insecure SMTP client configurations to the following upstream projects:

Background

I’ve been using my watch-diff project for more than 10 years now to monitor command outputs and get email notifications on changes. It has run in different forms, in different places and has been pretty useful. Recently, I was browsing the smtplib documentation for fun and noticed this for the first time:

Please read Security considerations for best practices.

Which leads to the following:

For client use, if you don’t have any special requirements for your security policy, it is highly recommended that you use the create_default_context() function to create your SSL context. It will load the system’s trusted CA certificates, enable certificate validation and hostname checking, and try to choose reasonably secure protocol and cipher settings.

It seems without this SSL default context, smtplib will not do any SSL validation at all. Without SSL validation, a person between the client and the server can provide a bogus certificate, decrypt and read the communication, forward it to the real server, all without any errors on the client side.

I wanted to test out if this were really the case and if my watch-diff tool was vulnerable.

Test setup with smtps-proxy

So I wrote a simple Go service that accepts connections on port 587, supports promoting connections to TLS using STARTTLS, returning a generated self-signed certificate, decrypts and reads the SMTP communication (including SMTP credentials) and forwards them to the real SMTP server.

smtps-proxy

To use it locally, I added 127.0.0.1 smtp.gmail.com to my /etc/hosts file. This way, whenever watch-diff opens a connection to the SMTP server, it is sent to the Go service instead. If SSL/TLS validation were working properly, the watch-diff program would crash with some sort of invalid certificate error. But it didn’t!

$ watch-diff -v -i 10 -r test@francisbergin.net date
INFO:watch_diff.__main__:executing command with time 2026-04-06 16:06:25.809820
[2026-04-06 16:06:25.809820] first_run:
Mon  6 Apr 2026 16:06:25 EDT
INFO:watch_diff.__main__:sending first_run email to test@francisbergin.net
INFO:watch_diff.email:sending email
INFO:watch_diff.email:running func: "_smtp_connect", count: 1
INFO:watch_diff.email:running func: "_smtp_login", count: 1
INFO:watch_diff.email:email sent successfully
INFO:watch_diff.__main__:sleeping for 10 seconds
$ smtps-proxy
2026/04/06 16:06:13 Starting SMTP server with STARTTLS on :587
2026/04/06 16:06:25 127.0.0.1:58938: NewSession
2026/04/06 16:06:25 127.0.0.1:58938: Generating certificate for SNI: smtp.gmail.com
2026/04/06 16:06:26 127.0.0.1:58938: Logout
2026/04/06 16:06:26 127.0.0.1:58938: NewSession
2026/04/06 16:06:26 127.0.0.1:58938: Auth credentials: identity= username=abc@gmail.com password=REDACTED
2026/04/06 16:06:26 127.0.0.1:58938: Connecting to real server: smtp.gmail.com (64.233.178.108)
2026/04/06 16:06:28 127.0.0.1:58938: Mail from: abc@gmail.com
2026/04/06 16:06:28 127.0.0.1:58938: Rcpt to: test@francisbergin.net
2026/04/06 16:06:28 127.0.0.1:58938: Data received
2026/04/06 16:06:29 127.0.0.1:58938: Data forwarded to real server (754 bytes)
2026/04/06 16:06:29 127.0.0.1:58938: Reset
2026/04/06 16:06:29 127.0.0.1:58938: Logout

As shown above, the service in the middle of the client and the server was able to capture credentials, even though TLS was used to encrypt communications!

Fix

When passing in the SSL default context into the starttls() function, as suggested in the Python SSL documentation, watch-diff will error out and quickly terminate the connection before any credentials or message content are sent:

Traceback (most recent call last):
...
s.starttls(context=context)
~~~~~~~~~~^^^^^^^^^^^^^^^^^
...
self.do_handshake()
~~~~~~~~~~~~~~~~~^^
...
self._sslobj.do_handshake()
~~~~~~~~~~~~~~~~~~~~~~~~~^^
ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate (_ssl.c:1081)

This error happens during the STARTTLS handshake before any credentials are sent.

The full change required is very simple (francisbergin/watch-diff@ade48ad):

diff --git a/watch_diff/email.py b/watch_diff/email.py
index ec49613..e926f2f 100644
--- a/watch_diff/email.py
+++ b/watch_diff/email.py
@@ -2,6 +2,7 @@ import functools
 import logging
 import smtplib
 import socket
+import ssl
 import time
 from email.mime.multipart import MIMEMultipart
 from email.mime.text import MIMEText
@@ -71,7 +72,8 @@ class Email:

         s = self._smtp_connect(self._smtp_host, self._smtp_port)
         s.ehlo()
-        s.starttls()
+        context = ssl.create_default_context()
+        s.starttls(context=context)
         s.ehlo()
         self._smtp_login(s, self._smtp_user, self._smtp_pass)
         s.sendmail(self._smtp_user, self._recipient, msg.as_string())

Prior work

These are a few of the related discussions that happened before:

Takeaways

This vulnerability was part of the very first version of this project. Throughout the years, it has run in different VPS environments. I was always under the impression that since the script was using TLS to communicate with the server, my credentials were safe. This was not the case. If an attacker had access to the network between the client and the server, they could have easily read the SMTP credentials and used them to send emails on my behalf.

This is a good reminder that just because a connection is encrypted, it doesn’t necessarily mean it’s secure. Proper SSL/TLS validation is important to ensure the security of communications.