Nobody Got the Password Reset, and Two Different Reasons Why
Two systems decide whether a reset email arrives, they fail identically from the customer's side, and confusing them cost us weeks. Plus the DNS write that nearly deleted a record we were not editing.
9 min readSeptember 2026
EmailDNSSPFDKIMDMARCAWS SESRoute 53Authentication
A customer says the password reset never arrived. There are two entirely separate systems that produce that sentence, they have nothing to do with each other, and treating them as one thing is where most of the time on this went.
This is the transferable half of getting transactional email working for a product that sends password resets: the distinction, the order to do things in, the DNS trap that nearly cost us more than the email did, and the product decisions around account recovery that only show up on the worst day.
Problem
Two systems decide whether your mail lands, and they fail in ways a customer describes identically.
The first is your provider's sending limits. Most transactional email services start you in a sandbox where you can only send to addresses you have personally verified. This one is absolute: the mail is never sent at all. It was our real blocker for weeks.
The second is domain authentication, which is DNS. Get it wrong and the mail is sent but filed as spam. The customer says they never got it, and they are telling the truth as they experience it.
The first is about permission. The second is about credibility. Neither has anything to do with your customer's own domain, which is the question people ask first:
- If someone signs up with their own business domain, can they still reset their password? Yes
- Their domain needs no records, no setup, and no relationship with you
- What stops them is your sandbox, or your domain being untrusted enough to get filtered
The Letterhead Model
Your domain is company letterhead. Anyone can print letterhead with your name on it, because that is simply how SMTP works, and it is the whole problem. The three records are three different answers to it.
SPF is the guest list: a public note saying only these servers may send letters on my letterhead. DKIM is the wax seal, a signature only you can produce, verified against a public key in your DNS. DMARC is the instruction card: if a letter has no valid seal and is not on the guest list, here is what to do, and send me a report either way.
We had the seal and nothing else. DKIM alone was carrying deliverability. The gap that mattered most was DMARC, because with no policy published anyone can send mail as your domain and receivers have no instruction to reject it. For a company whose emails say click here to reset your password, that is about the most valuable thing on earth for somebody to forge.
Solution
Two records, both deliberately toothless on day one.
SPF published with a softfail rather than a hardfail means be suspicious, not reject. DMARC published at p=none means report, do not act. Together they change no delivery outcome anywhere. Nothing that works today stops working. They turn the lights on, and a few weeks of reports tell you who is really sending as you, at which point you tighten knowing exactly what you will break.
Going straight to a reject policy before reading any reports is how people silently kill their own invoices, newsletters and CRM mail.
Read the guest list, do not guess it
- SPF is a closed list: any sender you forget starts failing the day you publish
- Your MX records name the mail platform, apex TXT tokens name the SaaS tools, and DKIM selectors name whoever already signs as you
- Ours returned exactly two senders, so the record could be exact rather than hopeful
The 10-lookup limit
- SPF permits 10 DNS lookups while evaluating, and exceeding it fails worse than having no record
- Each include costs at least one, and nested includes inside those count too
- Ours cost 2 of 10, because both includes are flat IP lists with no nesting
- Providers that chain several include levels can eat the whole budget alone
Where the reports go
- The DMARC report address must be a mailbox that actually exists
- A record whose reports bounce is worse than no record: the appearance of monitoring without the substance
- Expect daily XML attachments from the large receivers
The Trap That Nearly Broke the Domain
Route 53 stores every DNS record of the same name and type as one record set.
Our domain apex already held a Google Workspace verification token as a TXT record. The obvious way to add SPF is an UPSERT of a TXT record at the apex carrying the SPF value. That would have silently deleted the verification token, with no error and no warning, because UPSERT replaces the whole set rather than the values you mention. The correct change re-states everything that has to survive.
Always read the existing set first, and write it back in full. Then audit the rest of the zone, because anything holding more than one value has the same shape. Ours found three, in ascending order of how bad a mistake would be.
- TXT at the apex, 2 values: a partial write silently revokes domain verification
- MX at the apex, 5 values: a partial write stops all incoming mail for the whole company
- NS at the apex, 4 values: a partial write stops the entire domain resolving
MX is worse than the record we were actually working on. A partial MX write does not degrade delivery, it ends it, until somebody notices.
The Same Shape in Other Systems
This is a class of bug rather than a DNS quirk. The pattern is an interface that looks like it edits a field but actually replaces a container, and once you have the shape in your head you find it everywhere.
Parameter Store and secret blobs. We keep a deployment environment as one encrypted parameter holding an entire env file. Writing a partial value with overwrite replaces every secret at once: API keys, database URL, mail config, all of it. There is no merge, and no DNS is involved.
HTTP PUT on a resource. PUT means replace the representation, and a schema whose fields all have defaults will happily accept a one-field body and write defaults over everything else, returning 200. We confirmed it against a running server on a company profile endpoint: sending only a name emptied five other fields and reported success. That is correct PUT semantics and a loaded gun aimed at the record every score and generated document is computed against. If a client should be able to send part of a resource, the handler has to use only the fields actually sent rather than every field the schema defines.
Client-side form state. The same bug turns up in UI code, where loading a fresh copy of a record into a form that already holds unsaved edits discards them. Key the reset on the record's id rather than the record object, or every background refresh eats what the user was typing.
The general rule: before any write, ask whether the thing you are addressing is a field or a container. If it is a container, read it first and write it back whole.
The Order That Worked
Sequence matters here more than usual, because several of these steps are only verifiable once the one before them is done.
- Leaving the sandbox takes a request and a wait, and until it lands no customer outside your verified list receives anything
- Providers put you back in the sandbox for exceeding roughly 5% bounces or 0.1% complaints
- Verify from several public resolvers, not your own: yours may be cached or split-horizon
- Confirm in the same pass that MX, DKIM selectors and verification tokens are all still intact
What This Changed About Password Reset
Confirm the address at signup. Every route back into a locked-out account runs through the address typed at registration, so a typo there is unrecoverable and silent. Nothing reveals it until the day somebody needs a reset, by which point the mail has been going to a stranger for months. One plain message on day one turns that into something the customer notices while they can still sign in and fix it. It does not need to be a verification gate, because holding an account hostage to an inbox round-trip costs more signups than it protects.
Never echo a rejected password. Some frameworks include the offending value in validation errors by default, which for a password field means it travels back over the wire and lands in browser devtools, proxy logs, and any error reporter added later.
Answer identically for real and unknown addresses. Same body, same status, same timing. Anything that varies turns the endpoint into a free lookup of who your customers are.
Notify on credential changes, and put no link in it. Changing a password is exactly what somebody does after stealing a session, so silence at that moment is the worst available default. A security email that trains people to click is working against itself.
Send after you commit, and never let a mail failure break the operation. Every notification is a side effect of work that already succeeded, so if the send throws, the customer is told their password change failed when it did not, and the retry hits an account that already exists.
That last one carries the sharpest lesson of the whole exercise. We made the no-raise guarantee structural, wrapping the entire send rather than the network call, and then wrote a test for it by breaking the path deliberately. The first version of that test broke the network call, which already sat inside a try block, so it passed with and without the new guard. The guard was unverified and we nearly recorded it as verified. Break the thing before the existing safety net, or you are only testing the net.
Results
SPF, DKIM and DMARC all resolve, the Workspace verification token survived, and MX and both DKIM selectors are untouched, each confirmed from several public resolvers after propagation. Reports are arriving at a mailbox that exists. Nothing about delivery changed on the day, which was the entire point of publishing softfail and p=none.
The partial-write audit was worth more than the email work. It found the profile endpoint that would silently empty a company's Digital Twin, which is the record every opportunity score is computed against, and it found it before a customer did.
The first thing to check when somebody says a reset never arrived is the provider's suppression list. Providers stop mailing an address after a hard bounce, and that is invisible from inside your own application: the send looks fine and simply goes nowhere.
Get the next field note
One email when we publish, covering what we built, what it cost, and what went wrong.
Related
The Power of Privacy: How Ephemeral Backends Make Our Products Harder to Breach
You can't leak what you never stored. How one architectural decision, backends that hold as little user data as possible for as short a time as possible, runs through Aegis BI, MyBudgetNerd, and Thera, and why it's a security strategy, not just a privacy stance.
10 min read
Rebuilding the Infrastructure: One Cluttered AWS Account Into Four
Two live products shared one AWS account, three deployment systems, and a single disk. Over four days we rebuilt it into an account per app without taking either product down, plus the two near-misses that came closest to making that sentence untrue.
11 min read
The Agent That Cannot See My Tax File
An AI agent that has read a company's strategy, brand, product and marketing records, and cannot reach the tax ID, the bank statements or the source code. Not by policy. By a check that refuses to finish.
7 min read
Thera
AI capture intelligence for government contractors, in production and carrying real pipelines: a Digital Twin of your company, explainable bid/no-bid scoring, AI opportunity briefings, and an opt-in teaming network.
In Production · Signups Open
Ready to Deliver Your Next Data Product?
Share your goals by email and we will respond with a practical next-step plan for scope, timeline, and delivery.


© 2026 Athena Data Labs
A division of Athena Analytics L.L.C.
