Opted-out numbers never contacted even within the same processing batch
Why it matters
A batch reminder job that fetches the recipient list at the top of the function and then loops through sends creates a TCPA race window: a customer can send STOP between the initial query and their position in the loop. CWE-362 (race condition) is the exact failure mode — the opt-out write and the in-flight batch read operate on shared state without synchronization. Under TCPA (47 U.S.C. § 227), intent to comply is not a defense; the message that arrives after a STOP still carries the per-message penalty. A pre-cached Set of opted-out numbers becomes stale the moment the STOP is processed, which on a high-volume system can be milliseconds into the batch run.
Severity rationale
Critical because the race window between recipient list fetch and per-recipient send can result in TCPA violations even when an opt-out record exists in the database.
Remediation
Replace any cached opt-out list with a fresh database query per recipient inside the send loop. The extra round-trip per booking is negligible compared to the per-message TCPA exposure:
// src/jobs/reminders.ts
async function sendReminders() {
const dueBookings = await db.bookings.findMany({
where: { reminder_due_at: { lte: new Date() }, reminder_sent: false },
})
for (const booking of dueBookings) {
// Fresh DB query per recipient — never a cached list
const optOut = await db.smsOptOut.findUnique({
where: { phone_number: booking.customer_phone },
})
if (optOut?.opted_out_at && !optOut.opted_in_at) {
await db.bookings.update({ where: { id: booking.id }, data: { reminder_sent: true } })
continue
}
await smsQueue.add('send-reminder', { bookingId: booking.id })
}
}
Detection
-
ID:
optout-atomic -
Severity:
critical -
What to look for: Find any batch SMS processing code — reminder jobs, bulk notification jobs, or loops that process multiple bookings. In each batch processor, verify the opt-out check happens BEFORE the SMS is queued or sent, not after. Look for race conditions: (1) Does the batch job pre-fetch a list of recipients then loop through sending? If so, an opt-out received between fetch and send would be missed. (2) Is the opt-out check inside the loop (good) or only at the batch level (bad)? (3) Is there a database-level constraint or atomic query that prevents sending to opted-out numbers?
-
Pass criteria: Enumerate all batch SMS processors. ALL of the following: (1) 100% of batch processors check opt-out status per-recipient INSIDE the loop, immediately before queuing the SMS — quote the batch function name and the location of the opt-out check relative to the send call. A pre-cached opt-out list does NOT count as pass if it could become stale during batch execution. (2) No window exists where an opt-out could be processed after the recipient list is fetched but before their SMS is sent, OR the opt-out check uses a fresh database query per recipient (not a cached list).
-
Fail criteria: Batch job pre-fetches recipients and does not re-check opt-out before each send. Opt-out check happens outside the per-recipient loop. Race condition exists where a newly opted-out number could still receive an SMS within the same batch execution. No batch processing code exists but the system sends SMS in loops without per-iteration opt-out checks.
-
Skip (N/A) when: SMS is not offered (same criteria as sms-optout-persisted skip).
-
Detail on fail: Quote the batch function and describe the race condition. Example:
"sendDueReminders() at src/jobs/reminders.ts:15 queries all bookings due for reminders, then loops through each. The opt-out check is inside the loop (line 28), BUT it reads from a pre-fetched Set<string> populated at line 17 — a STOP received after line 17 but before line 28 would not be reflected.". -
Remediation: Check opt-out with a fresh database query per recipient inside the send loop:
// src/jobs/reminders.ts async function sendReminders() { const dueBookings = await db.bookings.find({ where: { reminder_due_at: { lte: new Date() }, reminder_sent: false } }) for (const booking of dueBookings) { // Fresh DB query per recipient — not a cached list const isOptedOut = await db.smsOptOut.findUnique({ where: { phone_number: booking.customer_phone } }) if (isOptedOut?.opted_out_at && !isOptedOut?.opted_in_at) { await db.bookings.update({ where: { id: booking.id }, data: { reminder_sent: true } }) continue } await smsQueue.add('send-reminder', { bookingId: booking.id }) } } -
Cross-reference: Email/SMS Compliance audit checks TCPA batch compliance. Database Audit checks for stale-read vulnerabilities. Booking Flow & Lifecycle audit checks booking query patterns for similar race conditions.
External references
- external · TCPA-47-USC-227 — Telephone Consumer Protection Act — 47 U.S.C. § 227 (batch opt-out compliance)
- cwe · CWE-362 — Race Condition (concurrent opt-out check and send)
- gdpr · Art. 21 — Right to object — must be honoured without delay
Taxons
History
- 2026-04-18·v1.0.0·Initial import from booking-notifications-reminders·automated