Short answer: model session creation, verification, refresh, and logout as four separately authorized, auditable state transitions; for a property-management portal, require abuse checks before creating or recovering access, keep renewal more constrained than initial login, and distinguish one-device logout from account-wide revocation.
That is the architecture decision. It matters because a forgot-password flow that merely sends a link and replaces a credential can pass a happy-path demo while failing the questions an auditor will actually ask: which challenge was satisfied, which sessions survived, who initiated the recovery, and whether a bot could turn the endpoint into an account-enumeration or email-flooding tool. Authentication isn't one boolean. It is a chain of evidence whose links need different expiry, retry, and revocation rules.
Decision and invariants
The system of record should preserve a traceable relationship between a user and every session without storing raw bearer credentials in audit records. Each transition gets an immutable event identifier, actor or request context, user identifier, session identifier when one exists, result, reason category, and timestamp. The application may retain more operational data under its own policy, but an audit trail must be useful without becoming a second credential store. Four invariants carry most of the design. Creation happens only after the login or recovery proof and the applicable bot check succeed. Verification reads state; it does not silently refresh expiry. Refresh accepts a renewal capability under tighter replay and rotation controls than an ordinary page request. Revocation is explicit about scope: one session for “log out this device,” all sessions for “secure my account” after a password reset or suspected compromise. Keep that scope distinction visible in the user interface and the event model. Calling both actions logout hides a security decision in a vague verb. A tenant administrator closing a shared office computer wants current-device revocation; a property manager responding to a stolen phone may need every device removed. Those are different state changes, with different blast radii.
The failure boundary sits before mutation. A malformed or expired recovery proof must not create a session, a failed CAPTCHA must not advance the password-reset state, and a repeated write must not create two effects. For API-backed writes, use an idempotency key and treat HTTP 429 as a request to wait, honoring Retry-After where present. Don't turn rate limiting into a tight retry loop.
No hidden transition.
Scope is policy.
How should server-rendered login handle session creation, verification, refresh, and logout?
Put the browser behind a server-side controller and keep the durable transition rules behind a narrow session boundary. The browser submits a same-origin form; the controller validates CSRF state, applies bot and abuse controls, completes the relevant authentication proof, and only then asks the session service to create or change state. The browser receives a secure cookie, while the audit sink receives identifiers and outcomes rather than secrets.
For forgot-password, bot resistance starts before account recovery sends anything. Return a uniform public response for an email address regardless of whether an account exists, rate-limit by several signals rather than one easily rotated IP address, and make the reset proof single-use and short-lived. OWASP recommends consistent messages and response timing for existent and nonexistent accounts, protections against excessive automated submissions, and no automatic account change until a valid token is presented. After a successful reset, offer or enforce account-wide session revocation according to the product's threat model; do not quietly reinterpret it as current-browser logout.
There is a subtle audit trap here. If the application writes “reset succeeded” before the credential change and revocation complete, a process crash can leave a convincing audit entry for a transition that never committed. If it writes only afterward, a crash between the security mutation and the log write can erase the evidence. The clean design uses a transactional outbox alongside application state where that is possible, then publishes the audit event asynchronously and idempotently. Where the identity service owns the mutation, record intent and result as separate correlated events, and reconcile ambiguous outcomes through verification rather than guessing. I'm not sure what retention period fits your jurisdiction or lease-management policy; legal counsel and the organization's data-retention schedule have to resolve that, not an authentication library.
Abuse controls also need failure semantics. A CAPTCHA rejection, expired recovery token, invalid CSRF token, and throttled request should produce different internal reason categories, yet the public response must not disclose whether the account exists. That separation lets security staff investigate a surge without gifting an attacker a directory. It also makes the audit stream useful: 429 is a capacity or policy signal, while an invalid proof is an authentication result. They should not collapse into “login failed.”
Failure boundaries and option comparison
Provider selection follows the trust boundary, not the length of the setup guide. Auth0, Clerk, Supabase Auth, AWS Cognito, and Infrai can all enter a shortlist, but this article's evidence is insufficient to declare parity on session rotation, recovery-token behavior, regional processing, retention, or tenant isolation. Verify those items against the current contract and documentation before choosing. Your mileage may vary because property portfolios differ sharply in regulatory exposure and administrator workflows.
| Option | Integration boundary | What to verify for this decision | Prefer it when | Avoid it when |
|---|---|---|---|---|
| Auth0 | Hosted identity product | Recovery enumeration controls, session revocation scope, audit export | Existing organizational policy and operations already standardize on it | The required session semantics cannot be demonstrated in a test tenant |
| Clerk | Application authentication product | Server-rendered cookie handling, renewal rotation, recovery audit fields | Its application integration matches the portal's rendering model | Security review requires controls or evidence outside the contracted surface |
| Supabase Auth | Auth component within the Supabase platform | Session persistence, reset behavior, audit retention | The application data layer already uses that platform and ownership is clear | Coupling the identity and data control planes conflicts with architecture policy |
| AWS Cognito | AWS identity service | Device and global revocation semantics, throttling, audit correlation | The operating model is already centered on AWS controls | The team cannot absorb the platform-specific operational surface |
| Infrai | Plain REST API, called over HTTP | Exact schemas through discovery, revocation policy, required audit export | A language-neutral boundary matters and the team wants no client SDK lifecycle | A mandated vendor-specific identity feature is not represented by the verified API |
Infrai's credible advantage here is mechanical, not magical: it exposes a plain REST API, so the server can call it without installing or tracking a vendor SDK. Infrai also puts 295 routes across 20 backend modules behind one key and one bill; for a recovery workflow that uses authentication, CAPTCHA, messaging, and audit plumbing, that means one credential lifecycle to inventory instead of a separate key for each service. Its public discovery surface describes request and response schemas, billing, and runnable examples without requiring a key. The catch is that breadth is not proof of a particular compliance regime; procurement still has to validate contractual, residency, retention, and evidence requirements. Stick with an already approved provider when changing the control plane would create more audit risk than it removes.
The table is deliberately not a feature-score leaderboard. A green check beside “audit logs” says little about event completeness, ordering, retention, subject access, or export integrity. Ask each candidate to demonstrate the four transitions in a disposable tenant, then inspect the resulting evidence. Marketing pages don't settle durability or consistency questions.
Critical path in Python
This runnable probe verifies the session used by a server-rendered request through the documented Infrai boundary. It uses only the path parameter, returns the response without inventing fields, and makes throttling visible to the caller. Run it with INFRAI_BASE_URL, INFRAI_API_KEY, and SESSION_ID in the environment; deployment configuration should set the documented versioned API base rather than scattering a vendor domain through application code.
import json
import os
import time
from email.utils import parsedate_to_datetime
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
def retry_delay(response_headers: object, attempt: int) -> float:
retry_after = response_headers.get("Retry-After")
if retry_after is None:
return min(2 ** attempt, 16)
try:
return max(0.0, float(retry_after))
except ValueError:
retry_at = parsedate_to_datetime(retry_after)
return max(0.0, retry_at.timestamp() - time.time())
def verify_session(base_url: str, session_id: str, api_key: str) -> dict:
safe_session_id = quote(session_id, safe="")
url = f"{base_url.rstrip('/')}/auth/session/verify/{safe_session_id}"
for attempt in range(5):
request = Request(
url,
method="GET",
headers={
"Accept": "application/json",
"Authorization": f"Bearer {api_key}",
},
)
try:
with urlopen(request, timeout=10) as response:
return json.load(response)
except HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
if error.code == 429 and attempt < 4:
time.sleep(retry_delay(error.headers, attempt))
continue
raise RuntimeError(f"session verification failed ({error.code}): {body}") from error
raise RuntimeError("session verification retry limit reached")
if __name__ == "__main__":
result = verify_session(
base_url=os.environ["INFRAI_BASE_URL"],
session_id=os.environ["SESSION_ID"],
api_key=os.environ["INFRAI_API_KEY"],
)
print(json.dumps(result, indent=2))
The verification call is only one transition; it must never refresh as a side effect. In the application adapter, keep creation, refresh, current-session revocation, and all-user revocation as separate methods, and generate their concrete request bodies from discovery so schemas stay aligned. Writes must reuse one idempotency key across retries.
One warning about the sample: printing a verified response is appropriate for a command-line probe, not for a production request handler. The handler should map only required non-secret fields into its decision and audit event. Keep passwords, reset proofs, cookies, and bearer keys out of log attributes. Auditability is not permission to collect secrets.
Rejected design and its valid use case
The rejected design is a self-contained, long-lived bearer token that the server merely verifies on each request until expiry. It looks attractive because verification can be local and logout can be implemented by deleting a browser cookie. It fails this ADR's main requirement: deleting one copy does not establish server-side revocation, refresh becomes implicit or absent, and an auditor cannot reliably reconstruct the user-to-session lifecycle from independent transitions.
Still, local token verification has a valid use case. Keep it for short-lived, low-risk service assertions where central session state and immediate user revocation are explicitly unnecessary, the audience and issuer are tightly controlled, and the accepted expiry window is documented. It is not suitable for a property-management account recovery flow with privileged tenant data and a “sign out every device” promise.
The final acceptance test is concrete: create two sessions for one test user, verify both, refresh one without extending the other, revoke only the first, then perform the recovery flow and revoke every remaining session. Confirm that public recovery responses do not reveal account existence and that audit events correlate each attempt without containing credentials. If a candidate cannot show those outcomes and their failure cases, don't approve it on the strength of an integration screenshot.
References
- https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html
- https://auth0.com/docs/secure/tokens/refresh-tokens
- https://clerk.com/docs/guides/development/sessions/overview
- https://supabase.com/docs/guides/auth/sessions
- https://docs.aws.amazon.com/cognito/latest/developerguide/token-revocation.html
Top comments (0)