GDPR Article 7(3) and CCPA §1798.120 require opt-outs to be honored — a race condition between async opt-out processing and a campaign send batch is not an acceptable excuse for sending to a contact who withdrew consent. CWE-284 (Improper Access Control) captures the broader class: the system fails to enforce the access control decision (do not send) during the window between request and processing. A contact who unsubscribes at 11:58 PM and a campaign batch that starts at midnight will be sent a campaign if the only guard is the processed consent record, which the async worker has not yet written.
High because the race window between an opt-out request and async processing completion is predictable and exploitable by campaign send timing, creating measurable violations of GDPR Art. 7(3) and CAN-SPAM §7704(a)(4).
Write the optOutStatus: 'pending' flag synchronously before enqueuing the job, so the pre-send guard can catch pending opt-outs even before the worker runs:
// src/app/api/unsubscribe/route.ts
export async function POST(req: Request) {
const { contactId, scope } = await req.json()
// Synchronous — takes effect immediately on all subsequent pre-send checks
await db.contact.update({
where: { id: contactId },
data: { optOutStatus: 'pending', optOutRequestedAt: new Date() }
})
await queue.add('process-optout', { contactId, scope })
return new Response(null, { status: 202 })
}
// Pre-send guard in src/lib/campaigns/can-send.ts
async function canSendCampaign(contactId: string, scope: string): Promise<boolean> {
const contact = await db.contact.findUnique({ where: { id: contactId } })
if (contact?.optOutStatus === 'pending' || contact?.optOutStatus === 'completed') return false
return hasConsent(contactId, scope)
}
The synchronous status write costs one extra database round-trip but eliminates the race window entirely.
ID: compliance-consent-engine.opt-out-processing.fail-safe-block
Severity: high
What to look for: Between when a contact requests an unsubscribe and when the async worker finishes processing it, there is a window where the contact could be picked up by a campaign send. Look for a pending_unsubscribe flag, an opt_out_status column, or a suppression check in the pre-send path that catches contacts whose opt-out job is queued but not yet processed. Without this, a contact who clicks unsubscribe at 11:58pm and a campaign batch that starts at midnight will send to that contact before the worker runs.
Pass criteria: The opt-out request immediately marks the contact as pending_unsubscribe (or equivalent) in a synchronous write before enqueuing the job. The pre-send consent check blocks contacts in pending_unsubscribe state. Quote the actual status field name and the values used for the pending state. Count all pre-send guard checks in the send path — at least 1 must check the pending-unsubscribe state.
Fail criteria: The unsubscribe endpoint only enqueues the job with no synchronous status update. The pre-send check only queries consent records — a contact with a queued-but-not-processed opt-out job is indistinguishable from a subscribed contact.
Skip (N/A) when: Opt-out processing is fully synchronous (though this would fail the async check above) or there is no overlap between opt-out processing and campaign sends.
Detail on fail: "Unsubscribe only enqueues job, no immediate status update — contacts remain sendable until worker completes (average 45min delay)" or "Pre-send check queries consent_records only, not opt_out_requests table — pending opt-outs not caught"
Remediation: Add a synchronous write before enqueuing:
export async function POST(req: Request) {
const { contactId, scope } = await req.json()
// Synchronous write — immediate effect on pre-send checks
await db.contact.update({
where: { id: contactId },
data: { optOutStatus: 'pending', optOutRequestedAt: new Date() }
})
// Async job handles cascade, ESP sync, audit logging
await queue.add('process-optout', { contactId, scope })
return new Response(null, { status: 202 })
}
// Pre-send check includes status check
async function canSendCampaign(contactId: string, scope: string): Promise<boolean> {
const contact = await db.contact.findUnique({ where: { id: contactId } })
if (contact?.optOutStatus === 'pending' || contact?.optOutStatus === 'completed') return false
// ... rest of consent checks
}