A race condition payout bug can make a tiny rewards launch unexpectedly expensive—and the failure mode is more common than many founders think. When an app converts user actions into withdrawable cash, crypto, gift cards, credits, or commissions, concurrency is not merely a backend concern: it is a financial-control problem.

A recent post in r/SaaS from the founder of Linesol, an anonymous mobile research app, is a useful case study. The app pays people to answer short product questions, with users able to withdraw via USDC on Solana or through a fiat option. Shortly after launch, the founder spotted a surprising cluster of payouts to the same recipient addresses. The apparent “free money” problem was not an account takeover at the payout provider. It was a race condition that allowed two devices logged into the same account to submit a survey at nearly the same time and receive duplicate rewards.

The founder shut down crypto payouts, investigated the pattern, fixed the concurrent reward-claim path, added rate limits, and resumed withdrawals. That was the right immediate response. But the more durable lesson is bigger: anonymous reward products need to treat every credit and payout as a ledger event protected by database constraints, atomic transactions, and recoverable payout workflows.

What happened in the Linesol payout exploit

According to the original r/SaaS post, Linesol began with a small pool of paid surveys. Users could earn a few cents for answering simple yes-or-no product research questions, then withdraw once they reached the relevant threshold. Within roughly two days, the founder saw multiple withdrawals that exceeded what normal participation should have produced, with repeated wallet addresses appearing across a burst of payouts.

That observation matters because the first visible indicator was not an error log. It was an economic anomaly: the same destinations receiving payments repeatedly, tied to clusters of similarly named accounts. The founder initially considered whether the payout system itself had been compromised. After reviewing application behavior, they found that multiple active sessions could submit the same rewarded survey concurrently before the account balance was properly protected.

In practical terms, the app had likely treated a reward flow as a sequence of separate steps:

  1. Check whether the user can complete a survey.
  2. Record the answer or completion.
  3. Add money to the balance.
  4. Let the balance become withdrawable.

When two requests execute in parallel, both can pass step one before either has completed steps two and three. If the system does not have a database-level rule saying “this account can receive this reward only once,” both requests can award money. That is a classic time-of-check to time-of-use problem: the state checked by request A is no longer reliable by the time request A writes its result.

The incident did not require sophisticated malware, leaked keys, or a blockchain exploit. It required ordinary app requests sent at the same time. OWASP specifically calls out race conditions in business workflows and recommends identifying critical sections, using database transactions or locks, and enforcing idempotency for external actions. (cheatsheetseries.owasp.org)

Why a race condition payout bug is worse than a duplicate form submission

A duplicate analytics event is annoying. A duplicate reward is a direct liability. The moment a balance can be redeemed for money or money-like value, the system is operating a miniature financial ledger—even if it is a bootstrapped consumer app paying out $0.05 at a time.

That changes the standard for correctness. A normal application workflow can often tolerate eventual consistency, retries, and duplicate background jobs if the UI is cleaned up later. A payout flow cannot safely assume it can reconcile after the fact, particularly once value has left the platform. Crypto withdrawals may be fast and difficult or impossible to reverse, while cash payout providers can introduce their own timing, webhook, and retry behavior.

There are actually two distinct duplicate-payment problems to prevent:

Duplicate earning

The same user action creates more than one internal credit. In the Linesol example, this was the core issue: one survey completion could be credited multiple times because concurrent sessions each believed they had won the right to earn it.

Duplicate disbursement

A single valid withdrawal is sent more than once to the external provider. This can happen when a worker sends a payout request, times out before receiving a response, and then retries without knowing whether the first request succeeded.

The first problem is solved primarily inside the application database. The second requires both an internal payout state machine and an idempotency strategy with the payment or crypto provider. Stripe’s API documentation, for example, describes idempotency keys as a way to retry requests without creating the operation twice; the principle applies broadly even when a platform uses another vendor. (docs.stripe.com)

Rate limits matter, but neither issue is fundamentally a rate-limit issue. A user making two legitimate requests milliseconds apart can still trigger a broken non-atomic flow. Limits reduce the volume and cost of abuse; financial invariants prevent invalid money creation in the first place.

The central principle: build a ledger before you build fraud rules

The most valuable comment on the original post was also the simplest: the balance update and withdrawal reservation should be one atomic transaction. Another commenter noted that a unique constraint on the combination of user and survey is what stops double submission, because app-level checks can lose the race.

That distinction is critical. Application code can ask, “Has this person already completed survey X?” But if two server processes ask that question simultaneously, both may get “no.” The database must be able to reject the second write regardless of the timing of application logic.

