0% прочитано

Credential Rotation Without Race Conditions: Short Transactions and lockForUpdate()

Using a real AVN Center case, I show how credential rotation is protected from concurrent changes: sensitive material is prepared before the transaction, authoritative state is re-read under lockForUpdate(), and the old and replacement credentials are changed atomically.

1 жовтня 2026 р. 8 хв читанняLaravel

Credential rotation looks simple: invalidate the current secret, create a new one, and persist it.

The interesting problem appears when two requests try to do this at almost the same time.

Both may read the same current state. Both may decide that the active credential is still eligible for replacement. But only one of them should win.

I encountered exactly this case in the ProductRegistry module of AVN Center while implementing Product Registration credential rotation.

The solution combines several pieces: a short transaction, an authoritative re-read under lockForUpdate(), and an atomic replacement of the old credential with the new one.

The important part is not merely having a transaction. What matters is which state is verified inside it. Data read earlier by the Application layer may already be stale by the time persistence starts writing.

An Early Read Does Not Guarantee a Safe Write

At the beginning of the use case, RotateProductRegistrationCredential loads the current credential and verifies that it matches the authenticated context.

The checks cover:

  • Product Registration identity;
  • credential environment;
  • authentication profile.

This validation is necessary, but it happens before the database write.

Between that read and persistence, another request may already complete a rotation. The earlier object therefore cannot be treated as authoritative at write time.

PHP - Initial context validation
$currentCredential = $this->repository->findById(    $authenticatedContext->credentialId(),); if ($currentCredential === null) {    throw ProductRegistrationCredentialNotFound::forId(        $authenticatedContext->credentialId(),    );} if (    ! $currentCredential->registrationId()->equals(        $authenticatedContext->registrationId(),    )    || $currentCredential->environment()        !== $authenticatedContext->environment()

I Prepare Sensitive Material Before Opening the Transaction

The next important detail is that the new secret and its hash are prepared before the database transaction begins.

Generating the secret does not require a database row lock. Neither does hashing it.

Doing that work after lockForUpdate() would keep the authoritative row locked longer than necessary.

The Application use case therefore prepares everything that does not depend on the database first, and only then enters the atomic replacement operation.

PHP - Preparing the replacement before persistence
$rotatedAt = $this->clock->now(); $currentCredential->supersede(    $rotatedAt,); $replacement = ProductRegistrationCredential::issue(    $this->credentialIdGenerator->generate(),    $currentCredential->registrationId(),    $currentCredential->environment(),    $currentCredential->authenticationProfile(),    $rotatedAt,); $secret = $this->secretGenerator->generate(); 

The lock should protect the critical state transition, not all preparation work around it.

Inside the Transaction I Re-read the Authoritative State

When persistence begins, the repository does not trust the state that was read earlier.

replaceActiveCredential() opens a transaction, finds the current record by its identifier, and reads it using lockForUpdate().

This provides two important properties:

  • while the transaction owns that row lock, another competing change cannot complete an incompatible write against the same state;
  • the decision is based on state confirmed by the database at write time rather than an older in-memory snapshot.
PHP - Authoritative re-read under a row lock
DB::transaction(function () use (    $currentCredential,    $replacementCredential,    $replacementSecretHash,): void {    $locked = ProductRegistrationCredentialRecord::query()        ->whereKey(            $currentCredential->id()->toString(),        )        ->lockForUpdate()        ->first();     if (        $locked === null        || $this->stringAttribute(            $locked,

Under the Lock I Verify More Than the Status

Checking only status = active is not enough.

The persisted record must still belong to the same security binding that was authenticated earlier.

Under the lock, persistence therefore verifies:

  • registration_id;
  • environment;
  • authentication_profile.

If any part no longer matches, rotation fails with a write conflict instead of writing new material over state that has already changed.

PHP - Re-checking the security binding
if (    $this->stringAttribute(        $locked,        'registration_id',    ) !== $currentCredential->registrationId()->toString()    || $this->stringAttribute(        $locked,        'environment',    ) !== $currentCredential->environment()->value    || $this->stringAttribute(        $locked,        'authentication_profile',    ) !== $currentCredential->authenticationProfile()->value) {    throw ProductRegistrationCredentialWriteConflict        ::forCredential(

The Old and New Credentials Change in One Transaction

After the authoritative checks succeed, the current active credential becomes superseded and the replacement is inserted as active.

Both writes happen inside the same transaction.

This avoids two unwanted intermediate states:

  • the old credential is already inactive but no replacement exists;
  • the replacement exists while the old credential is still active.

For this operation, there is only one valid outcome: the old credential is completely replaced by the new one, or the entire operation rolls back.

PHP - Atomic credential replacement
$locked->update([    'status' =>        $currentCredential->status()->value,     'superseded_at' =>        $this->toNullableStorageDateTime(            $currentCredential->supersededAt(),        ),]); ProductRegistrationCredentialRecord::query()->create([    'credential_id' =>        $replacementCredential->id()->toString(),     'registration_id' =>        $replacementCredential->registrationId()->toString(),

What Happens with Two Concurrent Rotations

Two concurrent rotation attempts
Request A                 Request B reads active              reads active    |                         |prepares new secret       prepares new secret    |                         |lockForUpdate()    |verifies active    |superseded + new active    |commit                              |                          acquires lock                              |

This is where the limitation of the initial Application check becomes clear.

Both requests may legitimately observe active before either write begins.

But after the first transaction commits, the second request acquires the lock on an already changed record. Its authoritative re-check sees that the credential is no longer active and stops the second rotation.

The goal is not to prevent a concurrent request from existing. The goal is to ensure that only one competing write can become authoritative.

The Database Remains the Final Guard

The row lock and authoritative re-check do not replace database constraints.

For one Product Registration, environment, and authentication profile, the system must not have more than one active credential.

Database uniqueness therefore remains the final guard. If persistence still violates that rule, the database error is converted into a controlled ProductRegistrationCredentialWriteConflict.

For concurrent code, I want both layers: an explicit check in the code and a hard guarantee where the authoritative state is physically stored.

Why I Keep the Transaction Short

In this case, the transaction protects one very specific critical section:

  • lock the current row;
  • re-check its authoritative state;
  • mark the old credential as replaced;
  • insert the replacement;
  • commit the result.

Secret generation and hashing stay outside because they do not need protection from the database row lock.

The less work performed between lockForUpdate() and commit, the less time the critical row remains locked.

What I Take from This Case

This case shows the difference between ordinary validation and actual protection against concurrent writes.

An early check is useful for fast failure, but it cannot guarantee that the state remains unchanged until persistence.

At the critical point, I therefore return to the source of truth: the database re-reads the row under a lock and only then permits the state transition.

The atomic replacement guarantees that the old credential does not become inactive without a replacement and that the replacement does not appear beside an old credential that is still active.

In concurrent flows, I do not only want to validate state. I want to validate it again at the exact place where that state is about to change.

An early check helps fail fast. A re-check under the lock protects the correctness of the write itself.