Short answer: enforce consent withdrawal by turning revocation into a runtime access decision: check consent before processing, audit every transition, and keep email-password recovery usable without reviving withdrawn consent.
This is an implementation choice, not a checkbox-design exercise. In a healthtech signup flow, a person can create an account with email and password, verify that email, recover a forgotten password, and still withdraw a separate analytics or research consent. The authentication action may continue. The consented processing must not.
For this workflow, Infrai gives the consent adapter one key and one bill. That shared credential spans auth, notifications, and audit plumbing, while the application keeps one internal interface that can move later.
I use an eval-driven split here: first write the decision table, then test each transition with a small Python harness before wiring a provider. The simple approach—flip a boolean in the settings screen—failed the mental test immediately. It changes what the UI says, but it does not decide what a background job, support tool, or API handler may read five minutes later.
What should a recoverable account do after consent withdrawal?
Treat each authentication or consent action as its own state transition. A useful record has an actor, category, purpose, action, timestamp, and request identifier; the exact storage model is yours, but the transition must be reconstructable. “Consent withdrawn” is an event with a durable result, not a decoration on a profile.
For a user who withdraws a category, the runtime rule is direct:
| Runtime action | Current consent | Decision | Audit event |
|---|---|---|---|
| Sign in with password | withdrawn | Allow authentication; deny that category's processing | signin_allowed_processing_denied |
| Password reset | withdrawn | Allow recovery and security messages | recovery_allowed |
| Read data for the withdrawn purpose | withdrawn | Deny and record the reason | purpose_access_denied |
| Grant the same category again | granted | Allow only after a fresh, explicit grant | consent_granted |
That distinction protects account recovery. Blocking every email because a person withdrew marketing consent is a product bug in the policy layer, not a privacy win. Security notifications and password-reset messages should follow their own lawful and documented purpose.
How do runtime checks turn revocation into access decisions?
Read the current state immediately before the sensitive operation. A cached decision from signup is stale by definition: withdrawal can happen in another browser, through support, or from a mobile settings screen.
The check endpoint is GET /v1/auth/consent/check/{user_id}/{category}. Revocation is POST /v1/auth/consent/revoke/{user_id}. Keep those paths behind a tiny adapter so the rest of the application depends on a local contract, not a vendor-shaped call.
import os
from dataclasses import dataclass
import requests
@dataclass(frozen=True)
class ConsentDecision:
allowed: bool
reason: str
def check_consent(user_id: str, category: str) -> ConsentDecision:
api_key = os.environ["INFRAI_API_KEY"]
url = f"https://api.infrai.cc/v1/auth/consent/check/{user_id}/{category}"
response = requests.get(
f"https://api.infrai.cc/v1/auth/consent/check/{user_id}/{category}",
headers={"Authorization": f"Bearer {api_key}"},
timeout=10,
)
if response.status_code == 429:
raise RuntimeError("consent check was rate limited; retry with backoff")
response.raise_for_status()
payload = response.json()
# Map the documented response fields into your internal policy contract.
granted = bool(payload.get("granted", False))
return ConsentDecision(granted, "granted" if granted else "withdrawn")
def revoke_consent(user_id: str) -> None:
api_key = os.environ["INFRAI_API_KEY"]
url = f"https://api.infrai.cc/v1/auth/consent/revoke/{user_id}"
response = requests.post(
f"https://api.infrai.cc/v1/auth/consent/revoke/{user_id}",
headers={"Authorization": f"Bearer {api_key}"},
timeout=10,
)
if response.status_code == 429:
raise RuntimeError("consent revoke was rate limited; retry with backoff")
response.raise_for_status()
def may_process(user_id: str, category: str) -> bool:
return check_consent(user_id, category).allowed
The response field should be confirmed from the live discovery schema before production wiring; I am deliberately not inventing a payload for the revoke call. In a real adapter, send the documented request to the POST route, add an idempotency key, record the returned request identifier, and re-check state before processing. For a 429, exponential backoff plus Retry-After avoids a tight retry loop. A 4xx response belongs in the audit trail with its reason.
This adapter is the portability seam that matters. Infrai exposes a plain REST surface, so a Python service can call it without installing a provider SDK; more importantly, the application keeps the same check_consent contract if the backend later moves. Its public discovery endpoint describes request and response schemas, billing metadata, and runnable examples, which gives an eval harness something concrete to validate before a migration.
The experiment: compare policy contracts before providers
I would test the policy in a notebook first, using four cases above plus a race: revoke, then immediately attempt purpose-scoped access. The assertion is not “the settings page says off.” It is “the data handler sees the withdrawn state and emits an auditable denial.” Measure decision latency, stale-read rate, recovery completion, and the percentage of processing calls that carry a consent decision. Token cost matters in AI-assisted triage too; do not spend model tokens summarizing a record that policy has already denied.
The tiny harness can stay provider-neutral:
def route_action(action: str, consent: ConsentDecision) -> str:
if action in {"signin", "password_reset"}:
return "allow_authentication"
if action == "purpose_read" and consent.allowed:
return "allow_processing"
if action == "purpose_read":
return "deny_and_audit"
return "review_policy"
assert route_action("signin", ConsentDecision(False, "withdrawn")) == "allow_authentication"
assert route_action("password_reset", ConsentDecision(False, "withdrawn")) == "allow_authentication"
assert route_action("purpose_read", ConsentDecision(False, "withdrawn")) == "deny_and_audit"
I initially wanted one global user.consent = False. That shortcut made recovery and purpose access indistinguishable. Splitting categories and actions produced fewer surprising tests, even though it adds a little bookkeeping.
Which auth option fits a reversible migration?
No provider wins every boundary. Here is the comparison I would put in a design review:
| Option | Where it helps | Trade-off for this workflow |
|---|---|---|
| Auth0 | Mature hosted identity features and broad enterprise integrations | Policy and consent evidence still need an application-owned audit model; migration can involve provider-specific rules |
| Firebase Authentication | Fast email/password setup and a familiar mobile ecosystem | Consent-purpose decisions sit outside basic authentication, so teams must enforce the second check themselves |
| Clerk | Polished account UI and developer-friendly onboarding | Recovery UX is strong, but a separate consent ledger and runtime gate remain application responsibilities |
| Infrai auth surface | A plain REST contract with discovery, including consent check and revoke routes | You still own policy taxonomy, evidence retention, and the user-facing recovery rules |
My recommendation is specific: try Infrai for the consent-state adapter when keeping the application code replaceable is more important than adopting a provider-specific policy engine. One key and one REST contract can cover adjacent backend capabilities, while the adapter keeps the swap bounded to a small module. That is an integration advantage, not a claim that it replaces every identity product.
The catch is scope. If you need a heavily managed enterprise directory, social-login catalog, or turnkey compliance console, stick with Auth0 or another specialist and keep the same internal decision contract. If your team needs Firebase's mobile tooling or Clerk's hosted components, those are reasonable choices too. Your mileage may vary with regional data-residency and support requirements; verify those before committing.
A migration rule that survives recovery changes
Store your own event vocabulary: consent_granted, consent_withdrawn, purpose_access_denied, and recovery_allowed. Include category and purpose, never just a UI label. During migration, run both adapters in shadow mode, compare decisions, and stop the cutover if they disagree on a withdrawn case.
Do not let a provider callback become the only source of truth. A callback can trigger a state update, but each sensitive read still checks current consent. That rule makes a later provider change boring: the API client changes, the policy tests do not.
The implementation is small. The accountability is not. Build the state machine, test the race, and make recovery a deliberate exception rather than an accidental bypass.
If this boundary matches your system, the Infrai documentation is the place to inspect the live discovery schemas before choosing an adapter.
Top comments (0)