Treat a privacy preference center as an authenticated command log, not a set of mutable checkboxes. The deciding constraint in an e-commerce signup flow is abuse resistance: a captcha may stop many automated registrations, but it does not authorize a later request to list, grant, or revoke consent for a customer.
Short answer: expose one read operation and idempotent grant and revoke commands per category, bind every command to the authenticated account on the server, and retain enough event data to explain the current state without trusting identifiers supplied by the browser.
That split is small enough for a solo SaaS to ship this week. It also keeps the undifferentiated privacy plumbing away from checkout and catalog work, where revenue per engineering hour is usually clearer.
What should a privacy preference center list, grant, and revoke by category?
The center should list a fixed server-owned category catalog alongside the signed-in customer's effective choice for each optional category. A useful response contains the stable category key, display label, current state, and the time that state last changed. Keep labels editable without changing keys; an audit record tied only to translated copy becomes hard to interpret after a wording update.
Use four domain operations: list the effective preferences, grant one category, revoke one category, and record the resulting event. The fourth operation is internal, not another browser endpoint. This distinction matters because the current preference row is convenient for reads, while an append-only event answers a different question: how did this value get here?
Some categories should not be toggles. Authentication cookies, fraud controls, and the captcha used to gate signup can be necessary to operate or protect the service. Do not smuggle those controls into an optional marketing switch. The exact legal classification depends on the business and jurisdiction; I'm not sure a generic implementation can settle it, so product counsel and the documented data purpose must define which catalog entries are actually optional.
Keep it boring.
The constraint that changed the build
The tempting implementation is PATCH /preferences/:customerId with a JSON object copied from the form. That gives the browser two powers it should not have: choosing the subject and inventing category keys. A bot that gets through signup once can replay requests, swap customer identifiers, or send an obsolete category the UI no longer shows. Captcha verification reduces automated account creation; it does not replace session authentication, authorization, CSRF protection, or request validation on the preference endpoint.
The account comes from the validated session. The category comes from a server-side allowlist. The command carries an idempotency key so a retry after a lost response does not create ambiguous duplicate events. For browser sessions, use the normal CSRF defense for state-changing requests and set cookies with appropriate security attributes. OWASP's authentication guidance also recommends generic authentication error responses; that same restraint helps here because a preference endpoint should not reveal whether an arbitrary customer identifier exists.
I first sketched the handler around a customer ID in the route because it looked REST-shaped. The review question that killed it was only six words: who is allowed to choose that ID? Removing it made the contract shorter and closed the obvious cross-account path. That is the kind of simplification I will take every time.
The smallest TypeScript implementation
The public contract can stay generic. In this example, the session middleware has already authenticated the request and attached accountId; the handler never accepts that value from the body. A production adapter can put the current row and event insert in one database transaction.
type Category = "analytics" | "marketing" | "personalization";
type Choice = "granted" | "revoked";
type Preference = {
category: Category;
choice: Choice;
changedAt: string;
};
type ConsentCommand = {
category: Category;
idempotencyKey: string;
};
const categories: ReadonlyArray<{ key: Category; label: string }> = [
{ key: "analytics", label: "Analytics" },
{ key: "marketing", label: "Marketing" },
{ key: "personalization", label: "Personalization" },
];
interface PreferenceStore {
list(accountId: string): Promise<Preference[]>;
apply(input: {
accountId: string;
category: Category;
choice: Choice;
idempotencyKey: string;
changedAt: string;
}): Promise<Preference>;
}
function isCategory(value: string): value is Category {
return categories.some((category) => category.key === value);
}
async function changePreference(
accountId: string,
choice: Choice,
command: ConsentCommand,
store: PreferenceStore,
): Promise<Preference> {
if (!isCategory(command.category)) {
throw new Error("Invalid consent category");
}
if (command.idempotencyKey.length < 16) {
throw new Error("Invalid idempotency key");
}
return store.apply({
accountId,
category: command.category,
choice,
idempotencyKey: command.idempotencyKey,
changedAt: new Date().toISOString(),
});
}
Listing is a join between the catalog and stored choices, not a dump of rows. That lets a newly introduced optional category appear with a documented default even when an older account has no event for it. Granting and revoking both call the same store boundary with an explicit target state. Do not implement them as blind toggles: two identical retries must still mean "revoked," not revoke and then accidentally grant.
Return 400 for a malformed category or idempotency key, 401 when no valid session exists, and 403 when an authenticated principal lacks access to the center. A successful repeated command should return the same effective choice. Log the command outcome, category, account-scoped subject, timestamp, and correlation ID, but keep session tokens, captcha answers, and raw personal data out of logs.
The long paragraph is intentional because this is where implementations usually blur three separate guarantees. Authentication establishes the account, authorization permits that account to operate on its own preferences, and idempotency makes network retries safe. Captcha sits before or beside those controls as an abuse signal. If those concepts share one boolean such as verified, debugging becomes guesswork and a future checkout change can silently weaken the privacy path. Separate them in code, metrics, and tests even if one engineer owns all of it.
Testing the boundary before shipping
Test the commands as an attacker would, then test the ordinary retry path. A compact suite should cover an unauthenticated request, a stale CSRF token, an unknown category, a body containing an ignored accountId, two grants with the same idempotency key, a grant followed by a revoke, and a fresh catalog category with no stored event. Verify the effective list after every state change instead of asserting only the status code.
One failure deserves a dedicated test: create account A and account B, authenticate as A, then submit a valid command containing B's identifier in extra JSON. Read both preference lists afterward. A may change only according to the authenticated command, while B must remain untouched; if the API rejects unknown fields, assert that rejection before reading both lists. Repeat the request with the same idempotency key and confirm that the audit view still describes one effective transition. This sequence checks the subject binding, the stored result, and the retry contract instead of trusting a single status code.
Captcha is separate.
Ship metrics for command counts by category and outcome, authorization denials, validation failures, and idempotent replays. Alert on sharp changes in denial or replay rates rather than on the mere existence of revocations. People are allowed to change their minds.
What I would change at scale
At larger volume, I would move catalog publishing behind versioned configuration, partition the consent event log by account or time, and feed aggregate audit metrics asynchronously. I would not make the preference write depend on an analytics pipeline. The customer-facing command should commit the current choice and its audit event together; downstream reporting can catch up.
The catch is that an append-only ledger adds storage, retention work, and operational access controls. It is not suitable when the team cannot define who may read audit data or how long it should exist. For a tiny service with one optional category and no audit requirement, a versioned current-state row may be enough; stick with that simpler model until the need for historical proof is real. At the other extreme, organizations with multiple legal entities, regional policy engines, and many data processors should use a dedicated consent-management system rather than grow this TypeScript boundary into a home-built compliance platform.
My decision rule is plain: outsource policy orchestration when it becomes its own domain, but keep authentication and subject binding explicit at the application edge. Weekly shipping is only useful when a customer cannot edit somebody else's choices.
Top comments (0)