Postfix TLS & DKIM

Submission vs implicit TLS, the two tls_security_level knobs that everyone gets wrong, DANE, postscreen, OpenDKIM, and the SPF/DMARC DNS records that actually survive deliverability audits.

Three levels, three jobs. Submission (587) uses STARTTLS and is for your authenticated users. Implicit TLS (465) is also for your users, but raw TLS from byte one — use it when clients refuse STARTTLS. Port 25 is for server-to-server and is opportunistic by default; you tighten it with smtp_tls_security_level (outbound) and leave smtpd_tls_security_level=may (inbound), never encrypt, or you will silently lose legitimate mail from older hosts.

Ports 25, 465 and 587

PortNameTLS flavorWho talks here
25SMTPSTARTTLS, opportunisticOther mail servers. Never authenticated users.
465Submissions / SMTPSImplicit TLS (handshake on connect)Authenticated users whose client will not speak STARTTLS.
587SubmissionSTARTTLS, requiredAuthenticated users. The default modern submission port.

Both 465 and 587 live in master.cf, not main.cf. Enabling them is two uncommented stanzas and a handful of per-service overrides. Anything shared (keys, cipher lists, log levels) goes in main.cf.

smtp_tls_security_level and friends

Postfix has two independent TLS policies. They are prefixed smtp_ (outbound — Postfix is a client talking to another MTA) and smtpd_ (inbound — Postfix is the server). Read them out loud: "SMTP dee" is the daemon, the receiver.

ValueOutbound meaning (smtp_tls_security_level)Inbound meaning (smtpd_tls_security_level)
noneNever offer STARTTLS.Never advertise STARTTLS. Do not use.
mayOpportunistic. TLS if the peer offers it, plaintext otherwise.Advertise STARTTLS but do not require it. The correct value for port 25.
encryptMandatory TLS. Drop if peer cannot STARTTLS. Use only with relay hosts you control.Require STARTTLS before MAIL FROM. Fine for 587. Fatal on 25.
daneOpportunistic DANE: use TLSA records when present, fall back to opportunistic.n/a
dane-onlyRequire a valid TLSA record. Bounces if not present.n/a
verify / secureRequire TLS and match peer cert to CA/hostname.n/a
Don't ever set smtpd_tls_security_level = encrypt on port 25. RFC 3207 is clear: public MX hosts must accept plaintext. You will drop mail silently from anything old enough to matter (ticketing platforms, cheap VPS senders, some bulk providers).

A sane main.cf TLS baseline

# /etc/postfix/main.cf
# ---- Identity ----
myhostname = mail.example.com
mydomain = example.com
myorigin = $mydomain

# ---- Keys and trust ----
smtpd_tls_cert_file = /etc/pki/tls/certs/mail.example.com.crt
smtpd_tls_key_file  = /etc/pki/tls/private/mail.example.com.key
smtpd_tls_CAfile    = /etc/pki/tls/certs/ca-bundle.crt

# ---- Inbound (port 25): advertise STARTTLS, never require ----
smtpd_tls_security_level = may
smtpd_tls_loglevel = 1
smtpd_tls_received_header = yes

# Modern ciphers only; the medium grade list Postfix ships is fine for public MX.
smtpd_tls_mandatory_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1
smtpd_tls_protocols           = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1
smtpd_tls_mandatory_ciphers   = medium
tls_medium_cipherlist         = ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256

# ---- Outbound ----
smtp_tls_security_level = may
smtp_tls_loglevel = 1
smtp_tls_session_cache_database = btree:/var/lib/postfix/smtp_scache
smtp_tls_CAfile = /etc/pki/tls/certs/ca-bundle.crt

# ---- SASL on submission only (see master.cf) ----
smtpd_sasl_type = dovecot
smtpd_sasl_path = private/auth
smtpd_sasl_auth_enable = no
broken_sasl_auth_clients = yes

Cross-link: see Postfix Config for the wider main.cf walkthrough, and Dovecot for the SASL socket this snippet references.

Submission (587) with STARTTLS

In master.cf, uncomment the submission block and add per-service overrides. The overrides re-scope things so that authenticated clients — not strangers on port 25 — are allowed to relay outbound:

# /etc/postfix/master.cf
submission     inet  n       -       n       -       -       smtpd
  -o syslog_name=postfix/submission
  -o smtpd_tls_security_level=encrypt
  -o smtpd_sasl_auth_enable=yes
  -o smtpd_sasl_type=dovecot
  -o smtpd_sasl_path=private/auth
  -o smtpd_reject_unlisted_recipient=no
  -o smtpd_client_restrictions=permit_sasl_authenticated,reject
  -o smtpd_sender_restrictions=reject_sender_login_mismatch,permit_sasl_authenticated,reject
  -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject
  -o milter_macro_daemon_name=ORIGINATING

Key points:

Implicit TLS on 465

Same idea, different stanza. smtps expects TLS at the first byte, so you set smtpd_tls_wrappermode=yes:

