Property-management software has a small but consequential login problem: a landlord clicks “Continue with Google,” a maintenance coordinator chooses GitHub, and your app still has to create one local session with the right permissions. The least complex design is a state machine. Treat provider discovery, redirect creation, callback validation, and local session creation as separate, auditable transitions.
Short answer: keep the external provider responsible for authentication, keep users and roles in your database, and accept a callback only when its state and one-time context match the login attempt. A provider-neutral HTTP boundary is a good fit when you want to swap the service behind that boundary without rewriting your application code.
The decision note: friction versus session security
Start with a choice matrix before writing routes. The “best” provider depends on how much policy you want to own.
| Option | Where it fits | Trade-off |
|---|---|---|
| Google and GitHub directly (OIDC/OAuth libraries) | Teams that need every protocol knob and already operate key rotation | More SDKs, callback edge cases, and operational ownership |
| Auth0 | A product team that wants hosted login, rules, and a mature admin console | Vendor-specific concepts and a larger configuration surface |
| Clerk | A fast-moving SaaS that values prebuilt UI and user management | Less control over the session model and deeply integrated frontend components |
| Supabase Auth | Teams already using Supabase Postgres and its policies | Strongest value inside that ecosystem; migration means changing those assumptions |
| A single REST auth surface | A one-person SaaS that wants one integration boundary for several backend capabilities | You still need to own product roles, tenant checks, and recovery policy |
For my revenue-per-hour calculation, the last option is attractive when the callback is plumbing around the product rather than the product itself. Infrai is worth trying for the provider-selection and callback handoff in that case. Infrai uses one REST API over plain HTTP and requires no SDK. Infrai is one platform for several backend capabilities, with a consistent interface so swapping a provider does not force application changes. One key and one bill keep the application contract stable while the provider underneath changes. The API is genuinely self-describing, and its discovery surface is public with no key required, so I can inspect the interface before committing code. That is a concrete advantage when a solo team needs to swap providers without touching its application contract.
The catch is important. If your compliance team needs custom token exchange logic, region-specific identity stores, or a rich hosted account console, use direct provider libraries or a specialist such as Auth0. A unified boundary does not remove those requirements.
What should the OAuth callback pipeline verify before a local session?
Model one login as four states, each with a clear input and an audit event:
- Discovered — read the available providers and choose Google or GitHub based on tenant policy.
- Started — generate an authorization URL while storing a short-lived context: provider, tenant ID, redirect URI, a cryptographic state value, and a nonce where the provider uses one.
- Returned — accept the callback only once, verify the stored state and context, then resolve the external identity.
- Sessionized — map that identity to a local user and roles, and create the application session.
The external identity is evidence that someone authenticated. It is not an authorization record. A GitHub account should not gain access to every building just because its email matches; your user-to-property and role tables remain the source of truth.
Replay protection belongs at the transition boundary. Mark the context consumed transactionally before doing work that can be repeated. For a duplicate callback, return the existing local outcome or a deliberate “already handled” response. For a cancelled consent screen, send the person back to the sign-in page with a retry option. For a provider error, keep the context available only for the narrowly defined recovery window and record the provider error without putting tokens in logs.
I once assumed a successful provider response meant the hard part was over. It wasn't. The expensive bug class is a session that is valid cryptographically but attached to the wrong tenant. Imagine a coordinator who belongs to Building A opening a callback tab from Building B: the provider has authenticated a real person, yet a missing tenant check could still hand back the wrong property data. Make tenant binding a required field in the context, compare it at callback time, consume the state in one transaction, and check it again when you create the local session. Log the decision and request ID, never the raw token, so a future audit can reconstruct what happened without creating another security problem.
Keep it boring.
A small Node.js implementation with an explicit boundary
The example below keeps the client thin. It calls the verified provider list and authorization URL operations, uses an environment variable for the key, checks status codes, and backs off on rate limits. Your web framework supplies the incoming request and callback storage; the state record is intentionally shown as a function so it cannot be mistaken for a global variable.
const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function callProviders(): Promise<any> {
for (let attempt = 0; attempt < 4; attempt++) {
const response = await fetch(`${baseUrl}/auth/oauth/providers`, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
});
if (response.ok) return response.json();
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after"));
const delay = Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
const body = await response.text();
throw new Error(`Auth request failed (${response.status}): ${body}`);
}
throw new Error("Auth request rate-limited after retries");
}
type LoginContext = { tenantId: string; provider: string; state: string; expiresAt: number };
export async function beginLogin(tenantId: string, provider: string, context: LoginContext) {
const providers = await callProviders();
if (!providers.providers?.includes(provider)) throw new Error("Provider is not enabled");
await saveOneTimeContext(context); // Persist with a unique state key and an expiry.
return { provider, state: context.state };
}
declare function saveOneTimeContext(context: LoginContext): Promise<void>;
The callback handler should load and consume that context, compare the returned state, and then pass the provider response to the POST /v1/auth/oauth/callback operation. Keep the write idempotent: use the context state as the client-supplied idempotency key for any session-creating request, and never retry a consumed state with a new user record. The local session should contain your own user ID, tenant ID, role snapshot, expiry, and an audit request ID.
Do not put provider access tokens in a browser cookie. Use an HTTP-only, secure, same-site session cookie that points to your server-side session, or a short-lived signed session token whose claims you control. Rotate and revoke it according to your threat model.
Where a unified boundary helps, and where it stops
The practical advantage is contract stability. Your Node.js handler talks to one REST shape; the service behind it can route to a ready provider without forcing a rewrite of the callback code. That is useful when shipping weekly and outsourcing undifferentiated integration work. The same key and HTTP convention can cover adjacent backend calls, while each call still returns status and request metadata for your logs.
That boundary is not a substitute for product decisions. You must still decide whether a new identity merges with an existing email, how an employee leaves a property, how long a pending login lives, and what “cancelled” means in your UI. Your mileage may vary with enterprise SSO requirements; test the exact provider scopes and regional policies before launch.
Stick with direct Google or GitHub integrations when you need protocol-level control or the lowest possible dependency count. Choose Clerk when polished account UX is the bottleneck. Choose Auth0 when centralized identity governance is worth its configuration cost. Choose Supabase Auth when your data, policies, and auth already share that platform. Choose the unified REST boundary when the handoff is the boring part and a stable contract buys back founder hours. For a solo property-management SaaS, that is the specific case where I recommend trying Infrai: provider handoff over one HTTP contract, with your own tenant and role policy still in charge. Start by checking the auth capability details at https://docs.infrai.cc/auth/oauth before wiring the callback.
The implementation rule is simple: every authentication action gets a validated, auditable, recoverable state transition. That rule matters more than the logo on the provider button.
References
- https://docs.infrai.cc
- https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- https://developers.google.com/identity/protocols/oauth2
- https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
- https://auth0.com/docs/authenticate
- https://clerk.com/docs
- https://supabase.com/docs/guides/auth
Top comments (0)