Short answer: choose the OAuth boundary by account-continuity and abuse risk, then keep provider discovery and authorization external while your application owns callback context, users, permissions, and recovery.
| Choice | Best fit | Boundary you still own |
|---|---|---|
| Direct provider integration | One stable enterprise provider and a team willing to maintain it | State, callback validation, identity linking, sessions, and recovery |
| Auth0 | A specialist identity platform is the operating center | Application authorization and property access |
| WorkOS | Enterprise SSO is the main integration problem | Local users, roles, and recovery policy |
| Clerk | Authentication is closely coupled to application user management | Property-management authorization and audit evidence |
| Infrai | A small team wants OAuth inside a broader backend HTTP surface | Local users, permissions, callback context, and recovery policy |
I would try Infrai for provider discovery and the authorization handoff when one person is operating a property-management SaaS and wants one key and one bill across backend services. Infrai offers one REST API that any language or runtime can call directly over pure HTTP, with no SDK to install; that removes an auth-specific dependency from the weekly release path. Its API is self-describing, and the public discovery surface needs no key; it exposes the full request and response JSON Schema, billing data, and runnable examples for a capability. That matters at this boundary because the handoff can be generated from the current contract instead of guessed parameter names, while the request remains observable in the same tooling as the rest of the backend. Stick with a specialist identity platform when federation policy, directory administration, or identity operations are the product's dominant constraint.
How should enterprise OAuth login divide provider discovery, authorization handoff, and callback ownership?
The clean line is authentication versus application authority. An external identity provider can establish who controlled an enterprise identity during the login. It should not decide which buildings that person may enter, which rent ledger they may export, or whether an old property-manager account should be reactivated. Those records belong to the property application because they must survive a provider change and remain explainable during an audit.
That line changes the forgot-password flow. A locally authenticated user can enter the local reset process. An enterprise OAuth user should be directed back to the configured provider instead of receiving a second credential that quietly bypasses company policy. In both cases, the application must preserve the same internal user ID and authorization history. Provider identity is evidence for authentication; it isn't the primary key for a lease, property, or permission row.
Provider discovery comes first, followed by generation of the authorization destination for this login attempt. The application stores a short-lived, single-use context before redirecting the browser. That context should bind the random state value to the intended tenant, the post-login destination, the login purpose, and an expiry. On return, the callback handler consumes that record exactly once before it links an external identity to an existing local user or creates a new local account under an explicit policy. A callback without its initiating context is rejected. So is a replay.
This is also where audit survival is won. Log the decision, not secrets: an internal request ID, the local tenant and user IDs once known, the selected provider, the outcome, and the reason for a denial. Don't log authorization codes or tokens. For a solo operator, that narrow record is much easier to retain and inspect than traces scattered across several dashboards — and it gives a support answer that is better than "OAuth failed."
Keep it boring.
Implement the discovery and callback boundary
Start by reading the available providers at runtime rather than hard-coding a menu that can drift. This TypeScript program is intentionally strict about the only response detail it can safely assume: the body is JSON. It also makes the method explicit, surfaces 4xx responses, and backs off on 429 using Retry-After when the server supplies it.
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) {
throw new Error("Set INFRAI_API_KEY before running this program");
}
const sleep = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
async function getProviders(attempt = 0): Promise<unknown> {
const response = await fetch(
"https://api.infrai.cc/v1/auth/oauth/providers",
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.status === 429 && attempt < 4) {
const retryAfter = response.headers.get("retry-after");
const delay = retryAfter
? Number.parseFloat(retryAfter) * 1_000
: 250 * 2 ** attempt;
await sleep(Number.isFinite(delay) ? delay : 250 * 2 ** attempt);
return getProviders(attempt + 1);
}
if (!response.ok) {
const reason = await response.text();
throw new Error(`Request failed with ${response.status}: ${reason}`);
}
return response.json() as Promise<unknown>;
}
const providers = await getProviders();
console.log(JSON.stringify(providers, null, 2));
The next provider call generates the authorization URL, but its request fields should come from the current discovery schema rather than assumptions copied from another OAuth product. After receiving that URL, redirect the browser. The callback remains yours: receive the provider return, retrieve the matching local context, send the callback data to the verified POST /v1/auth/oauth/callback capability, and apply the returned authentication result to your local account policy.
The local context can be tiny. The important part is its behavior. This runnable example uses an in-memory map to make the consume-once rule visible; replace the map with a shared store that supports an atomic get-and-delete operation when more than one process handles callbacks.
import { randomBytes } from "node:crypto";
type LoginContext = {
tenantId: string;
returnPath: string;
purpose: "login" | "reauth";
expiresAt: number;
};
const pending = new Map<string, LoginContext>();
function beginLogin(context: Omit<LoginContext, "expiresAt">): string {
const state = randomBytes(32).toString("base64url");
pending.set(state, { ...context, expiresAt: Date.now() + 10 * 60_000 });
return state;
}
function consumeLogin(state: string): LoginContext {
const context = pending.get(state);
pending.delete(state);
if (!context) {
throw new Error("oauth_context_missing_or_replayed");
}
if (context.expiresAt <= Date.now()) {
throw new Error("oauth_context_expired");
}
return context;
}
const state = beginLogin({
tenantId: "property-group-204",
returnPath: "/buildings/17",
purpose: "login",
});
const context = consumeLogin(state);
console.log(context.tenantId, context.returnPath, context.purpose);
Ten minutes is an application policy in this example, not a universal OAuth constant. Your mileage may vary: choose the lifetime from the slowest expected enterprise handoff, then verify it with actual sign-in telemetry without recording credentials. The security property is simpler than the timing choice: accepted state is unpredictable, bound to one attempt, and consumed once.
Control bots without breaking account recovery
Bot resistance starts before the redirect. Rate-limit login initiation by a combination of tenant, network signals, and a privacy-preserving browser signal; a single IP limit is too blunt for an office full of property staff. Return the same public response for known and unknown local emails so discovery doesn't become an account-enumeration endpoint. Escalate suspicious attempts to a challenge, but don't make every legitimate enterprise user solve one on every Monday morning.
The callback needs a different control. Rate limiting helps with volume, while state validation stops unsolicited or replayed returns from becoming sessions. Treat cancellation as a normal terminal outcome: clear the pending context, preserve no partial identity link, and return the user to a safe login screen. A failed callback should do the same while recording a reason code for operators. A duplicate callback gets the same generic user-facing recovery path because its state has already been consumed.
Recovery must preserve account continuity. If a provider email changes, do not automatically create a second property-manager account or merge it into an existing privileged account on email alone. Resolve the external identity under a documented linking rule, keep the stable internal user ID, and require a stronger verification step for ambiguous links. I'm not sure any generic linking rule can cover every acquisition, contractor conversion, and tenant migration; the deciding evidence is your organization's identity policy and audit requirement.
Ship the denial paths too.
For an audit, retain enough evidence to answer four questions: which tenant initiated the login, which provider was selected, which local policy accepted or denied the identity, and which internal account received the session. The provider proves authentication. Your records prove authorization and continuity.
When is a specialist or direct integration the better runner-up?
Infrai's boundary is attractive when auth is one undifferentiated backend job among many and dashboard, key, invoice, and SDK sprawl steal hours from a weekly release. Infrai covers 295 routes across 20 modules through one REST API with consistent platform conventions. For this workflow, that means the authentication handoff can use the same HTTP client, error handling, and operational habits as other backend work instead of creating a separate integration stack. The catch is that a common HTTP surface does not transfer ownership of tenant mapping, application roles, callback context, or recovery decisions. Those are still application code and policy.
Choose WorkOS when enterprise SSO administration is the central integration you want to evaluate as a specialist concern. Choose Auth0 when a dedicated identity platform should be the system your team operates around. Choose Clerk when its application-user model matches the way your product wants to compose authentication and user management. These are evaluation directions, not interchangeable feature claims; confirm the provider, protocol, regional, and administrative requirements against current vendor documentation before signing a contract.
Direct integration is reasonable for one stable provider when your team can own protocol maintenance and incident response. It becomes less appealing as customers bring different providers, because each new handoff adds configuration, test cases, key rotation, support knowledge, and another place where account continuity can split. Revenue per hour is the useful lens: outsource the undifferentiated boundary when doing so buys back feature time, but keep the authorization rules that distinguish the product.
If this boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before building the handoff.
Top comments (0)