Security work that prevents boring disasters

A replacement token can work while a forgotten worker still depends on the old one. Reproduce that failure, verify narrow permissions, and test revocation explicitly.
A working replacement is only part of the evidence
In the local fixture, the web consumer switches to a replacement token and continues receiving HTTP 200. The worker still uses the old token. Revoking that token leaves the web request healthy but changes the worker response to 401. Updating the worker to use the replacement restores its request. A green check from one consumer would have missed the incomplete rotation.
This is a reproducible Laravel 12 and Sanctum 4 example, not an Ulties production incident. The consumers are named variables making separate application requests against an in-memory SQLite database. There is no deployed worker, secret manager or cloud account. The example makes the evidence needed for a rotation concrete without pretending to verify an entire infrastructure security programme.
Give the replacement an explicit permission boundary
The fixture account owns an export and needs to read it. The action below issues a token with only exports:read and a one-hour expiry. The hour is a test choice, not a universal rotation interval. The test freezes its clock to whole-second precision and checks the expiry exactly. The caller must already be trusted to manage this account; this action is not an unauthenticated token-issuance endpoint.
Revocation uses the supplied account’s tokens relationship and the selected token ID. A request to revoke another account’s token deletes zero rows in the fixture. Keeping issuance and revocation separate allows a planned overlap while consumers move. It also leaves the operator responsible for completing revocation; issuing a replacement alone changes nothing about the old token’s validity.
<?php
namespace App\Actions;
use App\Models\User;
use Laravel\Sanctum\NewAccessToken;
final class RotateExportToken
{
public function replacement(User $owner): NewAccessToken
{
return $owner->createToken('export-reader-next', ['exports:read'], now()->addHour());
}
public function revoke(User $owner, int $oldTokenId): int
{
return $owner->tokens()->whereKey($oldTokenId)->delete();
}
}
Test the consumer that was easy to forget
The successful sequence tests both consumers with the old credential, switches each to the replacement, and verifies each again. It then revokes the old token, verifies that it receives 401, and checks that both updated consumers still receive 200. The failure sequence switches only the web consumer before revocation. The worker’s 401 and its recovery after updating the credential are expected assertions, not ignored test errors.
Each simulated request clears Laravel’s cached authentication guards before sending its bearer token. Otherwise, a multi-request framework test could accidentally reuse the previous authenticated user and obscure the revocation boundary. These requests exercise the real Sanctum guard and persisted token records. They do not establish how a long-running worker reloads configuration, retries failed jobs or refreshes a secret-manager cache.
For a planned rotation, I recommend listing every consumer before starting: web instances, scheduled exports, queue workers and external integrations. Attach a last successful check for each consumer and identify who will verify the old credential is rejected. For suspected compromise, OWASP recommends revoking exposed keys promptly; a planned overlap procedure must not become an excuse to keep a compromised credential active.
Prove what the replacement still cannot do
The replacement can read its owner’s export, but reading another account’s export returns 403. Attempting to archive its own export also returns 403 because the token lacks exports:write; the stored status remains ready. A successful read alone would not detect a replacement issued with wildcard permissions.
Three negative controls test whether the assertions detect the intended defects. Granting wildcard abilities makes the forbidden archive request return 200. Skipping revocation makes the old-token requests return 200 instead of 401. Removing the owner scope from token deletion allows the other account to revoke a token it does not own. Each changed implementation fails the corresponding test; a passing normal run alone would be weaker evidence.
OWASP recommends least privilege, deny-by-default decisions and authorization checks on every request. In this fixture, the route checks both resource ownership and the required token ability before changing the export. An inventory entry describing forbidden actions would not enforce either condition. The tests must reach the operation that could read or change the protected resource.
Keep recovery checks separate from credential checks
A token-rotation test says nothing about restoring the service after losing its database or secret store. OWASP’s secrets-management guidance calls for tested backup and restore procedures, including access to emergency credentials when the normal secrets service is unavailable. That is a separate dependency to test, not a benefit demonstrated by this token fixture.
I recommend restoring a selected backup into an isolated environment with outbound mail, payments and production integrations disabled. Check representative records and application behavior, then record recovery time and the age of recovered data. Use those observations to discuss acceptable downtime and data loss with the service owner. This article contains no measured recovery result and makes no claim about an existing backup system.
Reproduce the checks and record their limits
Download the bundle and run python3 frontend/scripts/verify-security-rotation.py from its extracted root with Docker available. Dependency retrieval occurs during the image build using the committed Composer lock and an immutable base image. Execution is unprivileged, read-only and network-disabled. The verifier checks the exact published action, framework versions, three tests and their assertions, then requires all three deliberately broken variants to fail for their intended reasons.
The plaintext tokens are generated only inside the disposable fixture and are not printed or persisted as review evidence. In a real review, record credential identifiers, consumers, permissions and redacted outcomes rather than copying token values into a ticket. The fixture does not test password login, MFA, production TLS, cloud IAM, concurrent requests or operating-system process restarts. It shows why permissions, consumer migration and old-token rejection need distinct evidence.
Reproduce the examples
Locked Laravel/Sanctum fixture with consumer rotation sequences, permission tests and three negative controls.
Download the source and tests (.zip)
