When a developer-tools user withdraws consent, the dangerous assumption is that deleting a row or changing a settings screen ends access immediately. It doesn't. The enforcement decision has to run on every request that can read or mutate personal data, and it has to survive retries, cached credentials, and hostile traffic.
Short answer: model withdrawal as an auditable state transition, then make each runtime authorization check read the current consent state before it touches data. For an account deletion flow, revoke first, deny protected work immediately, and make the deletion worker idempotent.
The incident lesson: revocation is a gate, not a notification
I have been paged for missed jobs and duplicate deliveries. The same operational shape appears in consent work: a queue consumer can receive the deletion command twice, while an API request is already in flight. A UI-only flag handles neither case. In a runbook review, I trace this as a timeline: request A reads active at 10:00:00, the revoke transaction commits at 10:00:01, and request A reaches its write at 10:00:02. If the write path trusts the first read, the account has been processed after withdrawal even though every individual component reported success. The invariant is simpler: no new data operation may begin after the effective withdrawal timestamp, and repeated commands must produce the same final state.
Denied.
That means the revoke endpoint records who withdrew, which category changed, when it changed, and the request correlation ID. The product flow must respect that result instead of merely showing “deleted.” A later request reads the state and makes a fresh decision. Existing access tokens are not proof that consent still exists.
There is a trade-off. A strongly consistent check on every protected operation costs latency and database capacity; a long-lived cache lowers both but creates a window in which a withdrawn user can still be served. For personal data, I would spend the read. Your mileage may vary if the data is already public and non-personal, but document that exception as a policy boundary.
How should consent revocation shape runtime access decisions?
Treat authorization as a small state machine, not a boolean sprinkled through handlers. A useful record has user_id, category, status, effective_at, version, and an audit event ID. The write path uses a conditional update so two revoke requests cannot move the state backward. The read path evaluates the current record and the operation's data category together.
The two facts-backed actions are explicit:
-
POST /v1/auth/consent/revoke/{user_id}records the withdrawal for a user. -
GET /v1/auth/consent/check/{user_id}/{category}returns the current decision for a category before processing data.
Here is the decision boundary in Go. It is intentionally boring; boring code is easier to page on.
package consent
import (
"context"
"errors"
"time"
)
var ErrConsentWithdrawn = errors.New("consent withdrawn")
type Decision struct {
UserID string
Category string
Allowed bool
Version int64
Effective time.Time
}
type Store interface {
Check(ctx context.Context, userID, category string) (Decision, error)
}
func Authorize(ctx context.Context, store Store, userID, category string) error {
decision, err := store.Check(ctx, userID, category)
if err != nil {
return err
}
if !decision.Allowed {
return ErrConsentWithdrawn
}
return nil
}
The handler should call Authorize before loading an export, starting an AI job, or enqueueing a data-bearing task. On denial, return a stable application-level response and emit an audit event without logging tokens or payloads. A queue worker repeats the check immediately before execution; checking only at enqueue time leaves a race.
Making deletion and revocation idempotent under abuse
Bot resistance starts with controlling state transitions, not with a CAPTCHA bolted onto the final button. Require an authenticated session, reauthentication for a high-impact action, and a per-user rate limit. OWASP recommends treating authentication failures and sensitive flows as observable security events; the same discipline applies to repeated withdrawal attempts.
Use an idempotency key scoped to the user and operation. Store its result with a short retention period, bind it to the authenticated principal, and reject a key replayed with a different request body. A revoked state is monotonic: active -> withdrawn. A retry sees withdrawn and returns the same semantic result, while an audit record preserves both attempts.
The deletion worker should be safe to run twice. Delete or anonymize one bounded resource class per transaction, record a completion marker, and publish the next step only after the marker commits. If a worker crashes after the database commit but before acknowledgment, redelivery is expected—not an incident. The marker makes it harmless.
Do not let a background job infer consent from a stale message. Pass the user and category identifiers, then perform the current check in the worker's transaction. This also gives operations a useful metric: denied-after-enqueue events reveal how long work sits in the queue and where revocation races are concentrated.
Log state transitions, not secrets. At minimum, capture a pseudonymous user key, category, decision, consent version, actor type, correlation ID, and latency. Keep the audit stream append-only and restrict who can read it; GDPR deletion does not mean erasing the evidence that a withdrawal was honored. Alert on invariant violations: a protected operation accepted with a consent version older than the withdrawal version, a worker that repeatedly retries a withdrawn task, or a sudden spike in revoke attempts from one network identity. These are useful abuse signals even when every individual request returns a valid response.
Test the race you are worried about. Start a data request, withdraw consent, then release the request and verify that the commit path rechecks the state. Run the same test with two revoke requests and with a duplicated queue message. I once assumed a single database check covered this; the second check at the commit boundary was what closed the gap.
Where this design is not suitable
The catch is operational cost. Per-operation reads and transactional markers are a poor fit for bulk analytics that never expose identifiable data, and they can be excessive for a short-lived internal prototype. In those cases, keep a documented data classification and choose an aggregate control with a measured freshness bound.
Stick with a simpler session-only logout when the requirement is merely ending one device session. Choose the consent state machine when withdrawal must affect categories of personal data, asynchronous work, and every active session. The decision should follow the harm of serving data after withdrawal, not the convenience of the first API you can call.
Top comments (0)