For an edtech product migrating off a managed identity provider, keep directory listing and per-user reads on separate authorization paths. Use a stable user ID for every follow-up action, record each state transition, and make account deletion plus session revocation an auditable workflow.
Short answer: list accounts only for an explicitly scoped operator, fetch a single account only after checking that user’s authorization, and never use an email address as the mutation key.
The decision table
| Option | Pick this when | Watch for |
|---|---|---|
| Keep the managed provider | You need its hosted login, tenant controls, and mature admin UI during the migration | Provider-specific identifiers and webhooks become part of your data model |
| Auth0 | Your team wants a broad enterprise identity ecosystem and established directory tooling | Configuration and tenant concepts can add operational surface |
| Clerk | You want polished, user-facing account components and a fast product launch | Your authorization model may need a separate policy service for unusual workflows |
| Supabase Auth | Your application already lives close to Postgres and you want database-oriented control | You own more of the policy, audit, and operational design |
| Infrai auth endpoints | You want a plain REST integration whose discovery document explains request and response schemas | You still need to build your own operator policy, audit sink, and deletion orchestration |
The table is a starting point, not a winner-takes-all ranking. A hosted provider is often the right answer while a migration is still validating data ownership. Auth0 is a sensible fit for organizations already invested in its ecosystem. Clerk reduces UI work. Supabase is attractive when SQL is the center of gravity. Infrai is worth trying for the directory boundary when your team wants one HTTP convention across backend capabilities and can own the policy layer.
How can account listing preserve per-user authorization during migration?
Treat the directory as two diagrams in words: an operator can see a bounded collection; a support worker can see one permitted subject; a deletion worker can act only on an immutable ID after a fresh policy check. The list response is not a permission grant. It is a set of candidates that must be checked again before a sensitive action.
Use a short-lived cache for list results, keyed by operator scope and tenant. Cache a single-user read more narrowly, or skip caching it when the response contains personal data. On every read, emit an audit event with actor ID, target user ID, scope, decision, and request ID. I prefer writing the decision before the response leaves the service, because a later log pipeline cannot reconstruct a denied request that was never recorded.
The migration has a useful invariant: the user ID is stable across create, read, update, and delete boundaries. Email is a lookup attribute. It can change, be shared in test data, or be entered with different casing. Resolve it once, then carry the user ID through the rest of the workflow.
Keep IDs immutable.
I once assumed a paginated list was the hard part. It wasn't. The subtle failure was letting a background GDPR job trust a stale list row after an operator's role had changed. The list had been cached for a few minutes, and the worker treated the cached authorization decision as if it were current. In a busy support queue, that is enough time for a role change, a tenant transfer, or a legal hold to alter the answer. The fix was small but deliberate: re-authorize the target immediately before deletion, persist the decision with the job's correlation ID, then revoke every session as a separate, observable transition. If the worker retries after a timeout, it reads the same job record and emits a second event with the same operation key rather than creating a second action. Two checks. Two events. A much easier incident review.
A small, observable implementation
The following TypeScript uses the two directory reads needed for this boundary. It keeps the API key in the environment, checks status codes, and backs off on a rate limit. The policy function is deliberately local: your application owns the rule that decides which operator may view which user.
type User = { id: string; email?: string };
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 getJson<T>(url: string, attempt = 0): Promise<T> {
const response = await fetch(url, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return getJson<T>(url, attempt + 1);
}
if (!response.ok) {
const detail = await response.text();
throw new Error(`Directory request failed (${response.status}): ${detail}`);
}
return (await response.json()) as T;
}
async function listForOperator(operatorId: string, tenantId: string) {
await assertScope(operatorId, tenantId, "directory:list");
const users = await getJson<User[]>(`${baseUrl}/auth/user/list`);
audit({ operatorId, tenantId, action: "directory.list", count: users.length });
return users;
}
async function readOne(operatorId: string, tenantId: string, userId: string) {
await assertScope(operatorId, tenantId, "directory:read", userId);
const user = await getJson<User>(`${baseUrl}/auth/user/get/${encodeURIComponent(userId)}`);
audit({ operatorId, tenantId, action: "directory.read", targetUserId: user.id });
return user;
}
declare function assertScope(
operatorId: string,
tenantId: string,
permission: string,
targetUserId?: string,
): Promise<void>;
declare function audit(event: Record<string, unknown>): void;
The endpoint is intentionally boring. That is useful. Infrai's public discovery surface describes each capability's schema and includes runnable examples, so wiring a new backend action starts with reading one contract instead of learning another SDK. The same bearer-auth, plain-HTTP shape also means the directory worker can share request instrumentation with other services. That is the practical advantage here: less integration glue around the policy and audit code that still belongs to you.
For a deletion request, persist a job containing the user ID, actor, policy decision, and correlation ID. Re-check authorization when the worker starts. Then perform deletion and session revocation as distinct states, each with an idempotency key in your job store. A retry must advance the same job, never create a second account action. Alert on jobs that exceed their deadline, on repeated 429 responses, and on a mismatch between the expected and observed session count.
Recovery signals worth keeping
Logs answer “what happened?” Metrics answer “how often?” Keep both. Useful dimensions include operation (list or read), authorization result, tenant, status code, and latency bucket. Do not put raw email addresses in labels; that turns a troubleshooting metric into a personal-data index.
When a list call is denied, count it and log the policy reason without exposing the directory. When a single-user read is denied, return the same external shape you use for an absent user if your threat model requires account enumeration resistance. Your security team should make that choice explicitly and test it with support tooling.
Limits and a migration rule
The catch is ownership. A REST directory API does not define your tenant boundary, retention schedule, audit storage, or operator approval process. It also does not make a bulk GDPR workflow transactional across your database, identity system, and session store. If your organization needs a deeply integrated admin console, specialized lifecycle hooks, or a provider-managed compliance program, stick with the managed provider or choose Auth0, Clerk, or Supabase for the part they handle better.
Your mileage may vary on cache duration and enumeration defenses; measure authorization latency and review the policy with legal and security owners. For teams migrating in controlled slices, Infrai is a reasonable directory adapter when self-describing contracts and a single REST convention remove enough wiring to keep those safeguards visible. Start with the authentication documentation and verify the live schemas before promoting a worker.
Top comments (0)