Provider migrations expose a nasty assumption: that a valid login is one thing. It is not. A support app has a session record, a short-lived access credential, a renewal credential, and a revocation decision. When those pieces disagree, the browser refreshes forever or throws the user back to the login form.
Short answer: diagnose the session as a lifecycle, then use correlated audit events to find the first state mismatch. Verify the session before refreshing, refresh once with a bounded retry policy, and treat a revoked session as a final sign-out rather than another refresh trigger.
The constraint that changes the migration
The concrete workload here is email-and-password sign-up and sign-in for a customer-support product. Agents cannot lose a draft because a background request silently replaced their session. Users should be able to sign out this device without ejecting every device they own.
I start with a timeline, not a vendor feature matrix. Record session_id, user id, device identifier, request id, token issue time, token expiry, and the action (create, verify, refresh, or revoke). The useful question is: which event first contradicts the next event?
One short example: the client sees an expired access credential, calls refresh, receives a new credential, and immediately sends the old credential again. That is a client-state race. A different loop has a server cause: refresh succeeds for one session while a second tab revokes it, so the next verify correctly says the session is no longer valid. Both look like “login is broken” in a support ticket. The audit trail separates them.
How do you diagnose session refresh loops and expired login state?
First, freeze the loop. Add a per-tab refresh lock and a counter. If a single page performs more than one refresh in a short window, stop automatic renewal and show a sign-in action. A loop that can run forever is an outage amplifier, even when every individual HTTP response is valid.
Next, inspect the four lifecycle actions independently:
- Session creation should bind the authenticated user, device context, and expiry policy.
- Verification should answer whether this exact session is currently valid, rather than infer validity from a decoded client token.
- Refresh should rotate or replace the access credential under a clear replay policy; it must not resurrect a revoked session.
- Revocation should invalidate one session or all sessions, with those choices visible in the product UI.
The diagnostic order matters. Verify the session_id that the browser believes it owns. Compare its result and request id with the refresh event. Then check whether a revoke-all action happened between them. Finally compare the credential actually attached to the failing API call with the credential returned by refresh. Logs that omit this last comparison are mostly theater.
Stop the spinner.
For a migration, run this probe against a staging user and keep the payload supplied by your identity adapter. The adapter owns provider-specific field names; the transport below owns method, authorization, status checks, and backoff. I don't let a browser timer decide any of this: the server's session record and its audit event are the source of truth, while the client only coordinates a bounded attempt. That distinction matters during a cutover because two providers can issue credentials with different expiry formats, rotation rules, and clock tolerances, and a shared adapter must preserve the meaning of verify, refresh, and revoke even when its wire payload changes.
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const baseUrl = process.env.AUTH_API_BASE_URL;
if (!baseUrl) throw new Error("AUTH_API_BASE_URL is required");
async function request(path: string, method: "GET" | "POST", body?: unknown) {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${baseUrl}${path}`, {
method,
headers: {
Authorization: `Bearer ${apiKey}`,
...(body === undefined ? {} : { "Content-Type": "application/json" }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
if (response.status !== 429) {
const text = await response.text();
if (!response.ok) throw new Error(`${response.status}: ${text}`);
return text ? JSON.parse(text) : null;
}
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error("rate limit persisted after four attempts");
}
const sessionId = process.env.SESSION_ID;
const refreshPayload = JSON.parse(process.env.REFRESH_PAYLOAD ?? "{}");
if (!sessionId) throw new Error("SESSION_ID is required");
const verified = await request(`/v1/auth/session/verify/${encodeURIComponent(sessionId)}`, "GET");
const refreshed = verified?.valid
? await request("/v1/auth/session/refresh", "POST", refreshPayload)
: null;
console.log({ verified, refreshed });
This is intentionally boring. The caller supplies the verified refresh payload, so a provider migration does not smuggle guessed fields into the transport. In production, attach an idempotency key to any write your adapter retries, and persist the returned request id beside the session event. A refresh is not permission to retry a failed password sign-in, and a 401 is not permission to refresh ten times.
Picking a replacement without recreating the loop
Managed services differ less in sign-in screens than in the control you retain over session semantics. Here is the comparison I use for a support product that is leaving one managed provider.
| Option | Session controls | Migration shape | Main trade-off |
|---|---|---|---|
| Auth0 | Mature token and refresh-token policies, configurable rules | Export users and map identities, then dual-run verification | Broad surface can mean more configuration to audit |
| Clerk | Hosted components with session management and user-focused APIs | Replace UI and session middleware together | Tighter coupling to its frontend model |
| Firebase Authentication | Client SDKs, refresh tokens, and security-rule integration | Move users into Firebase or link providers | The SDK and Firebase project become part of the application architecture |
| A plain REST auth gateway | You own the lifecycle log and adapter boundary | Keep your domain session table; swap the gateway behind it | More application code and operational responsibility |
Infrai belongs in the last row when the goal is one REST API, one key, and one bill across backend capabilities. That removes a pile of SDK configuration while the auth adapter stays ordinary HTTP, and the same interface can cover other backend modules later. It is a workflow advantage, not proof that every auth workload should move there.
The catch is ownership. If your team needs a polished hosted account center, consent screens, and turnkey compliance workflows, Auth0 or Clerk may be a better fit. Firebase is sensible when the rest of your stack already lives in Google Cloud. Stick with the incumbent when migration risk is higher than the session bugs you are fixing. “One bill” does not erase that decision.
What I would change at scale
At small scale, the probe and a session table are enough. At scale, I would make the lifecycle explicit in code: SessionCreated, SessionVerified, SessionRefreshed, and SessionRevoked, each carrying the same correlation fields. A stream processor can flag a refresh count above a threshold, a refresh after revocation, or an access credential used after its recorded expiry.
I would also separate risk controls. Access credentials should be short-lived because they are sent often. Renewal credentials deserve stricter storage, rotation, reuse detection, and device-level revocation. “Sign out” should revoke the current session_id; “sign out everywhere” should call the all-device operation in the provider adapter and write one audit event per affected session.
There is an uncomfortable edge: clock skew. A browser, API gateway, and identity service can disagree by seconds. I am not sure your provider's exact grace window is documented in one place, so measure it in staging and record the observed boundary before setting client timers. Your mileage may vary across regions.
The migration is complete when a support engineer can answer three questions from logs: which session was active, which credential was presented, and what event made the next action invalid. Until then, changing SDKs is just changing the shape of the loop.
Top comments (0)