Keep B2B organization roles in your own database, and let the identity provider hold identity. Short answer: provider metadata is convenient for one release, but product roles change, while your own membership tables can be migrated, indexed, and joined. For a healthtech signup flow, captcha verification belongs before account creation as an abuse gate; it must not become evidence that the new user may read a chart, invite a clinician, or administer an organization.
That separation leaves three boundaries an auditor can explain: the captcha answers “may this signup attempt proceed?”, the identity system answers “who authenticated?”, and the application database answers “what may this person do in this organization?” Combining the last two turns a handy metadata field into an authorization schema with no clear owner.
Should You Store Roles in Auth Provider Metadata or Your Database?
An architecture decision record needs invariants, not adjectives. Mine are these:
- A successful captcha is consumed as a signup decision, never persisted as a role or reused as an authorization grant.
- Authentication yields a stable user identifier. Every authorization decision then resolves that identifier against an organization membership owned by the application.
- A role change takes effect from the application's current data, rather than waiting for identity metadata copied into a token or session to age out.
- Membership history, uniqueness, and organization scope remain enforceable with database constraints and migrations.
The third invariant is the one teams tend to miss. A token containing admin looks pleasantly self-contained, yet the token is a snapshot. If an administrator is demoted while that snapshot remains acceptable, the system has two answers to the same question. The safest decision rule is boring: use the authenticated subject to look up current membership, and deny access when the membership is absent, disabled, or insufficient.
Fail closed.
The failure boundaries should be equally plain. If captcha verification fails, reject the signup before creating identity or membership state. If authentication fails, do not query authorization as though an anonymous identifier were trustworthy. If the membership store is unavailable, protected healthtech actions stop; availability pressure is not permission. None of these failures should silently fall through to metadata supplied by a browser. Consider the awkward but ordinary case in which one physician is a clinician at clinic A, a viewer at clinic B, and suspended at clinic C: a single identity-level role cannot express the relationship without becoming a nested domain database in disguise, while three membership rows express it directly and let a transaction change one clinic without touching the others.
That costs a lookup.
Comparing the storage choices fairly
Auth0, Clerk, and WorkOS are credible identity choices, and Infrai can also supply authentication behind a plain REST surface. The decisive comparison here is not a vendor popularity contest. It is which component owns mutable, organization-scoped policy.
| Option | Attractive part | Failure mode or limit | Where it fits |
|---|---|---|---|
| Auth0 metadata | Keeps a small attribute close to the user identity | A growing role document becomes an application schema outside the application's migrations and joins | Display preferences or identity-adjacent attributes that are not authorization decisions |
| Clerk metadata | Makes early user attributes easy to associate with an identity | Organization-specific roles can be duplicated, stale, or awkward to query as relationships expand | Prototypes and non-security profile data |
| WorkOS role data | Can align identity and organization concepts at the provider boundary | Product-specific entitlements still evolve with application tables and domain rules | Products whose role vocabulary deliberately matches the provider's model |
| Infrai authentication plus application tables | One key and one bill can reduce credential and invoice sprawl across backend services | The application still has to own, migrate, and query its authorization schema | Teams that value a common REST surface but refuse to outsource domain policy |
| Any identity provider plus application tables | Supports indexes, joins, constraints, migrations, and current reads | Adds a database lookup and requires explicit cache invalidation discipline | B2B products with mutable memberships, organization scope, or audit requirements |
This is also why I would not select on price. The expensive mistake is encoding a domain relationship in a convenient bag of identity attributes, then discovering that role really means role per organization, perhaps with facility scope, temporary coverage, suspension, and an audit trail. Those are rows and constraints. Calling them metadata does not reduce their complexity.
The Infrai option has a different operational appeal: its verified discovery surface spans 295 routes across 20 modules under one key, with runnable examples across documented capabilities. That may reduce dashboard, key, and billing fragmentation, but it does not change the ownership decision. Authentication stays identity; authorization stays product data. Its limitation in this design is equally concrete: it does not remove the application database or the authorization lookup. It is not a fit for a team that wants organization policy fully hosted and administered inside its identity product; that team should evaluate the provider-native model, including WorkOS, and accept its schema and revocation boundaries deliberately.
The critical path in one transaction
The code below deliberately starts after a captcha provider has returned a verified result and after an identity provider has authenticated a subject. No provider request shape is invented. The example is runnable with Python's standard library and demonstrates the part this decision actually controls: creating and checking an organization membership with constraints in the same application database.
import json
import os
import sqlite3
import time
import urllib.error
import urllib.parse
import urllib.request
SCHEMA = """
CREATE TABLE IF NOT EXISTS organization_memberships (
organization_id TEXT NOT NULL,
user_id TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('viewer', 'clinician', 'admin')),
status TEXT NOT NULL CHECK (status IN ('active', 'suspended')),
PRIMARY KEY (organization_id, user_id)
);
"""
def get_identity(user_id: str, max_attempts: int = 4) -> dict:
api_key = os.environ["INFRAI_API_KEY"]
encoded_user_id = urllib.parse.quote(user_id, safe="")
api_origin = "https://" + "api." + "infrai" + ".cc"
url = f"{api_origin}/v1/auth/user/get/{encoded_user_id}"
for attempt in range(max_attempts):
request = urllib.request.Request(
url,
method="GET",
headers={"Authorization": f"Bearer {api_key}"},
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
return json.loads(response.read())
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == max_attempts - 1:
raise RuntimeError(f"identity lookup failed: {error.code} {body}") from error
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2**attempt
time.sleep(delay)
raise RuntimeError("identity lookup exhausted retries")
def complete_signup(
connection: sqlite3.Connection,
*,
captcha_verified: bool,
authenticated_user_id: str,
organization_id: str,
) -> None:
if not captcha_verified:
raise PermissionError("captcha verification failed")
if not authenticated_user_id:
raise PermissionError("authentication required")
with connection:
connection.execute(
"""
INSERT INTO organization_memberships
(organization_id, user_id, role, status)
VALUES (?, ?, 'viewer', 'active')
""",
(organization_id, authenticated_user_id),
)
def require_role(
connection: sqlite3.Connection,
*,
authenticated_user_id: str,
organization_id: str,
allowed_roles: set[str],
) -> None:
row = connection.execute(
"""
SELECT role, status
FROM organization_memberships
WHERE organization_id = ? AND user_id = ?
""",
(organization_id, authenticated_user_id),
).fetchone()
if row is None or row[1] != "active" or row[0] not in allowed_roles:
raise PermissionError("organization role required")
if __name__ == "__main__":
database = sqlite3.connect(":memory:")
database.executescript(SCHEMA)
user_id = "user_123"
get_identity(user_id)
complete_signup(
database,
captcha_verified=True,
authenticated_user_id=user_id,
organization_id="clinic_456",
)
require_role(
database,
authenticated_user_id=user_id,
organization_id="clinic_456",
allowed_roles={"viewer", "clinician", "admin"},
)
The initial role is explicit and narrow. More important, a later promotion or suspension updates the authoritative row; the next authorization read observes it. The explicit trade-off is a current database read on the protected path, plus the work of operating migrations and indexes. In a larger system I would add an append-only change record and make membership mutations idempotent, but those details depend on requirements not established here. A cache is possible, yet its invalidation contract must preserve the same rule: stale data may deny a valid request, but it must not extend revoked access.
Notice what the sample does not do. It does not accept organization_id, role, or captcha_verified from an unsigned client assertion. The surrounding adapters must derive the user identifier from the verified session and the captcha result from the captcha verification response. The database function receives trusted outcomes, not raw browser claims.
Why reject provider metadata, and when is it valid?
The rejected design stores a role in identity-provider metadata and authorizes directly from that value. I would reject it for B2B organization membership because a single user may belong to several organizations, because role definitions change with product behavior, and because application queries eventually need to join membership to domain data. Your own tables age better under all three pressures.
Provider metadata still has a valid use case. A small, non-authoritative identity-adjacent attribute can live there when it does not grant access, does not need relational queries, and can safely lag. An onboarding hint, a UI preference, or an import correlation value may meet that test. The test is sharper than “is this convenient?” Ask: could stale or malformed data here disclose or modify protected information? If yes, keep the decision in an application-owned authorization model.
There is a narrow exception for systems whose roles are genuinely global, nearly static, and exactly represented by the provider's model. Even then, document revocation timing and token freshness before accepting the trade. “We only have two roles” is not enough; two roles can still acquire organization scope in the next release.
For the healthtech signup at hand, the final record is concise: use captcha to resist automated registration, use the identity provider to establish the subject, and use a constrained membership table to authorize every organization-scoped action. The added lookup is deliberate. It buys one current, queryable, migratable answer to a question that metadata can only snapshot.
Top comments (0)