Race conditions in AI-generated API code
作者:Seaworthy · 2026年6月22日
Photo by RKTW extend on Unsplash
Race conditions are the kind of bug that doesn't show up in unit tests, passes code review, and then causes a production incident at 2am on a Saturday. AI-generated API code is especially prone to them, not because the AI is careless, but because the patterns it learns from are written for clarity, not concurrency.
Why AI code misses this
LLMs are trained on code examples, documentation, and tutorials. That material is almost universally written to show one thing: how the happy path works. A tutorial teaching you how to deduct credits from a user account doesn't simulate two simultaneous requests hitting the same endpoint.
So the model produces code that looks correct. It reads the current balance, checks if it's sufficient, then deducts the amount. Sequential, readable, easy to follow. The problem is that "easy to follow" and "safe under concurrent load" are not the same thing.
The check-then-act problem
Here's a pattern that appears constantly in AI-generated payment and credits endpoints:
// Typical AI-generated credits deduction
async function deductCredits(userId: string, amount: number) {
const user = await db.users.findUnique({ where: { id: userId } });
if (user.credits < amount) {
throw new Error("Insufficient credits");
}
await db.users.update({
where: { id: userId },
data: { credits: user.credits - amount },
});
}
This reads fine. But two requests arriving within milliseconds of each other will both read the same credits value, both pass the check, and both perform the deduction. A user with 10 credits can spend 20.
This is a time-of-check to time-of-use (TOCTOU) vulnerability. It has real financial consequences, and it's completely invisible until production load creates the right timing or an attacker deliberately engineers it.
The fix is to push the conditional logic into the database query itself, so the check and the update happen atomically:
// Atomic update: the WHERE clause does the guard
const result = await db.users.updateMany({
where: {
id: userId,
credits: { gte: amount },
},
data: {
credits: { decrement: amount },
},
});
if (result.count === 0) {
throw new Error("Insufficient credits");
}
Now if two requests race, only one will match the WHERE clause. The other gets zero rows updated and throws. No double-spend.
Token invalidation
Another common failure: one-time tokens. AI-generated password reset flows frequently look like this:
async function consumeResetToken(token: string) {
const record = await db.resetTokens.findFirst({
where: { token, used: false },
});
if (!record) throw new Error("Invalid or expired token");
await db.resetTokens.update({
where: { id: record.id },
data: { used: true },
});
return record.userId;
}
Two concurrent requests with the same token both read used: false. Both pass the check. The token gets consumed twice, and an attacker who intercepts a reset link can race the legitimate user to exploit it.
Same fix: make the invalidation conditional on the current state.
const result = await db.resetTokens.updateMany({
where: { token, used: false },
data: { used: true },
});
if (result.count === 0) {
throw new Error("Invalid or expired token");
}
One request wins. The other finds nothing to update and fails cleanly.
Rate limiting is not immune
Vibe coders often reach for in-memory rate limiting: increment a counter in a Map and check it before processing a request. This breaks the moment you run more than one process, which happens immediately on platforms that auto-scale or run multiple workers.
Even with Redis, the read-increment-write sequence has the same TOCTOU hole unless you use an atomic command. INCR in Redis is atomic. A GET followed by a SET is not.
# Wrong: two requests can both read 0, both write 1
redis-cli GET rate:user:123
redis-cli SET rate:user:123 1
# Right: INCR is atomic
redis-cli INCR rate:user:123
If you're using a library like @upstash/ratelimit, this is handled for you. If you're rolling your own, you need atomic operations, not a fetch-then-store sequence.
Slug generation: a quieter version of the same bug
This one is rarely framed as a security issue, but it has real operational consequences. AI-generated slug generation for posts or user profile URLs often does a lookup to check uniqueness, then inserts a new row. Under concurrent writes, duplicates get through.
The reliable fix is a unique constraint at the database level plus a retry loop on conflict. The database constraint is the real guard. The application-level check is just an optimisation to produce a better error message.
Why this matters more for AI-assisted code
Traditional developers hit these issues too. The difference is that a developer who wrote the code knows there's a database involved, knows concurrency exists, and at minimum knows to search for how to do an atomic update.
AI-generated code arrives as a finished-looking implementation. The developer reviewing it sees logic that appears complete and correct. There's no obvious gap to investigate. The race condition stays hidden until production load or a deliberate exploit surfaces it.
The practical advice is not "don't use AI for API code." It's this: treat AI-generated read-then-write patterns as suspects. Any time you see a fetch followed by a conditional followed by a write, ask whether those operations need to be atomic. Often, they do.
Seaworthy's static analysis checks for TOCTOU patterns in your API routes and flags read-modify-write sequences that lack transactional guards.
本文由 AI 生成,总结了公开可用的来源。
Seaworthy 会扫描你的代码仓库,发现类似本文提到的各种问题。
安全漏洞、泄露的密钥和配置错误——在部署前捕获。免费运行,无需账户。