TL;DR
The least complex safe design is one recovery coordinator around three separate records: local credentials, linked Google or GitHub identities, and active sessions. Password reset should rotate only the local credential, identity inventory should decide which recovery path is valid, and a successful recovery should revoke every existing session before a new one is issued. Put enumeration resistance and abuse controls at the public boundary, not inside the email screen.
This matters on an edtech creator platform because a compromised instructor account can publish, change course material, and reach learners. A generic "forgot password" form covers only one slice of that risk. Some creators signed up with a password, some with social sign-in, and some linked both later; the recovery flow has to preserve those distinctions without telling an attacker which identities exist.
Keep the pieces boring.
How should FastAPI handle creator account password reset and session cleanup?
Start with the data flow. The browser submits an email address to a public recovery endpoint. The boundary applies rate limits and, when traffic looks automated, a step-up challenge. It always returns the same outward response. Behind that response, the coordinator loads the account's identity inventory, selects an eligible recovery method, stores a digest of a short-lived single-use token, and sends the raw token through a separate delivery adapter. On completion, it consumes the token, replaces the local password hash if a local credential exists, revokes the account's sessions, and records the security event.
The identity inventory is the hinge. Model a local password, Google sign-in, and GitHub sign-in as distinct identities attached to one internal creator ID. Store the provider's subject identifier for a social identity; don't turn a matching email address into an implicit account link. Linking or removing an identity is a sensitive operation and should require recent authentication. Recovery must also refuse any change that would leave the creator with no usable sign-in method.
| Identity inventory | Private recovery action | Session action |
|---|---|---|
| Password only | Send a single-use reset token | Revoke all after completion |
| Google or GitHub only | Send the linked sign-in route | Revoke after account recovery |
| Password plus social | Reset only the local credential | Revoke all after completion |
| No usable identity | Escalate to the audited support process | Keep existing state until verified |
There is an important UX consequence: requesting a reset for a social-only account must not reveal that fact in the public response. The internal delivery can explain the appropriate sign-in route to the account owner, while the browser still receives the same generic message and comparable timing. OWASP recommends consistent messages and response timing for existing and nonexistent accounts, plus protections against excessive automated submissions.
Bot resistance belongs at two levels — coarse controls for the requester and account-aware controls after lookup. An IP-only limit punishes a campus or coworking network where many legitimate creators share an address. An email-only limit lets an attacker distribute attempts across a list of victims. Use both signals, keep the public result generic, and add a challenge only when risk crosses a threshold. Don't lock the account merely because someone submitted its email to the recovery form; that turns the form into a denial-of-service tool.
No lookup leaks.
A runnable recovery core before the HTTP layer
The following Python example keeps web routing, email delivery, password hashing, and session storage behind interfaces. That makes the state transition easy to exercise in a notebook or an eval harness before FastAPI, a database, and a queue enter the picture. The example deliberately accepts an already derived new_password_hash; password hashing belongs in a reviewed authentication component, not in a tutorial helper.
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from hashlib import sha256
from secrets import token_urlsafe
def now_utc() -> datetime:
return datetime.now(timezone.utc)
def token_digest(raw_token: str) -> str:
return sha256(raw_token.encode("utf-8")).hexdigest()
@dataclass(frozen=True)
class Identity:
kind: str
subject: str
@dataclass
class Session:
session_id: str
revoked_at: datetime | None = None
@dataclass
class CreatorAccount:
creator_id: str
email: str
identities: list[Identity]
password_hash: str | None
sessions: list[Session] = field(default_factory=list)
@dataclass
class RecoveryGrant:
creator_id: str
digest: str
expires_at: datetime
consumed_at: datetime | None = None
class RecoveryService:
public_result = {"status": "accepted"}
def __init__(self, accounts: dict[str, CreatorAccount]) -> None:
self.accounts = accounts
self.grants: dict[str, RecoveryGrant] = {}
self.outbox: list[tuple[str, str]] = []
self.audit: list[tuple[str, str]] = []
def request(self, email: str) -> dict[str, str]:
account = self.accounts.get(email.strip().casefold())
if account is None:
return self.public_result.copy()
local_identity = any(i.kind == "password" for i in account.identities)
if local_identity:
raw_token = token_urlsafe(32)
digest = token_digest(raw_token)
self.grants[digest] = RecoveryGrant(
creator_id=account.creator_id,
digest=digest,
expires_at=now_utc() + timedelta(minutes=20),
)
self.outbox.append((account.email, raw_token))
else:
providers = ", ".join(sorted(i.kind for i in account.identities))
self.outbox.append((account.email, f"Use linked sign-in: {providers}"))
self.audit.append((account.creator_id, "recovery_requested"))
return self.public_result.copy()
def complete(self, raw_token: str, new_password_hash: str) -> str:
grant = self.grants.get(token_digest(raw_token))
if grant is None or grant.consumed_at is not None:
return "invalid_or_used"
if grant.expires_at <= now_utc():
return "expired"
account = next(
item for item in self.accounts.values()
if item.creator_id == grant.creator_id
)
grant.consumed_at = now_utc()
account.password_hash = new_password_hash
for session in account.sessions:
session.revoked_at = now_utc()
self.audit.append((account.creator_id, "recovery_completed"))
return "completed"
account = CreatorAccount(
creator_id="creator_1042",
email="teacher@example.edu",
identities=[
Identity(kind="password", subject="creator_1042"),
Identity(kind="google", subject="google-subject-781"),
Identity(kind="github", subject="github-subject-552"),
],
password_hash="stored-hash",
sessions=[Session("session_a"), Session("session_b")],
)
service = RecoveryService({account.email.casefold(): account})
service.request(account.email)
raw_token = service.outbox[-1][1]
assert service.complete(raw_token, "new-reviewed-password-hash") == "completed"
assert all(session.revoked_at is not None for session in account.sessions)
The accepted, invalid_or_used, and expired values are domain outcomes, not suggested public response bodies. A FastAPI adapter can translate a request into the same generic accepted response, send completion failures back to the reset page without disclosing account data, and attach request metadata to the audit event. The service also avoids issuing a replacement session. Recovery changes trust state; the creator should authenticate again after completion.
Replay fails.
The sample uses a 20-minute token lifetime to make the policy visible, not to declare a universal number. Choose the actual lifetime from the platform's threat model and delivery latency, then test expiration boundaries explicitly. The same goes for rate-limit thresholds. A notebook can explore the transition, but CI should lock it down with cases for unknown email, replayed token, expired token, social-only identity inventory, and two active sessions.
The trade-offs that decide the architecture
The safest default after completed recovery is global session revocation. The catch is disruption: a creator editing on a second device loses that session too. Keeping selected sessions is appropriate only when the platform has a trustworthy way to identify the recovering device and can explain the choice without weakening the response to an actual takeover. For a creator account with publishing privileges, ambiguity favors revocation.
Immediate email delivery is easy to reason about at small scale, but it couples public request latency to the delivery provider. A queued delivery path gives better isolation and controlled retries; it also adds a queue, worker observability, and another place where duplicate jobs must remain harmless. Pick the direct path for a small deployment only if the HTTP response stays generic and timing is controlled. Pick the queue when burst handling and delivery operations justify the extra moving part.
Blanket challenges can suppress automated traffic, yet they add friction for every legitimate creator. Risk-triggered challenges need more instrumentation and tuning, but they preserve the ordinary path for low-risk requests. I'm not sure any fixed threshold survives contact with every creator audience; resolve that uncertainty with recovery funnel data, challenge completion rates, and confirmed abuse reports rather than intuition.
Social sign-in changes the recovery boundary rather than removing it. The platform can recover its own session and account-link state, but it cannot reset a Google or GitHub credential. When the only remaining identity is external, send the creator toward that provider's sign-in or recovery flow privately. When local and social identities coexist, a local password reset must leave the linked identities intact unless a separate, recently authenticated unlink action removes them.
Cost belongs in the design review, though token generation is rarely the interesting line item. Delivery volume, challenge traffic, audit retention, and support cases are easier to miss. Track them by recovery outcome and risk tier without placing email addresses, raw tokens, or password material in logs. For an AI-assisted product team, keep model calls out of the authorization decision: generated support copy can be evaluated, but identity proof and session revocation need deterministic rules.
Operate recovery as a security workflow
Before launch, exercise the whole path with synthetic accounts representing password-only, social-only, and mixed identity inventories. Confirm that known and unknown emails receive indistinguishable public messages, a token works once, expiry is enforced, and successful completion revokes two or more seeded sessions. Also verify that unlinking is blocked when it would remove the final sign-in method. These are compact, high-value eval cases; they catch state-machine regressions better than screenshots of the form.
One mixed-identity case deserves extra attention because it crosses every boundary. Seed a creator with a local password, both social identities, and two active sessions; request recovery twice; then complete only the newest valid grant. The assertions should show that the public request result never exposes the inventory, the consumed grant cannot be replayed, the local password hash changes, the Google and GitHub identity records remain attached, and both sessions acquire revocation timestamps. Next, run the same request against a social-only creator and an unknown address. Their public results should match the first request even though the private actions differ. This single scenario checks the coordinator's most consequential contract: outward ambiguity for an attacker, precise state transitions for the account owner, and no accidental coupling between a local credential and external identities. It also gives an eval harness stable outcomes to compare without snapshotting email prose or depending on a browser.
In production, watch request volume by coarse network signal, challenge rate, delivery acceptance, completion rate, token replay attempts, and session-revocation count. Alert on changes in the relationships between those signals, not on a single busy hour. Audit entries should answer who initiated a sensitive transition, which creator ID changed, what category of action occurred, and when — while excluding secrets. Define retention and access policy with the rest of the security logging program.
Runbooks should cover delivery delay, a creator who lost access to every linked provider, suspected takeover after recovery, and a support request to change an identity. Support must not become a second, weaker authentication protocol. Give staff a documented verification and escalation path, restrict the actions their tools can perform, and audit those actions with the same care as the self-service flow.
Finally, rehearse rollback at the application level. A deployment can be rolled back, but a consumed token or revoked session should not be resurrected by that rollback. Database migrations and event consumers need forward-compatible state values, and retries must leave recovery_completed idempotent. That's the difference between a reset page and an account-recovery system.
Further reading
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
Top comments (0)