[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"blog-6da7c71d-411a-4be7-88f0-52f8f3e2bba9":3},{"id":4,"body":5,"uuid":6,"created_at":7,"updated_at":8,"brand":9,"header":10,"short_body":11,"image":12,"published":13,"published_at":14,"tags":15},16,"\u003Cp>A password reset email is the single most time-sensitive message your application will ever send. When a locked-out user clicks \"Forgot password,\" they are stuck on a screen, watching their inbox, and counting the seconds. If that email lands late, lands in spam, or never arrives at all, you don't just lose a session — you lose trust, support tickets pile up, and churn climbs. Yet most engineering teams treat the password reset email as an afterthought: a token, a link, and a \u003Ccode>send()\u003C\u002Fcode> call wired up in an afternoon.\u003C\u002Fp>\n\u003Cp>This guide treats it as what it actually is — a critical-path authentication email that has to be secure, fast, and inbox-reliable. We'll walk the full flow end to end: generating and expiring tokens the right way, writing reset content that converts and survives spam filters, configuring SPF, DKIM, and DMARC so your message authenticates, and shipping production-ready code you can adapt today. By the end you'll have a password reset flow that actually delivers, both in the security sense and the deliverability sense.\u003C\u002Fp>\n\u003Ch2>Why the Password Reset Email Is Harder Than It Looks\u003C\u002Fh2>\n\u003Cp>The deceptively simple \"send a link\" flow hides three independent failure domains, and a weakness in any one of them breaks the whole experience.\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Security:\u003C\u002Fstrong> The reset link is a bearer credential. Anyone who holds it can take over the account. Weak tokens, no expiry, or a leaky enumeration endpoint turn your reset flow into an attack surface.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Deliverability:\u003C\u002Fstrong> Reset emails are transactional and urgent, but mailbox providers don't know that automatically. Without proper authentication, your message gets throttled, filtered to spam, or silently dropped.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>User experience:\u003C\u002Fstrong> Latency, confusing copy, broken links on mobile, and tokens that expire before the user reads the email all generate support load and abandonment.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>A 2023 Cloudflare analysis of authentication traffic found that account-recovery flows are among the most-targeted endpoints for credential-stuffing and enumeration attacks, precisely because they bridge an unauthenticated user to an authenticated session. Meanwhile, Google's Postmaster Tools guidelines make clear that unauthenticated bulk and transactional mail is increasingly likely to be rejected outright. The password reset email sits squarely at the intersection of these two pressures, which is why it deserves real engineering attention rather than a copy-pasted snippet.\u003C\u002Fp>\n\u003Ch2>Anatomy of a Secure Password Reset Flow\u003C\u002Fh2>\n\u003Cp>Before writing any code, agree on the sequence. A robust flow looks like this:\u003C\u002Fp>\n\u003Col>\n\u003Cli>User submits their email address on the \"forgot password\" form.\u003C\u002Fli>\n\u003Cli>Server looks up the account \u003Cstrong>without revealing whether it exists\u003C\u002Fstrong> (more on enumeration below).\u003C\u002Fli>\n\u003Cli>If the account exists, the server generates a cryptographically random token, stores a \u003Cstrong>hash\u003C\u002Fstrong> of it with an expiry timestamp, and invalidates any prior tokens.\u003C\u002Fli>\n\u003Cli>The server sends a password reset email containing a link with the raw token.\u003C\u002Fli>\n\u003Cli>User clicks the link; the server hashes the supplied token, looks it up, checks expiry and single-use status.\u003C\u002Fli>\n\u003Cli>User sets a new password; the server consumes the token, updates the password hash, and optionally invalidates active sessions.\u003C\u002Fli>\n\u003Cli>The server sends a confirmation email (\"your password was changed\").\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>Each numbered step maps to a concrete control. Skip one and you've created a gap.\u003C\u002Fp>\n\u003Ch3>Step 1–2: Look Up the User Without Leaking Existence\u003C\u002Fh3>\n\u003Cp>The classic mistake is returning \"No account found with that email.\" That message is a free gift to attackers performing \u003Cstrong>user enumeration\u003C\u002Fstrong> — they now know exactly which addresses are registered. The fix is to always return the same neutral response:\u003C\u002Fp>\n\u003Cblockquote>\n\u003Cp>\"If an account exists for that email, we've sent reset instructions.\"\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Cp>Send the email only when the account exists, but never branch your HTTP response on it. Keep response timing roughly constant too, so attackers can't infer existence from latency.\u003C\u002Fp>\n\u003Ch2>Secure Token Generation and Expiry\u003C\u002Fh2>\n\u003Cp>The token is the heart of the password reset email. Get this wrong and nothing else matters.\u003C\u002Fp>\n\u003Ch3>Use a Cryptographically Secure Random Token\u003C\u002Fh3>\n\u003Cp>Do \u003Cstrong>not\u003C\u002Fstrong> use \u003Ccode>uuid4()\u003C\u002Fcode> as your only source of entropy if your platform's UUID isn't backed by a CSPRNG, and never use predictable values like \u003Ccode>user_id + timestamp\u003C\u002Fcode>. Use a dedicated secure random generator with at least 128 bits (32 hex chars) — 256 bits is a comfortable default.\u003C\u002Fp>\n\u003Ch3>Store a Hash, Not the Raw Token\u003C\u002Fh3>\n\u003Cp>Treat the reset token like a password. If your database leaks, an attacker with raw tokens can reset accounts directly. Store a SHA-256 hash of the token and compare hashes on lookup. The raw token lives only in the email.\u003C\u002Fp>\n\u003Ch3>Set a Short Expiry and Make Tokens Single-Use\u003C\u002Fh3>\n\u003Cp>Industry consensus (OWASP Forgot Password Cheat Sheet) is a \u003Cstrong>short lifetime\u003C\u002Fstrong> — 15 minutes to 1 hour. Long enough for a user to check email, short enough to limit exposure. Each token must be:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Single-use:\u003C\u002Fstrong> consumed the moment a password is set.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Invalidated on reissue:\u003C\u002Fstrong> requesting a new link kills the old one.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Expired on success:\u003C\u002Fstrong> changing the password (or logging in) voids outstanding tokens.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Here is a production-ready Python example. Imports go at the top, per good practice.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import hashlib\nimport secrets\nfrom datetime import datetime, timedelta, timezone\nfrom dataclasses import dataclass\n\nTOKEN_TTL = timedelta(minutes=30)\n\n\n@dataclass\nclass ResetToken:\n    user_id: int\n    token_hash: str\n    expires_at: datetime\n    used: bool = False\n\n\ndef generate_reset_token() -&gt; tuple[str, str]:\n    &quot;&quot;&quot;Return (raw_token_for_email, token_hash_for_db).&quot;&quot;&quot;\n    raw_token = secrets.token_urlsafe(32)  # ~256 bits of entropy\n    token_hash = hashlib.sha256(raw_token.encode()).hexdigest()\n    return raw_token, token_hash\n\n\ndef create_reset_record(user_id: int) -&gt; tuple[str, ResetToken]:\n    raw_token, token_hash = generate_reset_token()\n    record = ResetToken(\n        user_id=user_id,\n        token_hash=token_hash,\n        expires_at=datetime.now(timezone.utc) + TOKEN_TTL,\n    )\n    # Invalidate prior tokens for this user, then persist `record`.\n    return raw_token, record\n\n\ndef verify_reset_token(raw_token: str, record: ResetToken) -&gt; bool:\n    candidate_hash = hashlib.sha256(raw_token.encode()).hexdigest()\n    if record.used:\n        return False\n    if datetime.now(timezone.utc) &gt; record.expires_at:\n        return False\n    return secrets.compare_digest(candidate_hash, record.token_hash)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Note the use of \u003Ccode>secrets.compare_digest\u003C\u002Fcode> for a constant-time comparison, which prevents timing attacks against the hash lookup.\u003C\u002Fp>\n\u003Ch3>Token Strategy Comparison\u003C\u002Fh3>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Approach\u003C\u002Fth>\n\u003Cth>Entropy\u003C\u002Fth>\n\u003Cth>Stored as\u003C\u002Fth>\n\u003Cth>Revocable\u003C\u002Fth>\n\u003Cth>Verdict\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Ccode>user_id\u003C\u002Fcode> + timestamp\u003C\u002Ftd>\n\u003Ctd>Near zero\u003C\u002Ftd>\n\u003Ctd>Plaintext\u003C\u002Ftd>\n\u003Ctd>No\u003C\u002Ftd>\n\u003Ctd>Never use\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>uuid4()\u003C\u002Fcode> only\u003C\u002Ftd>\n\u003Ctd>~122 bits\u003C\u002Ftd>\n\u003Ctd>Plaintext\u003C\u002Ftd>\n\u003Ctd>Manual\u003C\u002Ftd>\n\u003Ctd>Weak — predictable libs, stored raw\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>secrets.token_urlsafe(32)\u003C\u002Fcode> + SHA-256 hash\u003C\u002Ftd>\n\u003Ctd>~256 bits\u003C\u002Ftd>\n\u003Ctd>Hash\u003C\u002Ftd>\n\u003Ctd>Yes\u003C\u002Ftd>\n\u003Ctd>Recommended\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Signed JWT (no DB record)\u003C\u002Ftd>\n\u003Ctd>High\u003C\u002Ftd>\n\u003Ctd>Self-contained\u003C\u002Ftd>\n\u003Ctd>\u003Cstrong>Hard to revoke\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Avoid — can't enforce single-use\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>JWTs are tempting because they're stateless, but a stateless token can't be marked \"used\" without a server-side record — which defeats the point. Stick with a random token plus a hashed database row.\u003C\u002Fp>\n\u003Ch2>Password Reset Email Content Best Practices\u003C\u002Fh2>\n\u003Cp>A secure token is useless if the user never acts on the email. The content of a password reset email has to be instantly recognizable, trustworthy, and frictionless.\u003C\u002Fp>\n\u003Ch3>Subject Line and Sender\u003C\u002Fh3>\n\u003Cul>\n\u003Cli>\u003Cstrong>Subject:\u003C\u002Fstrong> Be direct and specific. \"Reset your Postwing password\" beats clever copy. Avoid spammy words (\"URGENT!!!\", \"Click here NOW\").\u003C\u002Fli>\n\u003Cli>\u003Cstrong>From name:\u003C\u002Fstrong> Use a recognizable brand sender, e.g. \u003Ccode>Postwing &lt;security@postwing.app&gt;\u003C\u002Fcode>.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>From a subdomain\u003C\u002Fstrong> dedicated to transactional mail (e.g. \u003Ccode>mail.yourdomain.com\u003C\u002Fcode>) so reputation stays separate from marketing sends.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>Body Content That Builds Trust\u003C\u002Fh3>\n\u003Cp>A good reset email is short and answers four questions immediately:\u003C\u002Fp>\n\u003Col>\n\u003Cli>\u003Cstrong>What is this?\u003C\u002Fstrong> \"We received a request to reset your password.\"\u003C\u002Fli>\n\u003Cli>\u003Cstrong>What do I do?\u003C\u002Fstrong> A single, prominent button: \"Reset Password.\"\u003C\u002Fli>\n\u003Cli>\u003Cstrong>How long do I have?\u003C\u002Fstrong> \"This link expires in 30 minutes.\"\u003C\u002Fli>\n\u003Cli>\u003Cstrong>What if it wasn't me?\u003C\u002Fstrong> \"If you didn't request this, you can safely ignore this email.\"\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>Include the raw URL as plain text alongside the button — some clients strip or break buttons, and security-conscious users want to see where the link points.\u003C\u002Fp>\n\u003Ch3>A Clean HTML Template\u003C\u002Fh3>\n\u003Cpre>\u003Ccode class=\"language-html\">&lt;!DOCTYPE html&gt;\n&lt;html lang=&quot;en&quot;&gt;\n&lt;body style=&quot;margin:0;padding:0;background:#f4f4f5;font-family:Arial,sans-serif;&quot;&gt;\n  &lt;table role=&quot;presentation&quot; width=&quot;100%&quot; cellpadding=&quot;0&quot; cellspacing=&quot;0&quot;&gt;\n    &lt;tr&gt;\n      &lt;td align=&quot;center&quot; style=&quot;padding:32px 16px;&quot;&gt;\n        &lt;table role=&quot;presentation&quot; width=&quot;480&quot; cellpadding=&quot;0&quot; cellspacing=&quot;0&quot;\n               style=&quot;background:#ffffff;border-radius:8px;padding:32px;&quot;&gt;\n          &lt;tr&gt;&lt;td&gt;\n            &lt;h1 style=&quot;font-size:20px;margin:0 0 16px;&quot;&gt;Reset your password&lt;\u002Fh1&gt;\n            &lt;p style=&quot;font-size:15px;line-height:1.6;color:#3f3f46;&quot;&gt;\n              We received a request to reset the password for your account.\n              Click the button below to choose a new password.\n            &lt;\u002Fp&gt;\n            &lt;p style=&quot;text-align:center;margin:28px 0;&quot;&gt;\n              &lt;a href=&quot;https:\u002F\u002Fapp.example.com\u002Freset?token=RAW_TOKEN&quot;\n                 style=&quot;background:#16a34a;color:#ffffff;text-decoration:none;\n                        padding:12px 28px;border-radius:6px;font-size:15px;\n                        display:inline-block;&quot;&gt;\n                Reset Password\n              &lt;\u002Fa&gt;\n            &lt;\u002Fp&gt;\n            &lt;p style=&quot;font-size:13px;color:#71717a;line-height:1.6;&quot;&gt;\n              This link expires in 30 minutes. If you didn't request a reset,\n              you can safely ignore this email — your password won't change.\n            &lt;\u002Fp&gt;\n            &lt;p style=&quot;font-size:12px;color:#a1a1aa;word-break:break-all;&quot;&gt;\n              Or paste this link into your browser:\n              https:\u002F\u002Fapp.example.com\u002Freset?token=RAW_TOKEN\n            &lt;\u002Fp&gt;\n          &lt;\u002Ftd&gt;&lt;\u002Ftr&gt;\n        &lt;\u002Ftable&gt;\n      &lt;\u002Ftd&gt;\n    &lt;\u002Ftr&gt;\n  &lt;\u002Ftable&gt;\n&lt;\u002Fbody&gt;\n&lt;\u002Fhtml&gt;\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Content Rules That Affect Deliverability\u003C\u002Fh3>\n\u003Cul>\n\u003Cli>\u003Cstrong>Always send a plain-text alternative\u003C\u002Fstrong> alongside HTML (a multipart\u002Falternative message). HTML-only emails score worse with spam filters.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Keep the image-to-text ratio low.\u003C\u002Fstrong> A reset email that is one big image looks like a phishing attempt to filters.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Avoid URL shorteners.\u003C\u002Fstrong> Use your own authenticated domain in links; shorteners hurt reputation and look like cloaking.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Match the link domain to your sending domain\u003C\u002Fstrong> where possible — mismatches trigger phishing heuristics.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>Deliverability: SPF, DKIM, and DMARC for Authentication Email\u003C\u002Fh2>\n\u003Cp>You can write the perfect password reset email and still have it land in spam if the mailbox provider can't verify you sent it. Authentication is non-negotiable for any authentication email — especially after Google and Yahoo's 2024 bulk-sender requirements made SPF, DKIM, and DMARC effectively mandatory for reliable inbox placement.\u003C\u002Fp>\n\u003Ch3>The Three Records Explained\u003C\u002Fh3>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Standard\u003C\u002Fth>\n\u003Cth>What it proves\u003C\u002Fth>\n\u003Cth>Where it lives\u003C\u002Fth>\n\u003Cth>Failure symptom\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>SPF\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>The sending server's IP is authorized for the domain\u003C\u002Ftd>\n\u003Ctd>TXT record on the domain\u003C\u002Ftd>\n\u003Ctd>\"via\" warnings, soft-fail to spam\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>DKIM\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>The message wasn't altered and came from the domain\u003C\u002Ftd>\n\u003Ctd>TXT record + signature in headers\u003C\u002Ftd>\n\u003Ctd>Tampering flags, spam folder\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>DMARC\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Tells receivers what to do when SPF\u002FDKIM fail, and aligns the From domain\u003C\u002Ftd>\n\u003Ctd>TXT record at \u003Ccode>_dmarc.yourdomain\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Outright rejection under \u003Ccode>p=reject\u003C\u002Fcode>\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Ch3>SPF: Authorize Your Senders\u003C\u002Fh3>\n\u003Cp>SPF is a DNS TXT record listing which servers may send mail for your domain. If you send through a provider, you include their mechanism:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>v=spf1 include:_spf.postwing.app ~all\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Keep SPF under the 10-DNS-lookup limit, and use \u003Ccode>~all\u003C\u002Fcode> (soft fail) or \u003Ccode>-all\u003C\u002Fcode> (hard fail) — never \u003Ccode>+all\u003C\u002Fcode>, which authorizes everyone.\u003C\u002Fp>\n\u003Ch3>DKIM: Sign Every Message\u003C\u002Fh3>\n\u003Cp>DKIM adds a cryptographic signature to each email's headers using a private key, with the public key published in DNS. Receivers verify the signature to confirm the message is authentic and unmodified. Your email provider gives you a public key to publish as a TXT record, typically at a selector like \u003Ccode>pw1._domainkey.yourdomain.com\u003C\u002Fcode>. Once published, every outgoing password reset email is signed automatically.\u003C\u002Fp>\n\u003Ch3>DMARC: Set Policy and Get Reports\u003C\u002Fh3>\n\u003Cp>DMARC ties SPF and DKIM together with \u003Cstrong>alignment\u003C\u002Fstrong> (the authenticated domain must match your visible From domain) and tells receivers how to handle failures:\u003C\u002Fp>\n\u003Cpre>\u003Ccode>v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@yourdomain.com; adkim=s; aspf=s; pct=100\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Start with \u003Ccode>p=none\u003C\u002Fcode> to monitor via the aggregate reports (\u003Ccode>rua\u003C\u002Fcode>), confirm your legitimate mail passes alignment, then progress to \u003Ccode>p=quarantine\u003C\u002Fcode> and finally \u003Ccode>p=reject\u003C\u002Fcode>. Moving to \u003Ccode>p=reject\u003C\u002Fcode> too early can blackhole your own reset emails.\u003C\u002Fp>\n\u003Ch3>A Realistic Rollout Order\u003C\u002Fh3>\n\u003Col>\n\u003Cli>Publish \u003Cstrong>SPF\u003C\u002Fstrong> and verify legitimate mail passes.\u003C\u002Fli>\n\u003Cli>Enable \u003Cstrong>DKIM\u003C\u002Fstrong> signing at your provider and publish the public key.\u003C\u002Fli>\n\u003Cli>Publish \u003Cstrong>DMARC\u003C\u002Fstrong> at \u003Ccode>p=none\u003C\u002Fcode> and read aggregate reports for a week or two.\u003C\u002Fli>\n\u003Cli>Confirm both SPF and DKIM \u003Cstrong>align\u003C\u002Fstrong> with your From domain.\u003C\u002Fli>\n\u003Cli>Tighten DMARC to \u003Ccode>p=quarantine\u003C\u002Fcode>, then \u003Ccode>p=reject\u003C\u002Fcode>.\u003C\u002Fli>\n\u003Cli>Warm a \u003Cstrong>dedicated subdomain\u003C\u002Fstrong> for transactional mail to keep reset-email reputation clean.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>This is exactly the kind of setup a purpose-built transactional provider handles for you. Postwing, for example, walks you through publishing SPF and DKIM records for your domain and signs every transactional message automatically, so your password reset emails authenticate from day one.\u003C\u002Fp>\n\u003Ch2>Sending the Email: A Production Example\u003C\u002Fh2>\n\u003Cp>With tokens and DNS in place, the actual send should be a thin, reliable call. Below is a complete example using a transactional API. Imports at the top.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import os\nimport smtplib\nfrom email.mime.multipart import MIMEMultipart\nfrom email.mime.text import MIMEText\n\nSMTP_HOST = os.environ[&quot;SMTP_HOST&quot;]\nSMTP_PORT = int(os.environ.get(&quot;SMTP_PORT&quot;, &quot;587&quot;))\nSMTP_USER = os.environ[&quot;SMTP_USER&quot;]\nSMTP_PASS = os.environ[&quot;SMTP_PASS&quot;]\nFROM_ADDR = &quot;Postwing &lt;security@mail.example.com&gt;&quot;\n\n\ndef build_reset_email(to_addr: str, reset_url: str) -&gt; MIMEMultipart:\n    msg = MIMEMultipart(&quot;alternative&quot;)\n    msg[&quot;Subject&quot;] = &quot;Reset your password&quot;\n    msg[&quot;From&quot;] = FROM_ADDR\n    msg[&quot;To&quot;] = to_addr\n\n    text = (\n        &quot;We received a request to reset your password.\\n\\n&quot;\n        f&quot;Reset it here (expires in 30 minutes):\\n{reset_url}\\n\\n&quot;\n        &quot;If you didn't request this, ignore this email.&quot;\n    )\n    html = f&quot;&quot;&quot;\\\n    &lt;p&gt;We received a request to reset your password.&lt;\u002Fp&gt;\n    &lt;p&gt;&lt;a href=&quot;{reset_url}&quot;&gt;Reset your password&lt;\u002Fa&gt; (expires in 30 minutes).&lt;\u002Fp&gt;\n    &lt;p&gt;If you didn't request this, you can safely ignore this email.&lt;\u002Fp&gt;\n    &quot;&quot;&quot;\n\n    msg.attach(MIMEText(text, &quot;plain&quot;))\n    msg.attach(MIMEText(html, &quot;html&quot;))\n    return msg\n\n\ndef send_reset_email(to_addr: str, reset_url: str) -&gt; None:\n    msg = build_reset_email(to_addr, reset_url)\n    with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:\n        server.starttls()\n        server.login(SMTP_USER, SMTP_PASS)\n        server.send_message(msg)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>In production, push the send onto a background queue so the HTTP request returns immediately, and add retry logic with exponential backoff for transient failures.\u003C\u002Fp>\n\u003Ch3>Don't Forget the Confirmation Email\u003C\u002Fh3>\n\u003Cp>After a successful reset, send a second authentication email: \"Your password was changed.\" If the change wasn't authorized by the real user, this is their early-warning signal to contact support. It's a small touch that materially improves account security and trust.\u003C\u002Fp>\n\u003Ch2>Common Mistakes to Avoid\u003C\u002Fh2>\n\u003Cp>Even experienced teams trip over the same issues. Watch for these:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Revealing whether an email is registered.\u003C\u002Fstrong> Branching the response on account existence enables enumeration. Always return a neutral message.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>No token expiry or expiry measured in days.\u003C\u002Fstrong> A 7-day-valid reset link is a 7-day-long account-takeover window. Cap it at 15–60 minutes.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Storing raw tokens in the database.\u003C\u002Fstrong> A DB leak then becomes instant account compromise. Store hashes.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Reusable tokens.\u003C\u002Fstrong> If a token isn't consumed on use, a leaked email thread or browser-history entry stays exploitable. Make it single-use.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Not invalidating sessions on reset.\u003C\u002Fstrong> If an attacker had a session, resetting the password should log them out. Rotate session tokens.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Sending without SPF\u002FDKIM\u002FDMARC.\u003C\u002Fstrong> The fastest way to land critical mail in spam. Authenticate before you scale.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Mixing transactional and marketing on one domain.\u003C\u002Fstrong> A marketing spam complaint can tank the reputation that delivers your reset emails. Use a dedicated subdomain.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>No rate limiting.\u003C\u002Fstrong> Unlimited reset requests let attackers spam a victim's inbox and probe for valid accounts. Throttle per-account and per-IP.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Blocking the request thread on the SMTP call.\u003C\u002Fstrong> Send asynchronously so users aren't staring at a spinner.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Putting the token in a URL that gets logged.\u003C\u002Fstrong> Reset tokens in query strings can leak via server logs, referrer headers, and analytics. Strip them from logs and set a strict referrer policy.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>Frequently Asked Questions\u003C\u002Fh2>\n\u003Ch3>How long should a password reset email link stay valid?\u003C\u002Fh3>\n\u003Cp>Between 15 minutes and 1 hour is the industry standard (per the OWASP Forgot Password Cheat Sheet). This is long enough for a user to find the email and act, but short enough to limit the window in which a leaked link can be abused. Always make the token single-use as well, so it stops working the moment the password is changed.\u003C\u002Fp>\n\u003Ch3>Why does my password reset email go to spam?\u003C\u002Fh3>\n\u003Cp>The most common cause is missing or misaligned email authentication. Without correct SPF, DKIM, and DMARC records, mailbox providers can't verify you're a legitimate sender and route the message to spam — or reject it. Other causes include sending from a domain with poor reputation, HTML-only emails with no plain-text part, image-heavy content, and link domains that don't match your sending domain.\u003C\u002Fp>\n\u003Ch3>Should I use a JWT for the password reset token?\u003C\u002Fh3>\n\u003Cp>Generally no. A JWT is stateless, which means you can't easily mark it as \"used\" or revoke it without a server-side record — and single-use enforcement is essential for a reset token. A cryptographically random token stored as a SHA-256 hash in a database row, with an expiry and a \u003Ccode>used\u003C\u002Fcode> flag, gives you revocation, single-use, and reissue control that a bare JWT can't.\u003C\u002Fp>\n\u003Ch3>How do I prevent user enumeration in the reset flow?\u003C\u002Fh3>\n\u003Cp>Never reveal whether an email address is registered. Return the same neutral response (\"If an account exists, we've sent reset instructions\") whether or not the account exists, keep response timing roughly constant, and rate-limit requests per email and per IP address so attackers can't probe at scale.\u003C\u002Fp>\n\u003Ch3>Do I need a dedicated domain or subdomain for transactional email?\u003C\u002Fh3>\n\u003Cp>It's strongly recommended. Sending password reset and other authentication emails from a dedicated subdomain (e.g. \u003Ccode>mail.yourdomain.com\u003C\u002Fcode>) isolates their sender reputation from marketing campaigns. If a marketing send triggers spam complaints, your critical transactional mail still delivers because it's authenticated under a separate, clean reputation.\u003C\u002Fp>\n\u003Ch3>What's the difference between SPF, DKIM, and DMARC?\u003C\u002Fh3>\n\u003Cp>SPF authorizes which servers may send mail for your domain. DKIM cryptographically signs each message so receivers can confirm it wasn't altered and genuinely came from your domain. DMARC ties the two together with alignment rules and tells receivers what to do when checks fail (do nothing, quarantine, or reject), plus sends you reports. You need all three for reliable delivery of authentication email.\u003C\u002Fp>\n\u003Ch3>Should I invalidate active sessions when a password is reset?\u003C\u002Fh3>\n\u003Cp>Yes. If a user is resetting their password because their account was compromised, leaving existing sessions active lets the attacker stay logged in. On a successful reset, rotate or revoke all active sessions and send a confirmation email so the legitimate user knows the change happened.\u003C\u002Fp>\n\u003Ch2>Conclusion\u003C\u002Fh2>\n\u003Cp>A password reset email looks trivial until you map its three failure domains — security, deliverability, and user experience — and realize each one can break the flow on its own. Do it properly and the recipe is clear:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>Generate \u003Cstrong>256-bit random tokens\u003C\u002Fstrong>, store them \u003Cstrong>hashed\u003C\u002Fstrong>, expire them in \u003Cstrong>15–60 minutes\u003C\u002Fstrong>, and make them \u003Cstrong>single-use\u003C\u002Fstrong>.\u003C\u002Fli>\n\u003Cli>Write \u003Cstrong>short, scannable, multipart\u003C\u002Fstrong> email content with one clear action and an expiry notice.\u003C\u002Fli>\n\u003Cli>Authenticate every message with \u003Cstrong>SPF, DKIM, and DMARC\u003C\u002Fstrong>, rolling out DMARC from \u003Ccode>p=none\u003C\u002Fcode> to \u003Ccode>p=reject\u003C\u002Fcode>.\u003C\u002Fli>\n\u003Cli>Send from a \u003Cstrong>dedicated transactional subdomain\u003C\u002Fstrong>, asynchronously, with rate limiting and a confirmation email.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Get these right and your reset flow becomes invisible in the best way: users get locked out, get their email in seconds, click once, and move on. That reliability is the difference between a recovery flow that quietly works and a churn-generating support burden.\u003C\u002Fp>\n\u003Ch2>Build Reset Emails That Land — With Postwing\u003C\u002Fh2>\n\u003Cp>Securing the token is your job; getting the email into the inbox is ours. \u003Ca href=\"https:\u002F\u002Fpostwing.app\">Postwing\u003C\u002Fa> is a transactional email delivery platform built for developers and SaaS teams who need authentication emails — password resets, verifications, confirmations — to arrive fast and authenticate cleanly. We guide you through SPF and DKIM setup, sign every message, and give you the deliverability tooling to keep your reset emails out of spam.\u003C\u002Fp>\n\u003Cp>And because we accept \u003Cstrong>USDC payments on Base\u003C\u002Fstrong>, you can start sending without a corporate card, a credit check, or a billing department in the loop — pay on-chain and ship. Connect your domain, drop in your reset template, and send your first authenticated password reset email today at \u003Ca href=\"https:\u002F\u002Fpostwing.app\">postwing.app\u003C\u002Fa>.\u003C\u002Fp>","6da7c71d-411a-4be7-88f0-52f8f3e2bba9","2026-06-24T18:28:18.049318+03:00","2026-07-15T23:48:41.059833+03:00","postwing","Building a Password Reset Flow That Actually Delivers","Build a password reset email flow that delivers: secure token generation, SPF\u002FDKIM\u002FDMARC setup, content best practices, and production-ready code examples.","https:\u002F\u002Fapi.postwing.app\u002Fmedia\u002Fblog\u002Frecord_6da7c71d-411a-4be7-88f0-52f8f3e2bba9\u002FChatGPT_Image_Jul_15_2026_11_48_18_PM.png",true,"2026-07-22T09:00:00+03:00",[16,17],"password reset email","authentication email"]