Test SMTP with STARTTLS
Run node probe.js 127.0.0.1:2526 against your own server. It prints the capability list, issues STARTTLS and verifies the certificate. A server that advertises STARTTLS still accepts a full cleartext session, so what this check reports is what the client did, not what the server offered.
Why check this
Run this whenever the mail path changes, and once per release on any service that authenticates to a relay. STARTTLS is opportunistic: encryption starts only if the client asks for it, and the client asks only if the capability was in the EHLO reply. Both halves can fail quietly.
The failure it prevents: a proxy between the application and the relay removes one line from the capability list, the mail library stops asking for TLS, and the AUTH LOGIN that follows carries the relay password in base64 over a cleartext connection. Nothing errors. Mail keeps arriving. Step 5 does that in 20 lines of proxy.
Prerequisites
- RFC 3207 for the extension, in particular section 6 on the man-in-the-middle case.
smtp.key,smtp.crt,smtpd.jsandtalk.jsfrom How to test SMTP connection. The server listens on 2526.strip.js, a proxy that deletes the capability and forwards everything else:
// A TCP proxy that deletes the STARTTLS capability from the EHLO reply and
// forwards everything else unchanged. This is a STARTTLS stripping attack in
// 20 lines, run against your own server on the loopback address.
const net = require('net');
net.createServer((client) => {
const upstream = net.createConnection({ host: '127.0.0.1', port: 2526 });
client.pipe(upstream);
upstream.on('data', (chunk) => {
const text = chunk.toString('binary');
client.write(Buffer.from(text.replace(/250-STARTTLS\r\n/g, ''), 'binary'));
});
client.on('error', () => upstream.destroy());
upstream.on('error', () => client.destroy());
upstream.on('close', () => client.end());
}).listen(2527, '127.0.0.1', function () {
console.log('stripping proxy on 127.0.0.1:2527 -> 2526, pid ' + process.pid);
});
tlsd.js, the same server with encryption made mandatory, on port 2529:
// The same server with mandatory TLS: no mail is accepted on a cleartext session.
const fs = require('fs');
const { SMTPServer } = require('smtp-server');
const server = new SMTPServer({
name: 'mx-strict.h2check.test',
disableReverseLookup: true,
key: fs.readFileSync('smtp.key'),
cert: fs.readFileSync('smtp.crt'),
authOptional: true,
onMailFrom(address, session, callback) {
if (!session.secure) {
const err = new Error('Must issue a STARTTLS command first');
err.responseCode = 530;
return callback(err);
}
callback();
},
onData(stream, session, callback) { stream.on('end', callback); stream.resume(); },
});
server.listen(2529, '127.0.0.1', () =>
console.log('strict smtpd on 127.0.0.1:2529, pid ' + process.pid));
probe.js, a client that honours the capability list, the way a mail library does:
// Opens an SMTP session, prints the capability list, runs STARTTLS when the
// server offers it, prints the list again, and reports the certificate check.
// node probe.js 127.0.0.1:2526 [--insecure]
const net = require('net');
const tls = require('tls');
const [host, port] = (process.argv[2] || '127.0.0.1:2526').split(':');
const insecure = process.argv.includes('--insecure');
function session(sock, label) {
return new Promise((resolve) => {
let buf = '';
const lines = [];
sock.setEncoding('utf8');
sock.on('data', function onData(d) {
buf += d;
let i;
while ((i = buf.indexOf('\r\n')) !== -1) {
const line = buf.slice(0, i); buf = buf.slice(i + 2);
lines.push(line);
if (/^\d{3} /.test(line)) {
if (/^220 /.test(lines[0]) && lines.length === 1) { sock.write('EHLO tester.local\r\n'); continue; }
sock.removeListener('data', onData);
const caps = lines.filter((l) => /^250[- ]/.test(l)).slice(1).map((l) => l.slice(4));
console.log(label + ' capabilities: ' + caps.join(' | '));
return resolve({ caps, sock });
}
}
});
});
}
(async () => {
const plain = net.createConnection({ host, port: Number(port) });
const { caps } = await session(plain, 'cleartext');
if (!caps.includes('STARTTLS')) {
console.log('STARTTLS not offered, continuing in the clear');
plain.write('QUIT\r\n'); plain.end();
return;
}
plain.write('STARTTLS\r\n');
await new Promise((r) => plain.once('data', (d) => { console.log('STARTTLS -> ' + d.trim()); r(); }));
const secure = tls.connect({ socket: plain, servername: 'mx.h2check.test', rejectUnauthorized: !insecure });
secure.on('error', (e) => console.log('TLS refused: ' + e.code + ' ' + (e.reason || e.message)));
secure.on('secureConnect', () => {
const cert = secure.getPeerCertificate();
console.log('TLS ' + secure.getProtocol() + ' authorized=' + secure.authorized +
(secure.authorized ? '' : ' reason=' + secure.authorizationError));
console.log('peer subject=' + cert.subject.CN + ' issuer=' + cert.issuer.CN + ' notAfter=' + cert.valid_to);
secure.write('EHLO tester.local\r\n');
let buf = '';
secure.setEncoding('utf8');
secure.on('data', (d) => {
buf += d;
if (/\r\n250 [^\r\n]*\r\n$/.test(buf)) {
const caps2 = buf.trim().split('\r\n').filter((l) => /^250[- ]/.test(l)).slice(1).map((l) => l.slice(4));
console.log('encrypted capabilities: ' + caps2.join(' | '));
secure.write('QUIT\r\n'); secure.end();
}
});
});
})();
nm-downgrade.js, the same send through a real mail library with default options:
// Default nodemailer options, one recipient, two ports: the server itself and
// the same server behind a proxy that removed STARTTLS from the capability list.
const nodemailer = require('nodemailer');
(async () => {
for (const port of [2526, 2527]) {
const t = nodemailer.createTransport({ host: '127.0.0.1', port });
try {
const i = await t.sendMail({ from: 'ci@build.test', to: 'qa@build.test', subject: 'downgrade probe', text: 'x' });
console.log('port ' + port + ': ' + i.response);
} catch (e) {
console.log('port ' + port + ': send failed, ' + e.code + ' ' + e.message);
}
t.close();
}
})();
Steps
- Step 1.
Start the proxy and the strict server next to the one already on 2526.
node strip.js & node tlsd.js &stripping proxy on 127.0.0.1:2527 -> 2526, pid 42892 strict smtpd on 127.0.0.1:2529, pid 25176Three ports now answer: the server, the same server with an edited capability list, and one that refuses mail on a cleartext session.
- Step 2.
Probe the server with certificate verification on.
node probe.js 127.0.0.1:2526cleartext capabilities: PIPELINING | 8BITMIME | SMTPUTF8 | AUTH LOGIN PLAIN | STARTTLS | SIZE 10485760 STARTTLS -> 220 Ready to start TLS TLS refused: DEPTH_ZERO_SELF_SIGNED_CERT self-signed certificateThe server offered the extension, the upgrade was accepted, and the client dropped the connection because the certificate is self-signed. This is the only outcome in the procedure where the session both encrypts and is checked.
- Step 3.
Repeat with verification off and compare the two capability lists.
node probe.js 127.0.0.1:2526 --insecurecleartext capabilities: PIPELINING | 8BITMIME | SMTPUTF8 | AUTH LOGIN PLAIN | STARTTLS | SIZE 10485760 STARTTLS -> 220 Ready to start TLS TLS TLSv1.3 authorized=false reason=DEPTH_ZERO_SELF_SIGNED_CERT peer subject=mx.h2check.test issuer=mx.h2check.test notAfter=Oct 11 20:55:24 2026 GMT encrypted capabilities: PIPELINING | 8BITMIME | SMTPUTF8 | AUTH LOGIN PLAIN | SIZE 10485760authorized=falseon an encrypted session is the state most SMTP clients ship in. The traffic is unreadable to a passive listener and unauthenticated against an active one.STARTTLSis absent from the encrypted list, because the extension is spent once used. - Step 4.
Probe the same server through the proxy.
node probe.js 127.0.0.1:2527 --insecurecleartext capabilities: PIPELINING | 8BITMIME | SMTPUTF8 | AUTH LOGIN PLAIN | SIZE 10485760 STARTTLS not offered, continuing in the clearOne line was deleted from the reply. The client behaves correctly for a server with no TLS, and there is no error anywhere to notice.
AUTH LOGINis still advertised on that session. - Step 5.
Send the same message through a mail library on both ports.
node nm-downgrade.jsport 2526: send failed, ESOCKET self-signed certificate port 2527: 250 OK: message queuedRead that pair twice. On the honest server the send fails, because the library upgraded and then refused the certificate. Behind the proxy the same library, the same options and the same message deliver without a word, in the clear. Removing the capability turned a hard failure into a silent success.
- Step 6.
Ask openssl the same question about the stripped port.
printf 'QUIT\r\n' | openssl s_client -starttls smtp -connect 127.0.0.1:2527 -briefDidn't find STARTTLS in server response, trying anyway... Can't use SSL_get_servername depth=0 CN = mx.h2check.test verify error:num=18:self-signed certificate CONNECTION ESTABLISHED Protocol version: TLSv1.3 Ciphersuite: TLS_AES_256_GCM_SHA384 Peer certificate: CN = mx.h2check.test … Verification error: self-signed certificate Server Temp Key: X25519, 253 bits 250 SIZE 10485760 DONEopenssl reports an encrypted session on the port where the mail library sent in the clear. It says why in its first line: it did not find the capability and issued the command anyway. As a downgrade detector,
s_clientgives the wrong answer. - Step 7.
Check what mandatory encryption looks like from the client side.
node talk.js 127.0.0.1:2529 "EHLO tester.local" "MAIL FROM:<ci@build.test>" QUIT-- tcp connected to 127.0.0.1:2529 S: 220 mx-strict.h2check.test ESMTP C: EHLO tester.local S: 250-mx-strict.h2check.test Nice to meet you, [127.0.0.1] … S: 250 STARTTLS C: MAIL FROM:<ci@build.test> S: 530 Must issue a STARTTLS command first C: QUIT S: 221 Bye -- connection closedA refusal at
MAIL FROMis what a stripped capability should cost. The proxy in step 5 cannot produce a silent downgrade against this server: the message stops instead. - Step 8.
Stop the two processes you started and confirm the ports are free.
powershell -Command "Stop-Process -Id 42892 -Force; Stop-Process -Id 25176 -Force"$ netstat -ano | grep LISTENING | grep -E ":(2527|2529) " $A stripping proxy left listening is worse than a stale test server.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| STARTTLS in the cleartext list, 220 Ready to start TLS | The extension works | Continue to the certificate check. |
| TLS refused: DEPTH_ZERO_SELF_SIGNED_CERT | The client verified and the certificate failed | Fix the certificate. Do not switch verification off to make the test pass. |
| authorized=false on a completed session | Encrypted and unauthenticated | An active attacker can present any certificate. Turn verification on in the client. |
| STARTTLS not offered, continuing in the clear | Either the server has no TLS, or something removed the line | Compare with a probe from another network path. |
| 530 after MAIL FROM | The server requires encryption | Correct for a submission service, and a delivery failure on a public MX. |
| AUTH advertised on the cleartext list | Credentials can be sent before encryption | Configure the server to publish AUTH only after STARTTLS. |
| openssl says Didn't find STARTTLS in server response | The capability was absent and openssl tried anyway | Trust the capability list, not the handshake that follows. |
Common mistakes
What to check next
- How to test SMTP connection: the capability list this procedure edits, and how to read a session.
- How to check MTA-STS: the published policy that tells senders a downgrade is not acceptable.
- How to check SSL certificate expiry: the certificate that step 2 refused, checked the way a verifying client does.
- How to test email sending in a test environment: the catch-all arrangement these servers belong to.
- Email testing checklist: where this sits in a release that touches mail.
FAQ
Does STARTTLS encrypt SMTP?
Only when the client asks. The session starts in the clear, the client sends STARTTLS, and everything after the handshake is encrypted. A client that does not ask, or that never saw the capability, sends the whole message unencrypted and the server accepts it.
How do I check the certificate on a STARTTLS session?
Connect with verification on, as in step 2, and read the failure. openssl s_client -starttls smtp prints the chain and the verify result, and Node's tls.connect reports authorized and authorizationError for the same session.
Is port 465 safer than STARTTLS on 587?
Port 465 is TLS from the first byte, so there is no plaintext phase to strip. Port 587 with a client that requires STARTTLS and verifies the certificate reaches the same place. The weak setup is neither of those: opportunistic upgrade with verification off.
Why does my client report TLS while a capture shows plaintext?
They are looking at different hops. A proxy can encrypt to the relay and read the traffic on the way. Test from the host the application runs on, not from your laptop.
Verified
Verified by Maks Vernynode 22.23.2openssl 3.1.1nodemailer 10.0.8smtp-server 3.19.11
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
advanced12 minpublished updated Maks Verny