Short answer: treat consent as a versioned state machine alongside the one-time-code challenge, and make every grant or revocation produce an auditable event before a session is accepted. Categories describe what a patient agreed to; current state says whether that agreement is effective now. Keeping those ideas separate is what prevents a “consent=true” flag from becoming a bypass for bots or an accidental data share.
I build RAG and agent features in Python, so I tend to test a policy in a notebook before wiring it into a service. For a healthtech login flow, that habit exposed a nasty assumption: a valid SMS code proves control of a phone number, not permission to use that number for marketing, research, or a caregiver account. The authentication and consent decisions need different evidence and different expiry rules.
Why a phone code cannot stand in for consent
The simple implementation stores the code hash, verifies six digits, then sets consent = true. It is attractive because it is short. It is also ambiguous: which category was granted, which policy version was shown, and what happens after a patient withdraws one purpose but keeps account access?
For an abuse-resistant design, create a challenge with a narrow purpose (login) and a separate consent record with a purpose such as care-plan-sharing or product-updates. Rate-limit code requests and guesses per phone, device, and network, and return the same outward response for an unknown account as for a known one. OWASP's Authentication Cheat Sheet is a useful baseline for those controls, but it does not define your consent taxonomy.
The distinction matters operationally. A bot can automate SMS delivery without ever obtaining a valid grant. A legitimate patient can revoke analytics consent while remaining signed in. Your authorization layer should handle both cases without asking the user to solve a new puzzle. I've found that writing these as separate test fixtures also makes review faster: a reviewer can inspect the login assertion without mentally unpacking a privacy decision hidden inside it.
Keep them separate.
What should categories, current state, grants, and revocation mean?
I model each category as a policy-controlled purpose, not as a screen checkbox. A record has a stable category key, the policy version and locale presented, the actor and subject, the collection method, and timestamps. “Current” is computed from events and policy, rather than copied into a mutable boolean.
| Concept | Practical meaning | Evidence to retain |
|---|---|---|
| Category | A purpose and data scope, such as care coordination or optional research | key, description, policy version |
| Grant | An affirmative action after the notice was displayed | actor, subject, time, channel, locale |
| Current state | The effective result at a requested time | event sequence, expiry, jurisdiction |
| Revocation | A later action that disables a category without rewriting history | actor, time, reason, propagation status |
Here is a compact Python shape for the event reducer. It is deliberately boring: deterministic code is easier to evaluate than a clever callback chain, and it keeps the notebook-to-prod path clear.
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class ConsentEvent:
category: str
action: str # grant or revoke
at: datetime
policy_version: str
actor_id: str
def current_consent(events: list[ConsentEvent], category: str, now: datetime) -> bool:
relevant = [event for event in events if event.category == category and event.at <= now]
relevant.sort(key=lambda event: event.at)
state = False
for event in relevant:
if event.action == "grant":
state = True
elif event.action == "revoke":
state = False
return state
The reducer is not the audit log. Append the original events to tamper-evident storage, then derive a read model for fast checks. Preserve ordering with a server timestamp and an event identifier; client clocks are hints, not authority. I am not sure every jurisdiction will accept the same evidence fields, so the legal team should map this schema to the applicable retention and notice rules before launch.
How do grants and revocation interact with OTP abuse controls?
A grant endpoint should require an authenticated session created for the login challenge's subject. It should reject a mismatched phone or a replayed challenge, and it should record the consent event only after the policy document hash has been stored. Revocation should be idempotent: repeating it produces no new permission, while still allowing an audit event that explains who requested the action.
The abuse boundary is separate. Set quotas before sending an SMS, add a cooldown after repeated failures, and queue delivery so a burst cannot consume the entire provider budget. Count successful and failed attempts independently. A “consent granted” response must never reveal whether a phone number is registered; that response is an enumeration oracle when bots can query it at scale.
My first test used one phone and one category. It passed. The second used 10,000 synthetic numbers, a grant followed by an immediate revoke, and two concurrent retries; the race left the cache saying granted for 30 seconds. I traced the timeline instead of adding another retry: request A appended the grant, request B appended the revoke, and the asynchronous cache worker processed A after B. The API therefore returned the right event history while a downstream export still saw the old projection. The fix was to invalidate the category read model from the same transaction that appends the revoke event, then add a property test that any state observed after the event timestamp is false. That test now runs with delayed workers, duplicate deliveries, and a policy-version change in the middle of the stream, because those are the cases that make a tidy demo misleading.
The cache is a projection, not the source of truth.
Measuring the decision before shipping
An eval harness should replay realistic event streams, not just unit-test the happy path. Include delayed SMS, duplicate callbacks, policy-version changes, timezone boundaries, and a caregiver acting on behalf of a patient. Track false grants, stale-state duration, SMS requests per successful login, challenge guess rate, and the percentage of revocations reflected in downstream systems within the stated service objective.
Instrument category checks with a correlation ID that is safe to expose to support staff but contains no phone number. Alert on spikes in requests per IP and on high revoke-to-grant ratios; neither signal alone proves abuse. Sample the full event trail for investigation while keeping application logs free of codes and raw phone numbers.
The catch is that this design costs more storage and coordination than a boolean, and it is not suitable when you only need a throwaway demo with no regulated data. For a production healthtech app, stick with an explicit event model; if your primary requirement is cross-organization consent exchange, choose an implementation aligned with your jurisdiction's interoperability profile and have it reviewed by privacy counsel.
Top comments (0)