Designing Secure Password Reset Flows for Multi-Portal SaaS
Password reset is the least glamorous screen in your product and the one attackers probe first. It is the single flow that, by design, lets an unauthenticated stranger change an account's credentials. Get one detail wrong and it becomes an account-takeover primitive or a quiet way to enumerate your entire customer list. Now multiply that by three: a super-admin console, an advertiser dashboard, and a publisher portal, each with its own users, its own domain, and its own trust boundary. That is the problem we had to solve, and the security decisions behind it are worth walking through, because they generalize to any multi-portal SaaS.
Think of it as triage. The reset flow is where a locked-out user comes in bleeding, and your job is to treat them quickly without handing the same door to whoever is watching from the waiting room.
Three portals, one discipline
TrackingMD runs three distinct front doors. The Super Admin console lives on the central domain behind TOTP multi-factor auth. The Advertiser Dashboard lives on a tenant subdomain. The Publisher Portal lives on that same tenant subdomain under a partner path, with the organization resolved from the host header rather than a URL slug. Each has its own user population and its own reset endpoint.
The temptation in a system like this is to let each portal grow its own reset logic organically. That is how you end up with two portals that expire tokens and one that never does. Instead, all three follow the same four rules:
- The response never reveals whether an account exists.
- Only a hashed token is ever stored; the usable secret exists only in the email.
- Tokens expire on a fixed clock and are single-use.
- The reset link is scoped to the correct portal and the correct tenant.
The implementations differ where the trust boundary genuinely differs, and are identical everywhere else.
Never confirm who has an account
The most common leak in a reset flow is not the token, it is the response. If asking to reset unknown@example.com returns "no account found" but a real address returns "email sent," an attacker can walk a list of addresses and learn exactly who your customers are. For a publisher-marketing platform, that customer list is competitively sensitive on its own.
Every reset request in every portal returns the same sentence regardless of outcome: "If an account exists with this email, you will receive a password reset link." When the address matches a real user, we generate a token and send mail. When it does not, we do nothing. The caller cannot tell the two apart. The advertiser endpoint looks up the user and only proceeds inside the if-block; the publisher endpoint scopes the lookup to the current organization and only sends when both the publisher and its linked user exist; the admin endpoint additionally requires the account to be active. In all three, the outward-facing message is byte-for-byte identical.
We carry the same discipline into the second step. When a token is redeemed, a missing record, a token that does not match, an expired token, and even a valid token whose user has since been deleted all raise the same generic error: "This password reset token is invalid or has expired." No branch of that logic tells the attacker which condition they hit.
There is a subtler leak we also close. Sending mail can fail, and a naive implementation might surface that failure to the user, which again distinguishes a real address from a fake one. The advertiser and publisher endpoints wrap the send in a try/catch, log the failure internally as an email.send_failed event, report it to our error pipeline, and still return the same success message. The user experience of a delivery hiccup is indistinguishable from success, which is exactly what enumeration resistance requires.
Tokens you can't steal from the database
A password reset token is a bearer credential. Whoever holds it can change the password. That means the token deserves the same treatment as a password itself: it should never be recoverable from your own storage.
When a reset is requested, we generate a 64-character random token and store only its hash in the password_reset_tokens table, using the same one-way hashing we use for passwords. The plaintext token travels exactly once, inside the email, to the address that requested it. If someone dumps the reset table, they get a column of hashes that cannot be reversed into working links. Redemption re-hashes the submitted token and compares, the same way a login verifies a password.
Two more properties bound the blast radius:
- One live token per address. Before issuing a new token we delete any existing reset row for that email. Requesting a fresh link silently invalidates the previous one, so an old email forwarded or leaked later is already dead.
- A hard expiry and single use. Every token carries a creation timestamp, and redemption rejects anything older than sixty minutes. On a successful reset we delete the token row immediately, so the same link cannot be replayed. A short window plus single use means a captured link is useful only briefly, and only once.
Combined, these turn the token from a standing liability into a perishable, one-shot secret.
URLs that land in the right place
In a single-tenant app the reset link is obvious: your one domain plus a path. In a multi-tenant, multi-portal system, the link has to carry three pieces of routing without ever letting the user choose them.
The reset mail composes the destination from server-side context, never from user input. It knows the portal type, so a publisher gets a partner reset path and an advertiser gets the dashboard reset path. It knows the tenant, so it builds the URL against that organization's own subdomain rather than a shared host, keeping the reset inside the same origin the user already trusts. The Super Admin flow is separate by construction: admin tokens live in their own central table on the central connection, and admin links point at the central console's admin reset path. An admin token and a tenant token are stored in different tables and can never be crossed.
Because the tenant and portal are derived from who requested the reset, a user cannot manipulate a link into resolving against a different tenant or a different portal. The email address is URL-encoded into the link so it round-trips cleanly, but it is only an identifier; the token is what authorizes the change, and the token is bound to that address in storage.
The admin path has one extra piece of care worth calling out. Super Admin accounts sit behind TOTP, and a reset can double as the escape hatch for an account stranded mid-enrollment. The admin reset deliberately reconciles that MFA state as part of setting the new password, so "forgot password" never leaves a super-admin permanently locked out of their own second factor. That is a portal-specific rule layered on top of the shared token mechanics, not a fork of them.
The payoff of one shared model
None of these decisions is exotic on its own. Generic responses, hashed tokens, short expiries, single use, server-composed URLs: this is the textbook. The discipline is applying every one of them, identically, across three portals with three different user models and two different database connections, so that the weakest door is as strong as the strongest.
That consistency is the real product. A locked-out advertiser, a locked-out publisher, and a locked-out admin all get the same fast, safe path back in, and an attacker watching any of the three learns nothing they can use. As we add surfaces to the platform, the rule is simple: a new portal inherits the same four guarantees before it ships, not after someone rattles the handle.
See it in your own program
Start your free 7-day trial and put these ideas to work — no credit card required.
Start free trial