For a paid survey product, write down the invariants before choosing anti-fraud vendors or adding a CAPTCHA. Typical invariants include:

  • One eligible account can earn at most one reward for one assigned survey or task.
  • A reward must be tied to a specific completed action, offer, and reward amount.
  • Available balance can never fall below zero.
  • Reserved balance plus available balance must reconcile to ledger entries.
  • One approved payout intent can result in no more than one external transfer.
  • A payout provider callback cannot move a payout backward in its state machine.
  • Administrative adjustments require an auditable reason, actor, and immutable record.

These rules are product requirements, not implementation details. A risk model may decide whether a user is suspicious. It must never be the only thing preventing an impossible state such as two credits for one completed survey.

PostgreSQL’s documentation describes concurrency control as the system’s mechanism for maintaining data integrity while multiple sessions access data at once. It also warns that enforcing data-integrity rules at the application level under common isolation behavior can be difficult because the visible state may shift from statement to statement. (postgresql.org)

The database design that stops duplicate survey rewards

A robust implementation starts with a data model that represents facts rather than just mutable totals. The minimum useful set of records might include:

  • survey_assignments: which account was offered which task, under which campaign and reward terms.
  • survey_completions: the accepted completion record.
  • ledger_entries: immutable credits, debits, reversals, and adjustments.
  • account_balances: a fast, derived balance snapshot if needed for the product experience.
  • payout_intents: a user-requested withdrawal and its lifecycle.
  • payout_attempts: every attempt to call an external payment rail.
  • risk_events: nonfinancial signals used to review or limit accounts.

The exact table names do not matter. The separation of concerns does. A completion is evidence that work occurred. A ledger entry is evidence that value was granted. A payout intent is evidence that a user asked to withdraw. Combining all of those concepts into one editable users.balance field makes debugging, reconciliation, and incident recovery much harder.

Use a unique constraint for the earned action

If each user can complete a survey only once, enforce that rule where it cannot be bypassed:

ALTER TABLE survey_completions
ADD CONSTRAINT one_completion_per_account_survey
UNIQUE (account_id, survey_id);

In many systems, the safer key is more specific. If a user can legitimately receive multiple assignments for related tasks, use assignment_id instead. If the same survey can recur in a future campaign, include a campaign or offer-version identifier. The constraint should represent the actual business rule, not a convenient approximation.

A database uniqueness constraint turns a concurrency race into a deterministic result: one insert succeeds; the other receives a conflict. PostgreSQL documents constraints as rules that raise an error when a stored value would violate them. (postgresql.org)

Credit the ledger in the same transaction

The completion insert and the money credit must succeed or fail together. A simplified PostgreSQL-style pattern looks like this:

BEGIN;

INSERT INTO survey_completions (account_id, assignment_id, completed_at)
VALUES ($1, $2, now())
ON CONFLICT (account_id, assignment_id) DO NOTHING
RETURNING id;

-- If no row is returned, stop: this assignment was already completed.

INSERT INTO ledger_entries
  (account_id, entry_type, amount_cents, reference_type, reference_id)
VALUES
  ($1, 'survey_reward', 5, 'survey_completion', $completion_id);

UPDATE account_balances
SET available_cents = available_cents + 5,
    updated_at = now()
WHERE account_id = $1;

COMMIT;

In production, the application should explicitly check whether the completion insert returned a row and roll back if it did not. It should also use an integer representation of the smallest currency unit, never floating-point values, for money-like balances.

There are several valid concurrency strategies: a unique constraint plus transaction, a conditional update, row-level locking, or serializable isolation with retry handling. The right choice depends on the workload and schema. The non-negotiable outcome is that all concurrent paths preserve the same invariant. PostgreSQL provides row-level and explicit locking options for cases where application-controlled coordination is needed, while also noting that lock design must account for deadlocks. (postgresql.org)

Withdrawals need a reservation, not a casual balance check

A common payout bug looks like this:

if user.balance >= requested_amount:
    send_payout()
    user.balance -= requested_amount

That code can fail under concurrency in multiple ways. Two withdrawal requests may both see enough balance. A payout may be sent successfully but the process may crash before the balance is updated. Or the balance may be deducted first and the provider call may fail, leaving support to repair the account manually.

