What a sender does with your policy
RFC 8461 section 3.3 describes the sender's side, and it is worth reading once because the whole caching problem is in it. Every time a sending server has a message for your domain it does this:
- Looks up the TXT record at
_mta-sts.yourdomainand reads theid. - If it has a cached policy for your domain, the cached
idmatches, and the cache has not passed itsmax_age, it uses the cached policy. No HTTPS fetch. - If the
idhas changed, or the cache has expired, or there is no cache, it fetcheshttps://mta-sts.yourdomain/.well-known/mta-sts.txt, validates it, and caches the result formax_ageseconds. - If that fetch fails for any reason (host down, certificate expired, redirect, wrong content), it keeps using whatever it had cached until that copy expires. If it had nothing cached, it delivers as though you had no policy.
So there are two clocks. The id is the fast one: change it and senders re-read your policy on their next delivery. max_age is the slow one: it is how long a copy survives when nobody tells the sender to look again, or when the sender tries to look and cannot.
The two ways this goes wrong
1. You changed the policy and did not change the id
Senders keep applying the old policy for up to max_age. If the old policy is mode: enforce and lists MX hosts you no longer use, every enforcing sender (Gmail, Outlook.com, and a growing list of others) refuses to deliver to your new hosts, retries for a few days, and then bounces. The bounce reason usually names MTA-STS, but it is on the sender's side, so your users see "message could not be delivered" from their correspondents, and you see nothing at all in your own logs.
2. The policy host stopped answering
The certificate on mta-sts.yourdomain expired, someone put a redirect in front of it, the static host it lived on was decommissioned. Senders that have a cached copy carry on with it until max_age runs out. Then they treat you as having no policy, and the protection you set MTA-STS up for is gone. Nothing bounces, nothing is logged, and unless you are reading TLS-RPT reports, which would show "policy could not be fetched" for weeks, you find out when someone asks why your domain no longer scores on a security scan.
The MX migration, step by step
Here is the sequence that produces the bounce, so you can see where the cache bites.
| When | What you did | What an enforcing sender does |
|---|---|---|
| Before | Policy: mode: enforce, mx: mx1.oldhost.example, max_age: 1209600 (two weeks) | Cached that policy at its last fetch, up to two weeks ago |
| Day 0 | Change MX records to the new provider | Looks up MX, gets the new hosts; checks its cached policy; no mx line matches them; refuses to connect |
| Day 0 to 2 | Nothing, because nothing looks wrong on your side | Retries on its normal schedule, fails the same way each time |
| Day 2 to 5 | Users report bounces from Gmail contacts | Gives up and bounces the message to its sender |
| Day 5 | Update the policy file with the new hosts | Still using the cached copy: the id did not change |
| Day 5 + a minute | Change the id in the TXT record | Next delivery attempt sees the new id, fetches the new policy, delivers |
The order that does not lose mail is the reverse. Add the new MX hosts to the policy alongside the old ones, change the id, and wait at least max_age so that every sender's cache has had a chance to refresh (they refresh on their next delivery to you, so a busy sender refreshes within minutes and a quiet one may take the full period). Then change the MX records. Then, once the old hosts are out of service, remove them from the policy with another new id. During the transition both sets of hosts need valid certificates, because the policy says they are all legitimate.
Choosing max_age
The RFC allows up to 31557600 seconds (a year) and its own example uses 604800 (a week), which is where most deployments end up. The trade-off is simple to state:
| max_age | Protection if the policy host is down | Recovery if you publish a mistake and forget the id |
|---|---|---|
86400 (a day) | Lapses within a day | Within a day |
604800 (a week) | Holds for a week | Up to a week of refused mail |
2592000 (thirty days) | Holds for a month | Up to a month |
A day in testing mode, a week or two in enforce, is the pattern that has served well. Longer only makes sense if the policy host is on infrastructure you trust more than your mail servers, and it always means planning MX changes around the value. Shorter than a day in enforce mode is asking for the protection to lapse at the first hiccup on the policy host.
Checking where you stand
The email deliverability checker fetches the policy the way a sender does (no redirects followed), parses every line, matches each mx pattern against your real MX hosts, and writes max_age out as a duration with the sentence that matters: a sender that holds a copy keeps applying it for that long unless the id changes. An MX host the policy does not cover in enforce mode is graded as a fail, because that is a bounce in progress, not a warning. In testing mode the same finding is what to fix before you switch.
From a terminal, the two things to look at are the record and the raw fetch:
dig +short TXT _mta-sts.example.com curl -sSi https://mta-sts.example.com/.well-known/mta-sts.txt
You want a 200, Content-Type: text/plain, no Location header, and a body whose mx lines you can match by eye against dig +short MX example.com. If you are about to change something, note the id you are replacing, because the most common fix for "I changed the policy and nothing happened" is realising you did not change it.
Retiring a policy
The same cache applies in reverse. Deleting the TXT record and the file leaves senders with a cached enforce policy until it expires; if you also changed hosts, you have the migration bounce with no way to publish a fix. Publish mode: none with a new id first, leave it in place for at least the old max_age, and only then remove the record and the file.