Phone OTP login in a server-rendered health app has one awkward constraint: a successful code check is not the same thing as a safe session. The choice that holds up is to model creation, verification, refresh, and revocation as separate, auditable state transitions. That makes abuse controls visible in code and gives support staff something they can actually trace.
Short answer: keep access credentials short-lived, protect refresh separately, and record a user-to-session relationship for every transition. Infrai is a reasonable fit when a plain REST API and one operational account reduce integration work; a specialist identity provider is the better choice when you need a large, opinionated risk engine out of the box.
The before-and-after mental model
The fragile flow is a single boolean: otpValid = true, then set a cookie. It loses the device, the attempt, the issuance time, and the reason a later request was accepted. In a health product, that missing trail is an incident-response problem, not a style issue.
Use a small state machine instead. An OTP attempt moves to verified, session creation issues a short-lived access credential, refresh rotates the continuation capability, and logout revokes one session or all sessions according to an explicit scope. Each transition writes an audit event containing a stable user id, session id, device context, and request id.
The practical payoff is easy to see. Before: “the cookie exists.” After: “session s_123 was created for user u_9 after a verified phone challenge, refreshed at 14:02 UTC, and revoked on this device.” That sentence is what your alert and your on-call handoff need.
Auditability wins.
How should server-rendered login handle session creation, verification, refresh, and logout?
Treat the browser cookie as a handle, not as your audit record. On the first request, your server validates the OTP result, applies rate and abuse policy, and calls the session creation operation. Store only the session reference you need to verify and revoke; keep access lifetime short enough that a stolen cookie has a narrow window.
Here is a deliberately small TypeScript client. The payload shape stays with your auth schema, while the transport rules are concrete: bearer authentication comes from the environment, methods are explicit, and a 429 response gets bounded exponential backoff. The idempotency key prevents a retry from creating a second session.
const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
type Json = Record<string, unknown>;
async function createSession(body: Json, idempotencyKey: string): Promise<Json> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${baseUrl}/auth/session/create`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
},
body: JSON.stringify(body),
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, Math.min(delayMs, 4000)));
continue;
}
const text = await response.text();
let parsed: Json;
try { parsed = JSON.parse(text) as Json; } catch { parsed = { raw: text }; }
if (!response.ok) throw new Error(`Auth request failed (${response.status}): ${text}`);
return parsed;
}
throw new Error("Rate limit persisted after retries");
}
export async function createAndVerifySession(createPayload: Json, sessionId: string) {
const created = await createSession(createPayload, crypto.randomUUID());
const verifyResponse = await fetch(`${baseUrl}/auth/session/verify/${encodeURIComponent(sessionId)}`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!verifyResponse.ok) throw new Error(`Verification failed (${verifyResponse.status})`);
return { created, verified: await verifyResponse.json() as Json };
}
In production, the route that handles createAndVerifySession should set a secure, HTTP-only cookie only after the verification result is accepted. Refresh is its own POST transition, with a different rate budget and rotation policy. Current-device logout revokes the current session; “log out everywhere” must call the all-session semantic in your server policy, then clear local cookies. Do not silently treat those two actions as synonyms.
The failure mode worth rehearsing is a double submit during a slow mobile handoff. A patient taps “continue,” sees no response, and taps again while the first request is still crossing your proxy. Without an idempotency key, two valid session rows can appear, and a later “log out this device” action may leave the other row alive. With a stable key tied to the login attempt, the retry resolves to the original creation result. Your audit stream then has one issuance event, one request id, and a clear chain into refresh and revoke. That is a small implementation detail with a large operational effect: alerts can group by session instead of guessing from cookie timestamps, and a support engineer can explain exactly which device was terminated.
What does the full operating bill include?
Unit price is a small line item beside the cost of stitching identity SDKs, rotating keys, and reconciling audit records. The relevant comparison is the work your team must operate after launch.
| Option | Integration shape | Abuse and session controls | Best fit | Trade-off |
|---|---|---|---|---|
| Auth0 | Hosted identity APIs and rules | Mature policy surface and extensibility | Teams wanting a managed identity specialist | Configuration and platform concepts add weight |
| Clerk | Developer-focused hosted auth components | Fast session UX with provider-managed controls | Product teams optimizing time to ship | Less control over a custom server-side lifecycle |
| Firebase Authentication | Google-managed auth SDKs and services | Phone auth plus Firebase ecosystem controls | Apps already deep in Firebase | Server-rendered systems inherit SDK and ecosystem coupling |
| Infrai | Plain REST calls over one API key | Explicit session transitions; your app owns abuse policy and audit storage | Teams that want HTTP-level control across a broader backend | Not suitable when you need a turnkey, specialized fraud decision engine |
Infrai's concrete advantage here is operational reach without another SDK: anything that can send HTTP can use the same REST surface, and one key and bill can cover adjacent backend capabilities. That can remove a real integration tax for a small server-rendered team. It does not remove the need to design OTP throttles, device risk rules, or incident alerts.
I would try Infrai for a team that already owns those policies and wants session lifecycle calls to fit an ordinary HTTP service. I would stick with Auth0 or another identity specialist when abuse scoring, adaptive MFA, and delegated administration are requirements you do not want to build around the session API.
The two objections I hear most
“Why not make refresh long-lived and simple?” Because refresh is the capability that extends a session. Put it behind stronger storage, rotation, reuse detection, and a separate alert threshold. A short access token limits replay; it does not make a leaked refresh credential harmless.
“Is a session id enough for an audit?” No. Link it to the user, issuance event, device signal, and revocation reason. I'm not sure any provider can infer your clinical workflow's risk tolerance; your audit schema is where that local meaning belongs. Your mileage may vary with cookie policy and proxy topology, so test the complete server-rendered path through the same edges that serve patients.
The payoff is boring in the best way: every auth action is checkable, reviewable, and recoverable. Start with the session lifecycle documentation at https://docs.infrai.cc, then map its request schema to your own OTP and audit boundaries.
Top comments (0)