# /etc/postfix/master.cf
smtps     inet  n       -       n       -       -       smtpd
  -o syslog_name=postfix/smtps
  -o smtpd_tls_wrappermode=yes
  -o smtpd_sasl_auth_enable=yes
  -o smtpd_sasl_type=dovecot
  -o smtpd_sasl_path=private/auth
  -o smtpd_client_restrictions=permit_sasl_authenticated,reject
  -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject
  -o milter_macro_daemon_name=ORIGINATING
If you are running both 465 and 587, iOS Mail in its default state will pick 465 and succeed without intervention. Thunderbird picks 587. Outlook used to refuse 587 in some enterprise tenancies which is why 465 was resurrected by RFC 8314.

Open the firewall

firewall-cmd --permanent --add-service=smtp          # 25
firewall-cmd --permanent --add-service=smtps         # 465
firewall-cmd --permanent --add-service=smtp-submission   # 587
firewall-cmd --reload

See firewalld if the named services are not defined on your distro.

DANE for server-to-server

DANE uses DNSSEC-signed TLSA records to pin a certificate (or its issuing CA) for a given mail server. For outbound Postfix it turns opportunistic TLS into verifiable TLS without ever touching a public CA bundle — your peer's DNS is the source of trust.

Three preconditions: the peer's zone is signed, the peer publishes a TLSA record, and your resolver validates DNSSEC. Then:

# /etc/postfix/main.cf — outbound DANE
smtp_dns_support_level = dnssec
smtp_tls_security_level = dane
smtp_tls_loglevel = 1

Publishing your own TLSA record (for the cert on your MX):

# Generate the TLSA RR from your cert. 3 1 1 = "EE cert, SubjectPublicKeyInfo, SHA-256".
openssl x509 -in /etc/pki/tls/certs/mail.example.com.crt \
  -noout -pubkey \
  | openssl pkey -pubin -outform DER \
  | openssl dgst -sha256 -binary \
  | hexdump -ve '/1 "%02x"'
# -> 3f29577fc82... (long hex)

# Then the record:
# _25._tcp.mail.example.com. IN TLSA 3 1 1 3f29577fc82...

Rotate the cert: publish the new TLSA alongside the old one, wait for TTL, then flip.

dane vs dane-only. dane falls back to opportunistic TLS if no TLSA exists, which is what you want in practice. dane-only is for relay tables where you know the peer must have TLSA; a bounce is better than plaintext.

postscreen basics

Most botnet senders fail the SMTP protocol in stereotyped ways — they pre-greet, they pipeline before they should, they ignore 4xx responses. postscreen is a lightweight triage process that parks connections for a moment, checks them against DNS blocklists, and only hands them to the real smtpd if they look like a legitimate MTA.

In master.cf:

smtp      inet  n       -       n       -       1       postscreen
smtpd     pass  -       -       n       -       -       smtpd
dnsblog   unix  -       -       n       -       0       dnsblog
tlsproxy  unix  -       -       n       -       0       tlsproxy

And the minimum useful tuning in main.cf:

# ---- postscreen ----
postscreen_access_list = permit_mynetworks,
    cidr:/etc/postfix/postscreen_access.cidr
postscreen_greet_action = enforce
postscreen_dnsbl_threshold = 3
postscreen_dnsbl_sites =
    zen.spamhaus.org*3
    bl.spamcop.net*2
    b.barracudacentral.org*1
postscreen_dnsbl_action = enforce
postscreen_blacklist_action = drop

The *N after each blocklist is the score weight — Spamhaus ZEN alone is enough to reject at threshold=3. postscreen does not sit on 587 or 465; it is strictly an MX-side tool. Subscription to some of these blocklists has rate limits for high-volume hosts; check the per-list policy.

OpenDKIM: install & wire up

DKIM signs outbound messages with a private key; receivers fetch the matching public key from your DNS and verify the signature. Done right, it makes DMARC possible. Done wrong (mismatched selectors, wrong From: alignment), it is worse than not signing because reputation systems penalize broken signatures.

Install

dnf install -y opendkim opendkim-tools    # RHEL-family
# or
apt install -y opendkim opendkim-tools    # Debian-family

mkdir -p /etc/opendkim/keys/example.com
chown -R opendkim:opendkim /etc/opendkim

Generate a key

cd /etc/opendkim/keys/example.com
opendkim-genkey -b 2048 -s mail -d example.com
# Produces:
#   mail.private  (keep; chmod 600, owner opendkim)
#   mail.txt      (publish as a TXT record at mail._domainkey.example.com)
chown opendkim:opendkim mail.private
chmod 600 mail.private

KeyTable, SigningTable, TrustedHosts

# /etc/opendkim/KeyTable
mail._domainkey.example.com example.com:mail:/etc/opendkim/keys/example.com/mail.private
# /etc/opendkim/SigningTable
*@example.com mail._domainkey.example.com
# /etc/opendkim/TrustedHosts
127.0.0.1
::1
localhost
mail.example.com
10.0.0.0/8

opendkim.conf

# /etc/opendkim.conf
Syslog                  yes
SyslogSuccess           yes
Canonicalization        relaxed/simple
Mode                    sv
SubDomains              no
AutoRestart             yes
AutoRestartRate         10/1h
Background              yes
DNSTimeout              5
SignatureAlgorithm      rsa-sha256

