Short answer
Password reset emails usually go to spam because the message isn't authenticated as your domain (SPF, DKIM and DMARC missing or not aligned with the From address), because the sending domain or IP has little or poor reputation, or because the links look risky: click-tracking redirects, URL shorteners or a domain that doesn't match the sender. Content is rarely the main cause.
Fix it in this order: publish SPF, DKIM and DMARC for the domain in your From address and confirm spf=pass dkim=pass dmarc=pass in a received message's headers; send from a domain your users recognise; link directly to your own domain with tracking off; and check that the address isn't on a suppression list. If corporate users report "expired" links, a security scanner is clicking them first.
On this page
- Short answer
- First, find out what actually happened
- Authentication and alignment
- Domain age, reputation and subdomains
- Shared IP reputation: who else is on the pool
- Links: tracking, shorteners and mismatched domains
- Content: plain beats clever
- Retries, duplicates and abuse of the reset form
- Bounces and suppressions: the reset that was never sent
- Corporate filters and link scanners
- Sending a reset with Groundmail
- How to test inbox placement
- Diagnosis checklist
- FAQ
- Sources
First, find out what actually happened
"The reset email went to spam" covers three different failures, and they have different fixes. Before changing anything, find out which one you have.
| What the user sees | What happened | Where to look |
|---|---|---|
| Email is in the spam or junk folder | Accepted by the mailbox, then filtered | Authentication results, domain and IP reputation, links |
| Email never arrives anywhere | Never sent, rejected, or quarantined by a corporate filter | Your send logs, bounce and suppression events, the receiving admin's quarantine |
| Email arrives but the link says "expired" | Something used the token before the user did | Link scanners, prefetching, duplicate sends invalidating older tokens |
A 200 from your email API means the message was accepted for delivery, not that it reached the inbox. Wire delivery, bounce and suppression webhooks into your logs so support can answer "did we send it, and what did the receiving server say?" without guessing. On Groundmail those are email.delivered, email.bounced, email.delivery_delayed and email.suppressed (webhooks docs).
Authentication and alignment
This is the most common cause and the first thing to check. Gmail, Outlook and Yahoo expect transactional mail to be authenticated, and Google and Yahoo now require SPF, DKIM and DMARC from bulk senders (sender requirements). Unauthenticated mail is filtered hard or rejected.
Passing SPF and DKIM isn't enough on its own. DMARC also needs them to align with the domain in the visible From address. Three different domains are involved in every message:
| Domain | Where it lives | Checked by | Aligns with From when |
|---|---|---|---|
| From domain | The From: header the user sees | DMARC (the domain everything is compared to) | - |
| Return-path (envelope sender) | MAIL FROM in the SMTP session; becomes the Return-Path header | SPF | It is the From domain or a subdomain of it (relaxed alignment) |
| DKIM signing domain | The d= tag in the DKIM-Signature header | DKIM | It is the From domain or a subdomain of it (relaxed alignment) |
The classic failure: the From address says no-reply@example.com, but the message is DKIM-signed with the email provider's own domain and the return path is the provider's bounce domain. SPF passes and DKIM passes, but neither aligns, so DMARC fails. Reading the headers is the only way to see this; the email itself looks fine.
The fix is to verify your own domain with your provider, so it signs with d=example.com and uses a return path under your domain. Full setup, with copy-paste records, is in SPF, DKIM and DMARC for transactional email.
Domain age, reputation and subdomains
Mailbox providers keep a reputation for your sending domain, built from the DKIM-signed mail that carries it. A domain registered last week has no history, and receivers treat unknown domains cautiously. If you launched on a new domain, expect some early filtering until real users open and act on your mail.
Sending transactional mail from a subdomain, such as mail.example.com or notify.example.com, gives it a reputation that is partly separate from the root domain. If your marketing team sends newsletters from example.com and someone's campaign draws complaints, your resets on mail.example.com are better insulated. Keep the subdomain for transactional mail only, and keep the From address recognisably yours: no-reply@mail.example.com is fine; an unrelated domain is not.
Links: tracking, shorteners and mismatched domains
Filters look hard at links in messages that ask someone to act on their account, because that is exactly what phishing does. A reset email whose links point somewhere the sender doesn't is a red flag.
- Turn click tracking off for auth mail. Tracking rewrites every link through a redirect hostname. That adds a domain the filter has to judge, delays the user, and puts the single-use token in another system's logs. You don't need click data on a reset email.
- No URL shorteners. Public shortener domains are heavily used in abuse, and they hide the destination. Link to your own domain.
- No raw IP links (
https://203.0.113.7/reset), and no staging orlocalhostURLs that slipped through from a misconfigured environment variable. - Match the link text and the destination. If the visible text shows
https://example.com/resetbut thehrefgoes elsewhere, that matches a phishing pattern. - Use the domain the user knows. The reset link, the From domain and the app's own domain should all be recognisably the same brand.
Content: plain beats clever
Content matters less than authentication and reputation, but a few habits help, and they also help the user act quickly.
- A plain, specific subject. "Reset your Example password" beats "Action required!!" or "Urgent: account security". Avoid all caps, exclamation marks and urgency language.
- Always send a text part alongside the HTML. HTML-only and image-only messages score worse, and some users read mail in clients that show text.
- Show the URL as text as well as a button, so it works in clients that strip HTML.
- A consistent From name and address. Use the same display name ("Example") and the same address every time. Changing them looks like a new, unknown sender.
- Nothing else in the message. No marketing banners, promotions or newsletter footers. A reset email has one job.
- Say what to do if they didn't ask, and how long the link lasts. It reduces support tickets and spam-button clicks from confused users.
Retries, duplicates and abuse of the reset form
How you send matters as much as what you send. Three patterns cause trouble:
- Duplicate sends from retries. A job retries after a timeout and the user gets three identical emails, each with a different token, and only the last one works. Send with an idempotency key tied to the token, so a retry returns the original result instead of sending again.
- Users hammering "resend". Throttle per address (for example, one reset email per minute) and reuse a still-valid token rather than issuing a new one each time. Bursts of identical messages to one address look automated.
- Bots submitting the forgot-password form. Attackers use reset forms to probe for accounts or to send mail to addresses that don't exist. Rate-limit the endpoint, only send when an account actually exists (while showing the same response either way), and watch for bounce spikes. Bounces from junk addresses damage the reputation your real users' resets depend on.
Bounces and suppressions: the reset that was never sent
Email providers keep a suppression list of addresses that hard-bounced or complained, and refuse to send to them again. That protects your reputation, but it means a user whose address bounced once (a typo they later fixed at their mail host, a full mailbox misread as permanent, an old complaint) will never receive a reset, and nothing looks wrong in your app unless you check.
When a user says "I never got it", check the suppression list before anything else. On Groundmail, a send to a suppressed address returns 422 with the address in details.suppressed and emits an email.suppressed webhook, so you can log it and show support a clear reason. Look an address up with GET /suppressions?email=..., and once you've confirmed the address now works, remove it with DELETE /suppressions?email=....
Corporate filters and link scanners
B2B products hit a separate set of problems. Microsoft 365 with Defender, and other secure email gateways, can quarantine a message so it never reaches the junk folder at all: the user often sees nothing, and usually only their admin can release it. If one customer's whole domain reports missing resets, ask their IT team to check quarantine and, if needed, allowlist your sending domain. Fixed sending IPs make that conversation easier, which is one reason B2B senders move to dedicated IPs.
These gateways also scan links. Many open each URL in a message, at delivery or at click time, to check where it leads. If your reset link consumes the token on a GET, the scanner uses it up and the user gets "this link has expired".
Sending a reset with Groundmail
Groundmail is transactional email on our own mail servers and our own IP space, with a transactional-only shared pool on every plan and dedicated IPs or dedicated servers when you need isolation. Domains are verified with SPF, DKIM and a return-path CNAME under your domain, so mail is signed as you and aligns for DMARC. Open and click tracking are off by default on new domains; leave click tracking off on any domain that carries auth mail.
This sends a reset with a text part, a link straight to your own domain, and an idempotency key tied to the token so retries can't send duplicates:
Node
import { Groundmail, GroundmailError } from "groundmail";
const gm = new Groundmail(process.env.GROUNDMAIL_API_KEY);
export async function sendPasswordReset(email: string, tokenId: string, token: string) {
const url = `https://example.com/reset?token=${token}`;
try {
return await gm.emails.send({
from: { email: "no-reply@mail.example.com", name: "Example" },
to: email,
subject: "Reset your Example password",
text: `Use this link to reset your password:\n\n${url}\n\nIt expires in 30 minutes. If you didn't ask for this, ignore this email.`,
html: `<p>Use this link to reset your password:</p>
<p><a href="${url}">Reset password</a></p>
<p>Or paste this into your browser: ${url}</p>
<p>It expires in 30 minutes. If you didn't ask for this, ignore this email.</p>`,
tags: [{ name: "type", value: "password_reset" }],
idempotencyKey: `password-reset:${tokenId}`,
});
} catch (err) {
if (err instanceof GroundmailError && err.status === 422) {
// Recipient is suppressed: log it so support can see why.
console.warn("reset not sent, suppressed", err.details);
return null;
}
throw err;
}
}curl
curl -X POST https://api.groundmail.dev/emails \
-H "Authorization: Bearer $GROUNDMAIL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: password-reset:tok_8f2c" \
-d '{
"from": { "email": "no-reply@mail.example.com", "name": "Example" },
"to": "ada@example.org",
"subject": "Reset your Example password",
"text": "Use this link to reset your password: https://example.com/reset?token=...",
"html": "<p><a href=\"https://example.com/reset?token=...\">Reset password</a></p>"
}'A retry with the same key and the same body returns the original response instead of sending a second email. If a domain had click tracking turned on, turn it off with PATCH /domains/{id} and {"click_tracking": false} (domains docs).
How to test inbox placement
- 1
Send to real seed accounts
Keep test inboxes at Gmail, Outlook.com and Yahoo, plus a Microsoft 365 mailbox if you sell to businesses. Trigger a real reset from your production flow, not a hand-built test message, so you test the exact headers and links users get.
- 2
Read the Authentication-Results header
In Gmail use "Show original"; in Outlook, view the message source. Look for
spf=pass,dkim=passanddmarc=pass, and check thatheader.from, the DKIMheader.iord=domain andsmtp.mailfromare all your domain or its subdomains. Microsoft also addscompauth=passwhen its composite check passes. - 3
Check the links
Hover every link in the received message. Each should point at your own domain, with no redirect hostname in front of it.
- 4
Watch reputation over time
Set up Google Postmaster Tools for your sending domain. Once you send enough to Gmail, it shows your spam rate and authentication results. Track bounces and complaints from your provider's webhooks alongside it.
What passing looks like (illustrative)
Authentication-Results: mx.google.com;
dkim=pass header.i=@mail.example.com header.s=selector1;
spf=pass smtp.mailfrom=rp.mail.example.com;
dmarc=pass (p=NONE) header.from=mail.example.comDiagnosis checklist
| Symptom | Likely cause | Fix |
|---|---|---|
dmarc=fail with spf=pass and dkim=pass | SPF and DKIM pass for the provider's domain, not yours | Verify your own domain so DKIM signs d= your domain and the return path is under it |
dkim=none or dkim=fail | DKIM record missing, truncated or on the wrong host | Re-check the selector TXT record; see SPF, DKIM and DMARC |
spf=permerror | Two SPF records, or more than 10 DNS lookups | Merge into one SPF record and trim includes |
| Spam at Gmail only, headers clean | New domain or low domain reputation | Use a transactional subdomain, keep sending steadily, check Postmaster Tools |
| Spam everywhere, headers clean | Shared IP reputation or risky links | Ask what the pool carries; remove tracking and shorteners |
| Never arrives, no bounce, one company only | Corporate quarantine | Ask their admin to check quarantine and allowlist your domain or IPs |
| Never arrives for one user, API returned an error | Address is suppressed after a bounce or complaint | Look it up on the suppression list; remove once confirmed working |
| Link "expired" on first click | Link scanner consumed a single-use token | Consume the token on POST from a confirm button, not on GET |
| User gets several resets, only the last works | Retries or repeated clicks issue new tokens | Idempotency key per token; throttle and reuse live tokens |
| Arrives minutes late | Queueing in your app or provider, or receiver deferrals | Send auth mail from a fast path, not a batch queue; check email.delivery_delayed events |
Frequently asked questions
Why do my password reset emails go to spam?
Most often because the message isn't authenticated and aligned with the domain in the From address: SPF, DKIM and DMARC are missing, or they pass for your email provider's domain instead of yours. After that, the usual causes are a new or low-reputation sending domain, a shared IP pool that carries other kinds of mail, and links that go through tracking redirects or shorteners.
Why do password reset emails not arrive at all?
Check three things: whether your app actually sent it (logs and webhook events), whether the address is on your provider's suppression list after an earlier bounce or complaint, and, for business addresses, whether a corporate filter such as Microsoft 365 Defender quarantined it. Quarantined mail doesn't show in the junk folder, and usually only the recipient's admin can release it.
Why does my reset link say it has expired the first time the user clicks it?
A security scanner or link preview opened the URL before the user did, and your app consumed the token on that request. Make the link open a page with a button, and only use the token when the user submits it with a POST.
Should I use click tracking on password reset emails?
No. Click tracking rewrites the link through a redirect domain, which adds a domain filters have to judge on account-security mail, and it passes the token through another system. On Groundmail, click tracking is off by default on new domains.
Should password reset emails come from a subdomain?
It's a good idea. Sending transactional mail from a subdomain such as mail.example.com keeps its reputation partly separate from marketing mail on your root domain. Keep the subdomain under the brand users know, and authenticate it with SPF, DKIM and DMARC like any other sending domain.
How do I check whether my reset email passed SPF, DKIM and DMARC?
Send a real reset to a Gmail or Outlook account and open the raw message ("Show original" in Gmail, view source in Outlook). The Authentication-Results header should show spf=pass, dkim=pass and dmarc=pass, with your domain as header.from and in the DKIM and mailfrom domains.
Will a dedicated IP stop my reset emails going to spam?
Not on its own. A dedicated IP isolates your reputation from other senders, but it doesn't fix missing authentication, a new domain or risky links, and a low-volume dedicated IP can do worse than a well-run shared pool. Fix authentication first, then see shared vs dedicated IP.
Sources
Facts on this page about other companies' products, prices and policies come from these public pages. They change, so check them before you decide.