Short answer: keep login, consent grants, and data access as three separate decisions; evaluate a category-scoped grant on every protected read, and make revocation invalidate future reads without waiting for an identity-provider migration to finish.
That constraint changes the build. A managed authentication provider can prove who holds a session, but the session must not silently become permission to read every health category. For a small team shipping weekly, the highest-leverage move is a narrow authorization boundary that survives provider replacement. Outsource login if it saves time. Keep the consent record and access rule portable.
One warning up front: this is an engineering design, not a claim about which consent language or retention rule applies in a particular jurisdiction. I'm not sure any generic article can settle that; product counsel and the applicable policy have to define the categories, purposes, and required evidence.
Authentication is not consent.
How should health data consent category checks govern grants and revocation?
Use three rules. First, authentication establishes a subject, not consent. Second, a grant names the subject, health data category, purpose, status, and policy version; it isn't a global boolean. Third, revocation changes the authorization result for later reads and starts a separate cleanup workflow for derived or copied data.
Those rules are intentionally boring. That's good. They let an application migrate Google or GitHub sign-in away from a managed provider without rewriting the meaning of consent. The identity adapter may change how an external account maps to an internal subject ID, while the policy function still consumes the same subject ID and grant data.
A category check belongs near the data boundary. If a request asks for lab_results, a grant for profile must not pass merely because both records belong to the same person. Purpose matters too: a grant for direct care should not automatically authorize an unrelated analytics read. The category and purpose strings need a controlled vocabulary owned by the application, otherwise spelling drift turns policy into guesswork.
Don't put the whole rule in a session token. A long-lived token captures yesterday's grant state, so a revocation may be invisible until token expiry. A token can carry a stable subject ID and authentication context; the current grant decision should come from an authoritative store or a cache with an explicit invalidation path. This adds a lookup. The trade is deliberate — revenue per hour looks terrible when a fast feature creates a consent incident that must be reconstructed by hand.
The migration boundary that changed the choice
The smallest useful architecture has four pieces: an identity adapter, a consent ledger, one policy function, and an audit sink. The adapter converts each accepted login into an internal subject ID. The ledger stores grant events without treating an edited row as the whole history. The policy function returns a small decision object. The audit sink records the inputs and outcome needed to explain a protected read.
Authentication controls still matter. OWASP recommends generic authentication error responses so account existence isn't exposed through different messages or response behavior, and it recommends reauthentication after risk events. Those controls protect login. They don't replace consent checks, which answer a different question after identity has been established. This separation also prevents migration order from dictating policy order. Dual-running old and new login adapters can be temporary. Two consent authorities cannot be casually dual-run because conflicting grant state makes the access decision ambiguous. Choose one authoritative consent ledger before moving traffic, then make both identity paths resolve to the same internal subject IDs. The catch is identity linking. An email address alone is a weak join key because addresses and upstream account details can change. The migration needs an explicit, reviewed mapping from each accepted external identity to one internal subject. No automatic merge should expand access. When a mapping is uncertain, stop the link and ask for stronger verification rather than guessing.
The smallest working policy core
Here is the core I would ship before touching provider-specific migration code. It uses no framework and makes denial reasons explicit enough for tests and audit records.
type DataCategory = "profile" | "lab_results" | "medications";
type Purpose = "direct_care" | "patient_export";
type GrantStatus = "active" | "revoked";
type Grant = {
grantId: string;
subjectId: string;
category: DataCategory;
purpose: Purpose;
policyVersion: string;
status: GrantStatus;
};
type AccessRequest = {
subjectId: string;
category: DataCategory;
purpose: Purpose;
};
type Decision =
| { allowed: true; grantId: string; policyVersion: string }
| { allowed: false; reason: "no_matching_grant" };
function decideAccess(request: AccessRequest, grants: readonly Grant[]): Decision {
const grant = grants.find(
(candidate) =>
candidate.subjectId === request.subjectId &&
candidate.category === request.category &&
candidate.purpose === request.purpose &&
candidate.status === "active",
);
return grant
? { allowed: true, grantId: grant.grantId, policyVersion: grant.policyVersion }
: { allowed: false, reason: "no_matching_grant" };
}
It is small on purpose. There is no fallback from a narrow category to a broad one, no implicit purpose, and no isConsented shortcut. The caller authenticates the request, resolves the internal subject, loads relevant grants, runs this pure function, and records the decision. A denial remains a denial even if the user has a valid social-login session.
The first tests should be asymmetric: an exact active grant allows; the wrong subject denies; the wrong category denies; the wrong purpose denies; a revoked exact grant denies; and an empty set denies. Then add a migration test proving that two identity adapters resolving to the same subject produce the same consent decision. Six plain cases catch more risk here than a clever abstraction.
Keep the user-facing denial generic enough not to reveal protected categories. Put the detailed reason in restricted operational records, with a request correlation ID rather than raw health data. Logs are copies too. If full records leak into them, revocation and deletion workflows become harder because the data has spread into a system designed for retention and search.
Revocation is a state change plus a delivery problem
Revocation must win.
Revocation should append an event or otherwise preserve who changed what and under which policy version. The online authorization path must observe the revoked state before another protected read succeeds. Cached decisions therefore need short, defined lifetimes or targeted invalidation keyed by subject and grant. Hope is not an invalidation strategy.
There is also work beyond the read path. Queued exports, background jobs, analytics inputs, and downstream copies may already exist. A revocation event should fan out to the systems named in the application's data map, and each consumer should report completion or a policy-approved exception. This is where a one-person SaaS can overbuild quickly, so start with an explicit inventory and idempotent consumers rather than a grand event platform. The critical property is traceability: an operator can tell which consumers received the event, which completed, and which require review.
Race conditions deserve a written rule. Suppose a read begins while revocation commits. The system must define the ordering boundary it can enforce — for example, the authorization check observes the authoritative grant version before data is returned — and test that boundary. Your mileage may vary with storage guarantees, so document the actual consistency model instead of promising instant propagation everywhere.
Revocation also must not unlink the person's login by accident. They may still need to sign in to view settings, submit another grant, or export permitted data. Identity lifecycle, session lifecycle, and consent lifecycle can interact, but collapsing them into one disabled flag destroys useful distinctions and makes incident review much harder.
What I would change at scale, and when this design is wrong
At higher request volume, I would pre-index active grants by subject, category, and purpose; add monotonic grant versions for cache invalidation; and exercise revocation propagation in deployment tests. Metrics should count decisions and propagation lag without category names or health payloads in high-cardinality labels. Alerts should focus on invariant violations, such as a consumer acknowledging an older version after a newer revocation.
I would also separate policy rollout from application deployment. A policy version stored on each grant makes historical decisions explainable, but changing the vocabulary still needs a migration plan. Run old and new evaluators against non-sensitive fixtures, compare outcomes, then switch the authoritative evaluator through a controlled release. Ship weekly, but don't improvise policy semantics on Friday afternoon.
This design is not suitable when consent requires negotiation across many institutions, complex delegated authority, or jurisdiction-specific enforcement that the team cannot encode and audit itself. In that case, use a dedicated policy service or a reviewed standards-based integration, and keep the application as a policy enforcement point rather than inventing the authority model. A database lookup plus a TypeScript function is also the wrong ceiling for very high throughput with strict cross-region revocation deadlines; the same decision contract can stay, but storage, invalidation, and evidence collection need dedicated engineering.
For the original migration decision, the rule is simple: change identity providers only after external identities map deterministically to stable internal subjects and both login paths hit one consent authority. That preserves weekly shipping while keeping the risky, differentiated part — the meaning and evidence of health data consent — under explicit application control.
Top comments (0)