Consent records fail audits for boring reasons: a grant has no category, a revocation cannot be replayed, or the “current” value was reconstructed from a mutable profile row. A support team migrating off a managed identity provider should treat consent as an append-only decision log, then derive current state from that log.
Short answer: model categories explicitly, record every grant and revocation with purpose and evidence, and make the password-reset service consult the derived state before it sends anything beyond the reset message itself.
This is a build log for a customer-support forgot-password flow. The job is not to invent a consent standard. It is to make the boundary auditable while the authentication provider changes underneath it.
How should categories, current state, grants, and revocation shape the flow?
Start with a small vocabulary. A category is a purpose boundary, not a checkbox label. For this flow I use account_security for reset messages, support_contact for a human agent contacting a customer, and product_updates for optional mail. Security processing may have a different legal basis from marketing, but the system still needs an explicit record of what the person selected and when.
The current state is a projection. The source is an event with an immutable subject, category, action, timestamp, policy version, and evidence reference. A grant adds an active decision. A revocation adds a later inactive decision; it does not edit the old grant. That distinction matters when an auditor asks what the system knew on a particular date. It also means a replay from an empty database should produce the same answer as the live projection, which is a useful property during a provider migration because you can compare two systems without trusting either system's cached flag.
Keep the vocabulary small.
Here is the smallest TypeScript shape I would put behind the API. It is deliberately plain. No SDK, no configuration maze.
type ConsentCategory =
| "account_security"
| "support_contact"
| "product_updates";
type ConsentAction = "grant" | "revoke";
type ConsentEvent = {
id: string;
subjectId: string;
category: ConsentCategory;
action: ConsentAction;
occurredAt: string;
policyVersion: string;
evidence: {
channel: "web" | "agent" | "import";
noticeHash: string;
actorId?: string;
};
};
function currentConsent(events: ConsentEvent[]) {
const latest = new Map<ConsentCategory, ConsentEvent>();
for (const event of events) {
const previous = latest.get(event.category);
if (!previous || event.occurredAt > previous.occurredAt) {
latest.set(event.category, event);
}
}
return Object.fromEntries(
[...latest].map(([category, event]) => [category, event.action === "grant"]),
) as Record<ConsentCategory, boolean>;
}
The comparison needs a server-issued timestamp or a monotonic sequence in production. A client clock is evidence about the client, not an ordering guarantee. I also keep the original notice hash. If the wording changes, the record points to the exact notice version that was shown.
Build the reset boundary before moving providers
The reset endpoint should have one narrow responsibility: prove that the requester controls the account recovery channel, create a short-lived token, and send the security message. It should not silently use a broad “email allowed” flag that mixes account security with product mail.
The migration-friendly interface is an internal contract. The identity provider can change; the consent decision does not.
type ResetRequest = { email: string; requestId: string };
type ConsentReader = {
isActive(subjectId: string, category: ConsentCategory): Promise<boolean>;
};
type ResetSender = {
send(input: { email: string; token: string; requestId: string }): Promise<void>;
};
export async function forgotPassword(
input: ResetRequest,
deps: { users: Map<string, string>; consent: ConsentReader; mail: ResetSender },
) {
const subjectId = [...deps.users.entries()].find(([, email]) => email === input.email)?.[0];
// Do not reveal whether the address exists.
if (!subjectId) return { accepted: true };
const allowed = await deps.consent.isActive(subjectId, "account_security");
if (allowed) {
const token = crypto.randomUUID();
await deps.mail.send({ email: input.email, token, requestId: input.requestId });
}
return { accepted: true };
}
The response is intentionally the same for an unknown address and an inactive category. That avoids turning the endpoint into an account enumeration oracle. Rate limits, token expiry, single-use storage, and generic error handling still belong here; consent does not replace authentication controls. OWASP's Authentication Cheat Sheet is a useful baseline for those controls.
The security boundary stays boring.
One migration trap is importing only the latest profile flag. That loses revocations and makes an old decision impossible to explain. Import events with a stable subject mapping, preserve the original occurrence time, and mark the import channel in evidence. Run a reconciliation that compares the derived state in both systems before switching reads.
What does an auditable grant or revocation actually contain?
An auditor needs more than true or false. Store the subject, category, action, time, policy or notice version, collection channel, and an evidence pointer. Add the actor for agent-assisted changes. Keep the event immutable and restrict who can append one; operators should not be able to rewrite history through an admin form. If the support queue is busy and an agent records a change two hours after a call, the event should still carry the actual interaction time alongside the append time, with the difference visible to a reviewer rather than silently overwritten. That tiny distinction prevents a late ticket update from masquerading as the customer's original choice.
The event itself is not proof that a person understood a notice. It is a durable link to the notice and the interaction that produced the decision. For web collection, retain a notice hash and a reference to the rendered version. For support agents, retain the authenticated agent identity and the ticket reference, with access controls around that ticket.
Revocation should take effect for future optional processing without deleting the historical grant. A read model can answer “active now”; a time-travel query can answer “active on 2026-02-14.” Your retention policy may require deletion of personal data, so separate the subject key from event metadata and document the deletion behavior.
I am not sure one retention period fits every jurisdiction. The privacy officer and the applicable records rule should resolve that, not an engineer guessing in a migration script.
Test the failure modes, not just the happy path
I test the decision log with table-driven cases because the dangerous bugs are combinations: grant, revoke, grant again; two categories with different purposes; and events arriving out of order. A projection test should be deterministic for a fixed event set.
const cases: Array<[string, ConsentEvent[], boolean]> = [
["a grant is active", [grant("account_security", "2026-01-01")], true],
[
"a later revocation wins",
[grant("account_security", "2026-01-01"), revoke("account_security", "2026-01-02")],
false,
],
[
"marketing does not gate security mail",
[grant("account_security", "2026-01-01"), revoke("product_updates", "2026-01-02")],
true,
],
];
for (const [name, events, expected] of cases) {
const actual = currentConsent(events).account_security === true;
if (actual !== expected) throw new Error(`${name}: projection mismatch`);
}
In a real test file, grant and revoke would construct complete events, including policy versions. I keep the helpers out of the article so the important assertion stays visible.
Then exercise operational cases: duplicate event delivery, a missing evidence pointer, a provider timeout, and a replayed reset request. The reset endpoint should remain idempotent from the caller's perspective and should never log reset tokens or full email addresses. Metrics should distinguish accepted requests, messages sent, and suppressed optional processing; otherwise a dashboard can claim success while revocations are being ignored.
The migration trade-offs I would keep on the ticket
An append-only log costs storage and query work. A single mutable row is cheaper to read, but it cannot explain history without a second audit system. I would pay the event cost for regulated support workflows and keep a compact current-state projection for normal requests.
This design is not suitable when the team cannot operate durable event storage or protect evidence references. In that case, stick with a managed consent component until those controls exist; moving providers first just moves the audit gap. It is also a poor fit for a tiny internal tool with no personal data and no retention obligation.
At scale, I would add an outbox so the consent event and the projection update have a clear delivery contract, plus a nightly reconciliation against the reset-message ledger. I would not add a second policy language until a real rule requires it. More configuration means more states to test.
The decision rule is simple: if a support agent can answer who granted what, under which notice, and when it was revoked, the boundary is doing its job. If the answer depends on a mutable flag or a vendor console screenshot, the migration is not ready.
References
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- RFC 9449, OAuth 2.0 Demonstrating Proof of Possession: https://www.rfc-editor.org/rfc/rfc9449
- MDN Web Crypto API: https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API
Further reading
- NIST Digital Identity Guidelines: https://pages.nist.gov/800-63-3/
Top comments (0)