A safer approach is to reserve the funds before any external call. The withdrawal request becomes an internal state transition rather than an immediate payout command:

  1. Create a payout intent with a unique internal ID.
  2. In one database transaction, lock or conditionally update the account balance.
  3. Move the requested amount from available to reserved.
  4. Create an outbox job tied to the payout intent.
  5. Commit the transaction.
  6. Have a worker submit the payout using the payout-intent ID as its idempotency key or merchant reference.
  7. Mark the intent as paid only after a verified provider response or webhook.
  8. Release the reservation only if the payout is definitively failed or cancelled.

This gives the product a durable record of what it intended to pay before it contacts Coinbase, a bank payout service, or another provider. It also separates a temporary network error from a true payment failure.

An atomic conditional update is often a clean way to reserve balance:

UPDATE account_balances
SET available_cents = available_cents - $amount,
    reserved_cents = reserved_cents + $amount,
    updated_at = now()
WHERE account_id = $account_id
  AND available_cents >= $amount;

If the statement updates zero rows, the withdrawal should fail safely because the account no longer has sufficient available funds. The payout intent and outbox record should be written in the same transaction so a successful reservation cannot be lost before the worker sees it.

Why rate limiting helped—but could not fix the root cause

The Linesol founder added rate limits for account creation and withdrawals after the incident. That was sensible. A coordinated attacker often combines a logic flaw with cheap account creation, fast cash-out, and repeated retries. Limiting those paths slows extraction, reduces provider costs, and gives the team more time to detect patterns.

OWASP’s API Security Top 10 identifies unrestricted resource consumption as a common API risk. The category includes poorly limited operations that consume infrastructure or third-party resources on a per-request basis, which is especially relevant for payout calls, verification checks, SMS, and CAPTCHA challenges. (owasp.org)

But rate limiting cannot make an unsafe ledger safe. Consider these examples:

  • A limit of five requests per minute still permits two concurrent reward submissions.
  • An attacker can distribute requests across devices, IPs, sessions, or accounts.
  • A legitimate mobile app retry after poor connectivity may resemble abuse.
  • A strict withdrawal limit may block a fraudster eventually, but it does not repair money already duplicated inside the balance system.

Use rate limits as a containment control. Apply them to registration, survey submission, cash-out requests, payout-address changes, authentication recovery, and expensive risk checks. Use stricter limits at the earliest stages of a new account’s life, then relax them for accounts that have built a clean history.

Privacy-preserving fraud prevention for anonymous reward apps

The hard product question in the Linesol post is not whether to stop fraud. It is how to stop it without collecting emails, government IDs, face scans, or invasive tracking that would undermine the product’s privacy promise.

The answer is not “do nothing” and it is not “fingerprint everyone.” It is a layered system that minimizes personal data while raising the cost of coordinated abuse. Privacy and abuse resistance are competing constraints, but they are not opposites.

Start with progressive trust, not identity collection

A new account has no history. That does not mean it is fraudulent; it means the platform should expose less immediate financial risk. A privacy-respecting trust ladder can include:

  • A small maximum balance for a new account.
  • A waiting period before the first withdrawal.
  • Lower initial withdrawal caps and fewer daily cash-outs.
  • Manual or automated review for high-risk first payouts.
  • Gradual limit increases after diverse, valid activity over time.
  • Delayed release for rewards that are unusually easy to automate or duplicate.

A first-cash-out delay is particularly valuable because it changes the economics of fast account farming. It also creates time for duplicate completions, repeated payout destinations, impossible activity sequences, and abnormal answer patterns to surface.

Treat payout destinations as signals, not permanent guilt

The repeated wallet addresses in the Linesol case were an important signal. Clustering accounts by payout address can reveal a network attempting to concentrate rewards, especially if those accounts also share timing, device, behavioral, or survey-answer patterns.

Still, a shared address is not proof of fraud. Friends may share a wallet, a household may use the same off-ramp, or a user may change accounts after losing access. A good policy is to use destination reuse to trigger a risk score, review, or lower limits—not an automatic irreversible ban unless additional evidence supports it.

The same principle applies to IP addresses and device characteristics. Shared networks are normal at schools, workplaces, apartments, and mobile carriers. A fraud system that treats every shared attribute as a conviction will damage research-panel diversity and create biased samples.

Use CAPTCHA selectively

CAPTCHAs can reduce scripted account creation and high-volume submission attempts, but they add friction and can be difficult for some users. They are best applied adaptively: after anomalous retries, during rapid signup bursts, before a first withdrawal, or when a risk score crosses a threshold.

Do not put CAPTCHA in front of every ordinary survey response if the product depends on fast, low-friction participation. It will lower completion rates without addressing the database bug that made duplicate rewards possible.

Be cautious with device fingerprinting

