Atomic Database Operations#
Better Auth exposes three database-level primitives — consumeOne, incrementOne, and reserveVerificationValue — that provide race-safe single-row mutations without application-layer locking. They are defined on the DBAdapter interface and are always present on the factory-wrapped adapter; adapters that don't implement them natively receive a transaction-based fallback automatically .
| Auth-Flow Concern | Primitive |
|---|---|
| Replay protection (OTP, 2FA, WebAuthn, password-reset) | consumeOne via consumeVerificationValue |
| Rate limiting / quota enforcement | incrementOne |
| First-writer-wins deduplication / nonce tombstoning | reserveVerificationValue |
consumeOne — Atomic Single-Use Token Consumption#
consumeOne deletes a single matching row and returns it, or returns null if no row matched. Under concurrent invocation exactly one caller receives the row; every racer gets null .
The higher-level consumeVerificationValue on InternalAdapter wraps consumeOne inside a transaction with a per-key in-process mutex (withVerificationConsumeLock), cleans up stale siblings, and handles the secondary-storage path via secondaryStorage.getAndDelete when available. All downstream state changes must be gated on a non-null return value .
Callers:
- Email OTP —
atomicVerifyOTPcallsconsumeVerificationValue; concurrent racers receivenull→INVALID_OTP. Wrong codes recreate the row with an incremented attempt counter; after 3 failures (default) the row stays consumed . - Phone OTP — same pattern; racers are rejected with
INVALID_OTP. - 2FA challenge —
consumeVerificationValue(signedTwoFactorCookie)is called before issuing a session, preventing replay . - WebAuthn challenges — consumed on first use .
- Password reset —
consumeVerificationValue("reset-password:{token}")atomically deletes the token; expired or already-used tokens returnnull→INVALID_TOKEN.
Adapter fallback: factory wraps findMany + deleteMany in a transaction, re-applying the original where plus the matched row's id as a compare-and-swap guard. See factory.ts L1370–1416 .
incrementOne — Atomic Guarded Counter Update#
incrementOne applies field = field + delta to a single row only while the where clause still matches. The predicate acts as both row selector and invariant guard — encoding constraints like remaining > 0 or count < max directly in the atomic SQL statement. Returns the updated row, or null if the guard failed .
Callers:
- Core rate limiter — two
incrementOnecalls inrate-limiter/index.tsL169–205: window reset (guarded onlastRequest <= data.lastRequest) and in-window increment (guarded oncount < max) . - API key quota —
consumeRemainingdecrementsremainingby 1 guarded onremaining > 0;null→USAGE_EXCEEDED. - API key rate limit —
consumeRateLimitsequencesstart / reset / increment / denydecisions, each backed by a guardedincrementOne; on guard failure the row is re-fetched and the function retries recursively . - OAuth refresh token rotation — CAS-style
incrementOnemarks a token revoked while minting its successor; a concurrent redeemer receivesinvalid_grant. - Invitation acceptance —
updateInvitationwithfromStatus: "pending"compiles toUPDATE … SET status = 'accepted' WHERE id = ? AND status = 'pending' RETURNING *; only one concurrent acceptor wins .
Adapter fallback: wraps findMany + updateMany in a transaction, re-applying the original where predicates on the write. See factory.ts L1529–1585 .
reserveVerificationValue — First-Writer-Wins Tombstoning#
reserveVerificationValue attempts to INSERT a marker row exactly once, returning true if this caller created it, false if a row for the same identifier already existed .
The reservation key is a deterministic SHA-256 hash of "reserve:" + identifier (base64url-encoded); the primary key constraint rejects duplicates. A failed create triggers a re-read to distinguish a race loss (row exists → false) from a genuine error (row absent → re-throw) .
Use cases: SAML assertion IDs, JWT jti replay protection, or any nonce that must be accepted exactly once.
Caveat: the secondary-storage-only path (storeInDatabase: false) has no primary key uniqueness guarantee and is best-effort under concurrency; a FIXME in the source marks this for future tightening. Additionally, reserveVerificationValue's secondary-storage write path lacks the if (ttl > 0) guard that is present in createVerificationValue and updateVerificationByIdentifier, which can cause unintended behavior when TTL is zero or negative .
Adapter Portability and Known Gaps#
consumeOne and incrementOne are optional on CustomAdapter but always present on the factory-wrapped DBAdapter. They are tracked for promotion to required in a future release .
| Primitive | Ideal native form | Fallback |
|---|---|---|
consumeOne | DELETE … RETURNING * | transaction(findMany + deleteMany) |
incrementOne | UPDATE … SET n = n+Δ WHERE … RETURNING * | transaction(findMany + updateMany) |
Drizzle / PostgreSQL atomicity gap (fixed in PR #11331): The Drizzle adapter previously used a SELECT … LIMIT 1 subquery inside the UPDATE's WHERE IN (…) without repeating the guard on the UPDATE itself. Under PostgreSQL READ COMMITTED, two concurrent writers could both select the same row ID and both succeed, violating the one-winner contract. PR #11331 fixed this by repeating the original where conditions on the UPDATE statement, so PostgreSQL rechecks the guard after waiting for a concurrent writer . MySQL remains handled with an explicit SELECT … FOR UPDATE transaction path .
consumeVerificationValue secondary storage: Without getAndDelete, the fallback get + delete cannot coordinate across processes; a FIXME tracks making getAndDelete required in a future breaking release .
API key secondary storage: claimUsageInSecondaryStorage is a non-atomic read-modify-write. Use storage: "database" or fallbackToDatabase: true for strict quota/rate-limit enforcement .
Key Source References#
| Resource | Role |
|---|---|
packages/core/src/db/adapter/index.ts L458–504 | consumeOne and incrementOne interface definitions |
packages/core/src/db/adapter/factory.ts L1336–1585 | Transaction-based fallback implementations |
packages/better-auth/src/db/internal-adapter.ts L1229–1497 | consumeVerificationValue and reserveVerificationValue |
packages/better-auth/src/api/rate-limiter/index.ts | Core rate limiter using incrementOne |
packages/better-auth/src/plugins/email-otp/routes.ts L1304–1352 | atomicVerifyOTP implementation |
| PR #9993 | Introduced consumeOne, incrementOne, and atomicity across replay markers |
| PR #11331 | Fixed Drizzle PostgreSQL incrementOne guard recheck under row-lock contention |