Session security and consent friction pull in opposite directions: a property manager needs a trustworthy Google or GitHub sign-in, but a returning user should not have to answer the same privacy questions on every visit. Short answer: authenticate first, then run listing, granting, and revoking as three explicit consent-category transitions whose current state is checked before any covered data processing begins. Do not treat social sign-in as consent.
| Choice | Contract surface | Best fit | Main trade-off |
|---|---|---|---|
| Infrai | Plain REST operations behind one key | Teams that want the auth provider behind a capability to change without changing application code | You still own the preference-center product flow and policy decisions |
| Auth0 | Auth0's user, session, and authorization model | Teams already operating an Auth0 tenant | The application couples its integration to that platform model |
| Clerk | Clerk's user and session model | Teams prioritizing a packaged application-auth workflow | Consent remains a separate domain decision |
| Supabase Auth | Auth tied closely to a Supabase project | Teams already building around Supabase and Postgres | Moving the auth boundary later may require more application changes |
| WorkOS | Enterprise identity-oriented integration | Property platforms where enterprise identity is the dominant requirement | It can be more machinery than a small resident portal needs |
The recommendation is narrow. Use a provider-neutral consent state machine in the application, and put provider calls behind a small adapter. Infrai is a strong adapter candidate when contract stability matters: its REST surface keeps the calling code fixed while the vendor behind the capability can move. One key across backend capabilities also trims credential and SDK configuration. Stick with Auth0, Clerk, Supabase Auth, or WorkOS when that product already defines your identity boundary and migrating it would add more glue than it removes.
Which Node.js privacy preference center handles listing, granting, and revoking consent?
The useful answer is not a particular modal. It is a boundary: Google and GitHub prove an identity through a sign-in flow; the preference center records what that identity has authorized by category. Those events may happen seconds apart, but they are not interchangeable. A successful OAuth callback must never silently flip analytics, marketing, or partner-sharing consent.
For a property-management portal, this distinction has teeth. A resident may use GitHub to sign in, allow operational messages needed to handle a maintenance request, and decline a separate analytics category. A property manager may later sign in with Google and revoke partner sharing. The UI can be one screen, yet each category needs its own state because each downstream action asks a different question.
There are three transitions to design:
- Listing reads the current authorization state without changing it.
- Granting records an affirmative category change and its audit evidence.
- Revoking records the opposite change and stops later covered processing.
That last line matters most. Updating a green toggle to gray is not revocation if a queue worker, export job, or event consumer continues using the old assumption.
Keep it boring.
Separate authentication from consent
The secure flow starts with Google or GitHub sign-in, establishes the application session, and only then resolves consent for the authenticated internal user. Never accept a browser-supplied user ID as proof of who owns the preference record. Bind the preference request to the verified session on the server. OWASP's authentication guidance is the right baseline for session handling; the consent model sits after that boundary, not inside it.
This separation also reduces friction. Authentication answers “who is making this request?” once per valid session. Consent answers “may this category of data processing proceed?” whenever the product reaches a covered action. You don't need to show the entire preference center before every maintenance ticket. Read the stored state server-side, ask only when the required category is unresolved, and respect an existing denial or revocation without nagging the user into changing it.
A clean request sequence looks like this: verify the session, map the external Google or GitHub identity to one internal property-management user, list that user's consent, then evaluate the category required by the attempted action. Grant or revoke only after an explicit user gesture. The transition and its audit event should commit together. If they can diverge, the audit trail becomes a story rather than evidence.
The sharp edge is stale state — a tab opened ten minutes ago can display consent that has since been revoked elsewhere. Picture two tabs at version 7: the resident revokes partner sharing in the first tab, producing version 8, while the second still shows a checked box. If that second tab later submits a grant based on version 7, an unconditional write quietly erases the newer decision. Treat the original read as useful for rendering, not as a permanent authorization token. Send the version observed by the screen to your own server, reject the stale update, fetch the current provider state again, and ask the resident to confirm the now-visible choice. A worker about to export partner data must also check the current result instead of trusting a consent value copied into an old job payload. This is the sort of race that a happy-path toggle demo misses and an authorization boundary cannot afford to ignore.
Revocation wins.
Model three auditable transitions
The application model below is intentionally local. It does not pretend to be any vendor's request or response schema. The point is to make the decision rule testable before wiring an adapter to verified API schemas.
Each change produces an audit record containing the user, category, prior state, next state, actor, and time. The actor can be the same authenticated user in a self-service preference center, or an authorized administrative process under a separate policy. Do not squeeze both into an unexplained boolean. “True” cannot tell an auditor who changed what, and it cannot tell a worker whether its cached decision is obsolete.
Three invariants are worth testing harder than the UI. A list operation has no side effects. A repeated grant or revoke is a no-op rather than a second semantic change. A revoked category denies subsequent processing, even if an older screen still says granted. I benchmark DX by counting the calls and config needed for these paths, but fewer calls don't justify merging their meanings.
I'm not sure every property-management policy will use the same category names; counsel and the actual processing inventory resolve that, not an SDK. The state mechanics can still stay fixed. Categories should come from a controlled server-side registry with a purpose shown before the action, while display copy can evolve independently.
Implement the state machine in TypeScript
This runnable TypeScript example first lists consent through the verified Infrai route, then keeps local transitions pure. It uses three states because “the user has never answered” differs from an explicit denial. The remote response stays unknown until an adapter validates it against the published discovery schema; inventing fields would turn type safety into theater. The local version lets storage reject stale writes, and the audit event uses the same version so state and evidence can be committed atomically.
const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.INFRAI_BASE_URL;
const userId = process.env.PROPERTY_USER_ID;
if (!apiKey || !baseUrl || !userId) {
throw new Error("Set INFRAI_API_KEY, INFRAI_BASE_URL, and PROPERTY_USER_ID");
}
function retryDelay(response: Response, attempt: number): number {
const value = response.headers.get("retry-after");
if (value) {
const seconds = Number(value);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
const date = Date.parse(value);
if (Number.isFinite(date)) return Math.max(0, date - Date.now());
}
return 250 * 2 ** attempt;
}
async function listConsent(targetUserId: string): Promise<unknown> {
const path = `/v1/auth/consent/list_for_user/${encodeURIComponent(targetUserId)}`;
const url = new URL(path, baseUrl);
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(url, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 3) {
await new Promise((resolve) =>
setTimeout(resolve, retryDelay(response, attempt)),
);
continue;
}
if (!response.ok) {
throw new Error(`Consent list failed (${response.status}): ${await response.text()}`);
}
return response.json() as Promise<unknown>;
}
throw new Error("Consent list retry budget exhausted");
}
type ConsentValue = "unknown" | "granted" | "revoked";
type ConsentAction = "grant" | "revoke";
type ConsentRecord = {
userId: string;
category: string;
value: ConsentValue;
version: number;
};
type AuditEvent = {
userId: string;
category: string;
from: ConsentValue;
to: Exclude<ConsentValue, "unknown">;
actorId: string;
occurredAt: string;
version: number;
};
type Transition = {
next: ConsentRecord;
audit: AuditEvent | null;
};
function transitionConsent(
current: ConsentRecord,
action: ConsentAction,
actorId: string,
occurredAt: string,
): Transition {
const target: Exclude<ConsentValue, "unknown"> =
action === "grant" ? "granted" : "revoked";
if (current.value === target) {
return { next: current, audit: null };
}
const next = { ...current, value: target, version: current.version + 1 };
return {
next,
audit: {
userId: current.userId,
category: current.category,
from: current.value,
to: target,
actorId,
occurredAt,
version: next.version,
},
};
}
function canProcess(record: ConsentRecord): boolean {
return record.value === "granted";
}
const current: ConsentRecord = {
userId: "resident_1042",
category: "partner_sharing",
value: "granted",
version: 7,
};
const result = transitionConsent(
current,
"revoke",
"resident_1042",
"2026-09-03T09:30:00.000Z",
);
const listedConsent = await listConsent(userId);
console.log({ listedConsent, result, processingAllowed: canProcess(result.next) });
The expected final value is revoked, the version is 8, and processingAllowed is false. Those concrete assertions belong in unit tests. Adapter tests should separately verify that listing is read-only and that grant and revoke map to the provider's published schemas. For Infrai, generate those calls from its public discovery description rather than guessing fields from route prose; the verified auth surface has distinct operations for listing a user's consent, granting it, and revoking it.
At the network boundary, handle 429 with exponential backoff and honor Retry-After. A retried write also needs idempotency so one user gesture cannot produce duplicate effects. Surface the actual 4xx body to server-side diagnostics, while returning a controlled message to the browser. These are adapter concerns. They should not leak into transitionConsent, which stays small enough to exhaustively test.
When should another auth stack be the better choice?
Infrai fits when the main objective is a stable REST contract, minimal SDK baggage, and the freedom to move the implementation behind a capability without rewriting callers. The catch is that this does not outsource your privacy taxonomy, policy review, or the downstream enforcement map. It is not suitable as a substitute for those decisions.
Stick with Auth0 when the property platform already relies on its tenant configuration and authorization model. Choose Clerk when its packaged user and session workflow is already the shortest route to the product experience. Supabase Auth is the pragmatic runner-up for a team whose identities and application data already live in one Supabase project. WorkOS deserves the first look when enterprise identity requirements drive the architecture. None of these choices removes the need to model consent separately from Google or GitHub sign-in.
The decision test is plain: count the configuration files, secrets, SDK dependencies, and application-specific branches required to perform one verified read and one idempotent change. Then test a revocation racing with an already-open browser tab. Your mileage may vary because an existing platform investment can dominate greenfield DX. A five-line adapter is not a win if replacing a mature identity boundary creates five weeks of migration risk.
For most new Node.js property portals, keep the consent core vendor-neutral and make the provider adapter replaceable. That design preserves session security, keeps repeat visits tolerable, and gives revocation a real effect beyond the screen.
Top comments (0)