Device fingerprinting may increase detection capability, but it creates privacy, consent, legal, and false-positive concerns. It can also be evaded by sophisticated fraud operators. If it is used at all, collect only the minimum stable signals needed for fraud defense, document the purpose clearly, set retention limits, and avoid using it as the sole basis for a punitive decision.

For an anonymous research platform, privacy-preserving alternatives can include coarse device integrity signals, short-lived abuse tokens, app-attestation services where appropriate, hashed network indicators with rotation policies, and server-side behavioral patterns. The goal is not to identify a real-world person. It is to assess whether a session behaves like a credible participant.

Protect research quality separately from payout integrity

A reward exploit is not only a finance problem. It can contaminate the product data that customers are paying to receive. If someone can create many accounts or replay the same survey, the “winning” product variation may reflect incentive gaming rather than consumer preference.

That means a good platform needs two related but separate systems:

  • Financial integrity: Did the platform pay only for eligible, unique, valid actions?
  • Research integrity: Is this response likely to represent independent, thoughtful feedback from the intended audience?

The financial system should be deterministic. A unique completion constraint either exists or it does not. Research-quality scoring can be probabilistic because honest people vary in response time, preferences, and device behavior.

Useful quality signals for simple product questions include implausibly fast completion across many prompts, perfect repetition, contradictory demographic answers, identical answer sequences across linked accounts, unusual concentration in one experiment cell, and abrupt changes after a cash-out threshold is reached. These should be evaluated at the cohort level as well as the individual level. A user may be unusual; fifty new users behaving identically at the same moment is more informative.

For product teams using the resulting research, the operational implication is important: do not treat every completed response as equal. Weight or exclude suspicious responses according to a documented methodology, preserve raw auditability, and disclose when a sample has been subject to quality filtering. Otherwise, anti-fraud measures may protect payout costs while quietly leaving biased research outputs intact.

Build an outbox and reconciliation process for external payouts

The app database cannot control what happens inside an external payout provider. Network calls can time out, webhooks can arrive out of order, and provider dashboards can disagree temporarily with a local worker queue. The answer is to make payout processing recoverable.

A transactional outbox pattern is useful here. Rather than calling the provider directly inside the web request, the application commits both the payout reservation and a message saying “process payout intent X.” A background worker reads that message and performs the external call. If it crashes, the message remains available for retry.

Every payout attempt should have:

  • An internal payout intent ID.
  • A unique provider idempotency key or reference derived from that ID.
  • A recorded request timestamp and sanitized request metadata.
  • The provider’s transfer identifier, if one exists.
  • Explicit states such as requested, reserved, submitted, pending, paid, failed, and cancelled.
  • A retry policy that never creates a fresh payout intent for the same user request.

The worker should not interpret a timeout as “payment failed.” It should move the attempt into an uncertain state, query the provider or wait for a signed webhook, and only retry with the same idempotency reference when the provider’s behavior supports it. This is precisely why idempotency is a payout design feature, not a minor API-header detail. (docs.stripe.com)

Daily reconciliation closes the loop. Compare internal ledger debits and paid payout intents with provider-side payout records. Alert on any mismatch, any payout without a matching reservation, any reservation that remains pending too long, and any destination receiving an unusual share of total withdrawals.

How to test for concurrency before users do

Most teams test whether a survey can be completed once. Fewer test whether it can be completed twice at exactly the same instant from two authenticated sessions. That gap is how a race condition payout bug reaches production.

Add concurrency cases to the test suite for every value-moving workflow. These should include reward claims, coupon redemption, referral bonuses, inventory holds, free-trial upgrades, withdrawals, refunds, and affiliate commissions.

A practical pre-launch test plan includes:

  1. Send 20 to 100 parallel requests for the same reward-claim endpoint and assert that exactly one completion and one ledger credit exist.
  2. Repeat the test from separate sessions for the same account.
  3. Attempt two withdrawals against a balance that can fund only one.
  4. Simulate a provider timeout after the external system may have accepted a payout.
  5. Deliver duplicate and out-of-order webhook events.
  6. Kill a worker after funds are reserved but before it writes a final local status.
  7. Run the test against production-like database isolation and connection-pool settings.
  8. Verify that dashboards, user-facing balances, ledger totals, and provider reconciliation all agree afterward.

Property-based testing can be useful for the invariant itself: no matter the ordering, retries, failures, or concurrency level, a given assignment cannot produce more than one reward and an account cannot pay out more than its credited balance. This style of testing shifts the focus from expected screens to the rules the system must never violate.

