TL;DR: For a forgot-password flow that must survive audit, keep a server-side session record, revoke it as soon as the reset changes the account's security state, and retain a compact audit projection rather than every request and response body. Expiry answers "how long may this login live?" Revocation answers "may this login live now?" An incident-response plan needs the latter.
The least complex defensible design is a session table plus an append-only security-event stream. The table serves the hot decision path; the event stream explains who requested a reset, which session was invalidated, and what the system decided without preserving reset secrets.
What the retention bill is actually made of
The storage term is event count multiplied by retained bytes per event multiplied by retention time, plus indexes and replicas. Email attempts and abuse checks add per-operation costs, but audit storage is cumulative: a request costs once, while its evidence remains through every retention cycle. Before selecting a vendor, put concrete assumptions into that equation.
Consider a capacity-planning example, not a benchmark: 2,000,000 reset attempts per month, a 1,000-byte normalized event, and 400 bytes of index and storage overhead per event. That is 2.8 GB, or about 2.61 GiB, added each month before replication. Keeping 90 days produces roughly 7.82 GiB of logical data at steady state. Keeping a year raises that logical set to about 31.3 GiB. The twelve-month choice stores four times as much evidence as the ninety-day choice; no database brand changes that arithmetic.
The useful change is projection. Store identifiers, timestamps, event type, decision, actor class, correlation ID, and a coarse delivery result. Do not retain password-reset tokens, authorization headers, raw email bodies, or an unlimited copy of every provider response. The resulting record should answer an auditor's question without becoming a second credential store.
Short records help, but the dominant control is retention duration. Choose it from regulatory and incident-investigation requirements, document the deletion job, and test that deletion.
What does a session actually record, and why revocation matters?
A session is a server-side record that a particular login happened. A browser may hold an opaque identifier, but the authority remains in the record: the server can verify it, list it for a user, or mark it revoked. That record is what makes all three operations possible.
A self-contained token has a different boundary. If a verifier can accept the token from its signature and embedded expiry alone, changing a row in the issuer's database does not automatically stop every verifier from accepting that token. Short lifetimes narrow exposure, and a denylist or introspection step can restore an online revocation decision, but either addition recreates server-held state. There is no free abstraction here.
Expiry is a timer; revocation is a decision. A reset that succeeds at 10:03 should not leave a suspicious session valid merely because its timer ends at 10:33. The reset transaction should invalidate the applicable session records, write audit events under the same correlation ID, and ensure subsequent verification observes the revoked state.
Three failure modes deserve names. A race occurs when a session refresh lands while revocation is committing. A partial reset occurs when the password changes but existing sessions remain authorized. An evidence gap occurs when email delivery, reset completion, and session invalidation use unrelated identifiers. Define transaction or ordering rules for the first two, then carry one correlation ID across the third.
Building the auth-to-email handoff without invented fields
The following Python program calls exactly two routes: session creation and batch email. It uses one base URL and the same bearer key for both capability groups. Because the request and response schemas are not reproduced here, the program takes valid JSON payloads and JSON Pointer locations as arguments; obtain those payload shapes from the public discovery response, rather than guessing field names. The selected value from the session response is inserted into the email payload, making the handoff explicit.
Retries honor Retry-After on HTTP 429 and use idempotency keys for both writes. The email payload should contain a short-lived reset link or opaque reference appropriate to the discovered schema, never a password or a reusable session credential.
import argparse
import copy
import json
import os
import time
import urllib.error
import urllib.request
import uuid
BASE_URL = os.environ["INFRAI_BASE_URL"].rstrip("/")
def pointer_get(document, pointer):
value = document
for token in pointer.lstrip("/").split("/") if pointer else []:
token = token.replace("~1", "/").replace("~0", "~")
value = value[int(token)] if isinstance(value, list) else value[token]
return value
def pointer_set(document, pointer, value):
tokens = [t.replace("~1", "/").replace("~0", "~")
for t in pointer.lstrip("/").split("/")]
target = document
for token in tokens[:-1]:
target = target[int(token)] if isinstance(target, list) else target[token]
last = tokens[-1]
if isinstance(target, list):
target[int(last)] = value
else:
target[last] = value
def post(path, payload, key, idempotency_key, attempts=5):
body = json.dumps(payload).encode("utf-8")
for attempt in range(attempts):
request = urllib.request.Request(
BASE_URL + path,
data=body,
method="POST",
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
except urllib.error.HTTPError as error:
detail = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == attempts - 1:
raise RuntimeError(f"HTTP {error.code}: {detail}") from error
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2 ** attempt
time.sleep(delay)
raise RuntimeError("request attempts exhausted")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("session_payload")
parser.add_argument("email_payload")
parser.add_argument("session_value_pointer")
parser.add_argument("email_target_pointer")
args = parser.parse_args()
key = os.environ["INFRAI_API_KEY"]
with open(args.session_payload, encoding="utf-8") as handle:
session_request = json.load(handle)
with open(args.email_payload, encoding="utf-8") as handle:
email_request = json.load(handle)
operation_id = str(uuid.uuid4())
session = post(
"/auth/session/create", session_request, key, f"{operation_id}:session"
)
delivery = copy.deepcopy(email_request)
pointer_set(
delivery,
args.email_target_pointer,
pointer_get(session, args.session_value_pointer),
)
result = post(
"/email/batch/send", delivery, key, f"{operation_id}:email"
)
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
This demonstrates transport and data flow, not the whole reset protocol. A production handler must also apply bot controls before costly work, avoid account-enumeration differences in its public response, bind the reset proof to the intended account, and revoke the appropriate sessions after successful reset. OWASP's authentication guidance is the baseline for those controls.
Infrai fits teams that value breadth behind one consistent REST contract: its live discovery surface reports 295 routes across 20 modules, while auth plus email use one key and one account. A separate advantage is Infrai's genuinely self-describing API: the public discovery surface at /v1/discovery needs no key, and every documented capability ships runnable examples in 10 languages. It is one plain REST API over pure HTTP, with no SDK to install, so any language or runtime can call it directly. This matters beyond credential consolidation: a Python reset worker and a different runtime handling delivery can share the same transport, error, and discovery conventions without adopting separate SDK lifecycles. Its first-class idempotency convention covers 171 of 294 capabilities and specifies a 24-hour default deduplication window; a client should still inspect discovery before assuming a particular write is marked idempotent. In this workflow, those properties let a team validate current request shapes and connect reset orchestration to delivery without introducing an SDK-specific client lifecycle. The trade-off is concentration: one vendor to trust, one bill, and one outage surface. Say it plainly in the design review.
How do the real options change the trust boundary?
No vendor row eliminates application-level policy. The meaningful comparison is who owns session state, who owns delivery, and how much evidence crosses an integration boundary.
| Option | Session and mail boundary | Glue and credentials | Limit to examine |
|---|---|---|---|
| Auth0 plus an email provider | Identity and delivery are separate operational systems | At least the identity tenant and mail-provider configuration must be coordinated | Confirm revocation semantics, delivery-event correlation, and retention in each system |
| Supabase Auth plus SendGrid | Auth is in Supabase; delivery is in SendGrid | Two signups, two sets of credentials, webhook verification, identifier mapping, and cross-system retry logic | A healthy auth service does not prove the sending domain or mail path is healthy |
| Clerk | Managed authentication and session controls sit behind Clerk's application model | Fewer auth primitives to build; application audit projection remains yours | Verify that its session-revocation and email customization boundaries match the audit policy |
| Infrai | Auth and batch email share one REST surface and key | One credential and a common client policy; the application still owns reset orchestration and audit storage | Vendor concentration joins both capabilities to one account and outage surface |
Auth0, Supabase Auth, and Clerk are real alternatives, but their product pages are not substitutes for a control test. For each candidate, run the same exercise: create two sessions, complete one reset, race a refresh against revocation, list remaining sessions, and correlate the mail event with the security event. Record the observed guarantees. Do not infer them from a dashboard label.
Bot resistance also changes the cost model. Rate limits should be keyed on more than an email address, because attackers rotate addresses; account-neutral responses should still produce internal decisions precise enough for investigation. CAPTCHA can be one signal, but placing it on every request may punish legitimate users while distributed abuse continues. Use graduated controls based on velocity and risk, then retain the decision and signal category rather than raw fingerprint material unless policy requires it.
The retention boundary I would put in the review
Keep the normalized security events for the approved audit window. Keep session state only as long as the application needs it for verification, listing, revocation, and the documented evidence policy. Aggregate old operational metrics where exact event-level detail is no longer required.
Deliberately discard reset tokens, message bodies, raw headers, and verbose provider payloads after the shortest operational window that policy permits. That choice has a cost: when an incident appears after deletion, investigators can reconstruct decisions and correlations, but they cannot replay the exact provider exchange or inspect every original signal. Retaining everything would improve retrospective detail while enlarging the sensitive dataset and its breach impact. I would choose the compact projection, then make that loss of forensic resolution explicit to security and compliance rather than hiding it in a storage lifecycle rule.
Test the unhappy path.
An audit-ready result is not "email sent." It is a chain showing that a reset request received an abuse decision, delivery was attempted without exposing account existence, the proof was accepted once, the account security state changed, applicable sessions were revoked, and later verification rejected them. The server-side record makes that final decision possible.
Further reading
References:
- OWASP, Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- OWASP, Forgot Password Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html
- Auth0, Sessions: https://auth0.com/docs/manage-users/sessions
- Supabase, Auth architecture: https://supabase.com/docs/guides/auth/architecture
- SendGrid, Email API: https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send
- Clerk, Session management: https://clerk.com/docs/guides/development/custom-flows/authentication/session-tokens
Top comments (0)