KeyTable                file:/etc/opendkim/KeyTable
SigningTable            refile:/etc/opendkim/SigningTable
ExternalIgnoreList      refile:/etc/opendkim/TrustedHosts
InternalHosts           refile:/etc/opendkim/TrustedHosts

Socket                  inet:8891@127.0.0.1
PidFile                 /run/opendkim/opendkim.pid
UserID                  opendkim
UMask                   002

Tell Postfix about the milter

# /etc/postfix/main.cf
milter_default_action = accept
milter_protocol = 6
smtpd_milters = inet:127.0.0.1:8891
non_smtpd_milters = $smtpd_milters
systemctl enable --now opendkim
systemctl restart postfix

If SELinux is in enforcing mode you may need setsebool -P postfix_local_write_mail_spool on; see SELinux for the general pattern.

DNS: DKIM, SPF, DMARC

All three live in your authoritative zone for example.com.

DKIM

mail._domainkey.example.com. 3600 IN TXT ( "v=DKIM1; k=rsa; s=email; "
  "p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7x9p..."
  "...QIDAQAB" )

Split the p= value across quoted chunks of 255 characters — most DNS servers will refuse a single TXT string longer than that.

SPF

example.com. 3600 IN TXT "v=spf1 mx a:mail.example.com ip4:198.51.100.25 -all"

-all is a hard fail. Use ~all (soft fail) while you are still discovering unknown senders in an enterprise environment, then tighten once DMARC rua reports go quiet.

DMARC — a sensible starting policy

_dmarc.example.com. 3600 IN TXT ( "v=DMARC1; p=quarantine; adkim=s; aspf=s; "
  "rua=mailto:dmarc-reports@example.com; "
  "ruf=mailto:dmarc-forensics@example.com; "
  "fo=1; pct=100" )

Key tags:

Reading TLS logs

With smtpd_tls_loglevel = 1 you get one-line summaries. A successful inbound TLS session looks like this:

postfix/smtpd[12345]: initializing the server-side TLS engine
postfix/smtpd[12345]: connect from mx.partner.net[203.0.113.12]
postfix/smtpd[12345]: Anonymous TLS connection established from mx.partner.net[203.0.113.12]: TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256

Outbound (Postfix-the-client) is postfix/smtp:

postfix/smtp[7781]: Trusted TLS connection established to gmail-smtp-in.l.google.com[142.251.x.x]:25: TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits)

The word before "TLS connection" is the verification result:

Grep for TLS connection established across a day's maillog, split by verification word — you will quickly see which of your regular correspondents are only reaching you Anonymously. That is the list of places to ask "please publish DANE" or "please trust a public CA".

Troubleshooting

SymptomLikely causeFix
TLS library problem: ... alert bad certificate in the logs when peers connect to 25 Cert chain on your side is missing the intermediate. cat leaf.crt intermediate.crt > chain.crt; point smtpd_tls_cert_file at the chain. Verify with openssl s_client -connect mail.example.com:25 -starttls smtp -showcerts.
warning: TLS library problem: ... no shared cipher Peer is on TLS 1.0/1.1 only and your smtpd_tls_protocols excludes them. Decide: keep the modern floor and accept that this correspondent won't do TLS, or loosen smtpd_tls_mandatory_protocols just for mandatory paths.
OpenDKIM signs but DMARC still fails DKIM d= domain does not align with the From: header domain. Check SigningTable: the selector must map to the same domain as the From: header, not to the envelope sender.
DKIM-Signature header appears, but verifiers report bodyhash mismatch A milter or filter edits the body after OpenDKIM signs. Put OpenDKIM last in smtpd_milters. Never let amavis/spamassassin rewrite the body on outbound.
DANE outbound: Verified TLS connection works for some peers, Trust anchor for certification path not found for others Peer published TLSA 2 x x (trust anchor) pointing at an intermediate your resolver cannot reach, or the peer's zone is not DNSSEC-signed. Confirm DNSSEC with delv mail.peer.tld; if ad flag missing, resolver is not validating — fix local resolver or smtp_dns_support_level.
postscreen blocks legitimate senders (ticketing, monitoring hosts) They hit a DNSBL or tripped greet enforcement. Add a permit entry in postscreen_access.cidr, or lower postscreen_dnsbl_threshold. Keep zen.spamhaus.org — the others are the usual false-positive sources.
Submission (587) rejects with 454 4.7.0 TLS not available Certificate or key path wrong, or permissions block postfix from reading the key. ls -l the key (expect root:postfix 640); postfix check; systemctl restart postfix and re-test with openssl s_client -starttls smtp -connect mail.example.com:587.
Users complain 465 "works sometimes" Client sending STARTTLS to the implicit-TLS port — it hits raw TLS bytes and times out. Configure the client as "SSL/TLS" (not "STARTTLS") on 465. Or move them to 587.
Reusable. Pair this with Dovecot for IMAP/Submission SASL, Postfix Config for virtual domains and relay hosts, and Rsyslog Forwarding (TLS) to ship the mail logs somewhere you can actually search them.