The community feedback was right, with one important expansion

The r/SaaS discussion correctly separated the root cause from the amplifiers. The core repair was making the reward and balance workflow atomic. The secondary controls—account-creation limits, withdrawal limits, delayed first cash-out, and payout-address clustering—make coordinated abuse less profitable and easier to investigate.

The expansion is that “atomic balance update” should not mean only one carefully written endpoint. It should mean a system-wide money model. Credits, debits, reversals, referral bonuses, manual adjustments, and withdrawals all need immutable references, deduplication rules, authorization checks, and a reconciliation trail.

This is where early-stage founders can avoid a costly trap. It is tempting to patch the observed route: add a mutex around survey submission, block the suspicious wallets, or set a stricter cash-out limit. Those changes may stop the exact attacker. They do not prove that referral credits, daily streak rewards, bonus campaigns, payout retries, and admin tools obey the same financial invariants.

OWASP’s business-logic guidance frames the issue well: authorization is not just whether a user may access a feature, but whether the specific action is valid in its current business context. A normal authenticated user can still exploit a workflow if the workflow permits an invalid state transition. (cheatsheetseries.owasp.org)

A practical rollout plan for founders running paid user actions

If your product currently pays users for surveys, gigs, referrals, creator tasks, moderation, affiliate activity, or AI data work, do not wait for a visible anomaly to audit the system. Start with the highest-value action and work outward.

First 48 hours

  • Pause or lower withdrawal limits if you suspect active exploitation.
  • Preserve logs, request IDs, payout records, and database snapshots before changing data.
  • Identify every affected completion, credit, account, destination, and payout.
  • Add a database-enforced uniqueness rule for the rewarded action.
  • Move the credit and balance change into one transaction.
  • Add alerts for repeated destinations, sudden payout volume, and negative or inconsistent balances.

Next two weeks

  • Introduce payout intents, reservations, a background worker, and provider idempotency references.
  • Build a reconciliation job and an internal review queue for uncertain payouts.
  • Add progressive withdrawal limits and a first-cash-out delay for new accounts.
  • Create risk policies that use multiple signals rather than blanket device or IP bans.
  • Run parallel-request, duplicate-webhook, and worker-crash tests in CI and staging.

Ongoing operations

  • Review fraud false positives alongside fraud losses.
  • Track whether anti-abuse rules distort demographic representation or response quality.
  • Reconcile every payout rail daily and investigate differences quickly.
  • Keep ledger records immutable; fix mistakes with compensating entries rather than silent edits.
  • Treat new reward features as security-sensitive launches, not merely growth experiments.

Conclusion: anonymity changes the controls, not the need for correctness

The Linesol incident is a useful reminder that the most dangerous early-stage bugs are sometimes ordinary concurrency mistakes attached to real money. A user does not need to defeat cryptography or compromise a provider account if the app itself will issue the same reward twice.

Anonymous participation can still be a legitimate product choice, especially for research products where privacy may improve candor and broaden access. But anonymity removes some familiar controls, so the product must compensate with stronger workflow design: database constraints, atomic ledgers, reservation-based withdrawals, idempotent provider calls, progressive trust, and careful anomaly detection.

The order matters. First make it impossible for valid-looking requests to create invalid money. Then use rate limits and privacy-conscious risk signals to make abuse uneconomical. That is how a rewards platform protects both its payout budget and the quality of the data it sells.

FAQ

What is a race condition payout bug?

A race condition payout bug occurs when two or more requests execute at nearly the same time and each incorrectly receives a reward or withdrawal approval. It can cause duplicate credits, overspending, or duplicate external payouts.

Is rate limiting enough to stop duplicate payouts?

No. Rate limiting reduces abusive volume, but it does not guarantee that concurrent requests cannot pass the same eligibility check. Use database constraints and atomic transactions to enforce the underlying financial rule.

How do you stop one survey from being rewarded twice?

Create a database unique constraint on the account and the specific survey assignment, then insert the completion and its ledger credit within one transaction. The second concurrent attempt must fail at the database layer.

Can anonymous apps prevent fraud without collecting emails or IDs?

Yes, although no control is perfect. Use progressive withdrawal limits, first-cash-out delays, payout-destination clustering, selective CAPTCHAs, behavioral signals, server-side integrity checks, and manual review for higher-risk payouts while minimizing retained personal data.

Why should payout requests use idempotency keys?

An idempotency key lets a system safely retry a payout request after a timeout or transient failure without creating a second transfer. Pair the key with a durable internal payout intent and provider reconciliation process.