Dlog

Ensuring Data Integrity With Stripe's Payment Success Webhook

📖 4 min read

I was recently reviewing a Stripe integration in an open-source project when I came across a critical bug. Although if you measure the severity of a bug as a function of the bug severity * the number of impacted users, I’d argue it’s not so critical. Nonetheless, let’s start with the code. Take a look and see if you can identify any issues.

export async function POST(req: Request) {
  const body = await req.text();
  const signature = headers().get('Stripe-Signature') as string;

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (error) {
    return new Response(`error: ${message}`, { status: 400 });
  }

  const session = event.data.object as Stripe.Checkout.Session;

  if (event.type === 'payment_intent.succeeded') {
    const { plan, userId } = session.metadata as Metadata;

    const [user] = await db
      .select()
      .from(userTable)
      .where(eq(userTable.id, userId));

    const creditPlan = creditPlans.find(
      creditPlan => creditPlan.stripePriceId === plan
    );

    if (!creditPlan) {
      return new Response(`invalid plan ${plan}`, { status: 400 });
    }

    await db
      .update(userTable)
      .set({ credits: user.credits + creditPlan.creditAmount })
      .where(eq(userTable.id, userId));

    await db.insert(payment).values({
      id: createId(),
      userId: user.id,
      amount: creditPlan.creditAmount,
      price: creditPlan.price.toString(),
    });
  }

  return new Response(null, { status: 200 });
}

Hint: while the project is written in Typescript, the issues are language agnostic. I will note that the ORM in use is Drizzle, though it shouldn’t be necessary to identify the issues.

No, it is not that they’re using Typescript.

Issues

  1. Race Condition: There is a race condition, specifically in the block that handles payment_intent.succeeded events. If multiple requests for the same user with different successful payments are processed concurrently, it could lead to inconsistent and stale data in the database. The problem arises because the code reads the user’s current credit value from the database, performs a credit update, and then writes the updated credit value back to the database. However, if two requests read the user’s credit value simultaneously, both processes might increment the credit based on the outdated value, leading to incorrect results.

  2. Data Integrity Violation: The second issue is related to data integrity and occurs within the same block handling payment_intent.succeeded events. After updating the user’s credit, the code inserts a new payment record into the database. However, if the payment insert operation fails for some reason (e.g., database connectivity issues or constraints violation), it will leave the system in an inconsistent state. The user’s credit will be updated, but there will be no corresponding payment record to reflect the successful transaction.

Both of these are are large data integrity violations in a very critical section of code.

Remediations

  1. To address the race condition, the code needs to implement a mechanism to ensure that updates to the user’s credits are atomic and isolated, so that concurrent updates won’t cause conflicts or inconsistencies.

  2. To address the data integrity violation, the code should ensure that both the user update and the payment insert are treated as part of a single transaction. If any part of the transaction fails, all changes made within that transaction should be rolled back to maintain data consistency.

Here’s the updated code with the remediations applied.

export async function POST(req: Request) {
  const body = await req.text();
  const signature = headers().get('Stripe-Signature') as string;

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (error) {
    return new Response(`error: ${message}`, { status: 400 });
  }

  const session = event.data.object as Stripe.Checkout.Session;

  if (event.type === 'payment_intent.succeeded') {
    const { plan, userId } = session.metadata as Metadata;

    const creditPlan = creditPlans.find(
      creditPlan => creditPlan.stripePriceId === plan
    );

    if (!creditPlan) {
      return new Response(`invalid plan ${plan}`, { status: 400 });
    }

    await db.transaction(async (tx) => {
      const [user] = await tx
        .select()
        .from(userTable)
        .where(eq(userTable.id, userId))
        .for('update');

      await tx
        .update(userTable)
        .set({ credits: user.credits + creditPlan.creditAmount })
        .where(eq(userTable.id, userId));

      await tx.insert(payment).values({
        id: createId(),
        userId: user.id,
        amount: creditPlan.creditAmount,
        price: creditPlan.price.toString(),
      });
    });
  }

  return new Response(null, { status: 200 });
}

Let’s dissect the changes.

  1. Atomic and Isolated Updates: The code has been wrapped in a transaction to ensure atomicity and isolation of updates. The db.transaction function is used to define a transaction block, and all database operations within this block are treated as part of a single transaction.

  2. FOR UPDATE Lock: The query to fetch the user’s data is appended with .for('update'), which applies a SELECT FOR UPDATE lock on the user’s row during the transaction. This lock prevents concurrent processes from reading the user’s data until the transaction is completed, avoiding the race condition.

  3. Transactional Insert: The user update and the payment insert operations are now both executed within the same transaction. If any part of the transaction fails (e.g., due to database constraints or other errors), the entire transaction will be rolled back, maintaining data integrity.

Conclusion

With the implementation of these crucial remediations, the webhook has been significantly enhanced to handle successful payment events from Stripe with a robust approach, ensuring data integrity and consistency. Although the modifications were tailored to the specific ORM used in this project, the underlying principles of ACID compliance have universal relevance and span across various domains and problem spaces.