An application can perform a correct authorization check and still allow an action that should no longer be permitted. The problem appears when the application checks authority, waits or performs other work, and only later changes protected state. During that gap, the facts that justified the decision can change.

For example, a worker may confirm that a user can modify a project, queue the requested change, and apply it several seconds later. If the user’s project access is revoked before the worker runs, using the earlier decision can let revoked authority survive longer than intended.

This is a security form of a time-of-check to time-of-use race, often shortened to TOCTOU: the application checks a condition at one moment but relies on that result at a later moment when the condition may no longer be true.

This article develops a practical mental model for authorization races. You will learn which facts can become stale, where to place the decisive check, how transactions and version checks can help, and where rechecking is necessary but still not sufficient.

An authorization result is a statement about a moment

Suppose an application evaluates this question:

Can user 42 delete document 900 now?

The answer may depend on several pieces of state:

user 42 is active
user 42 belongs to team 7
document 900 belongs to team 7
user 42 has the editor role

If all four facts are true, the application may return allow. But allow is not a permanent property of the request. It is the result of evaluating particular facts at a particular time.

That distinction matters whenever those facts are mutable. A role can be removed, an account can be suspended, or a resource can move to another owner. If the protected operation happens after such a change, an earlier authorization result may no longer describe reality.

A useful mental model is:

authorization decision = principal + action + resource + relevant state + time

Treating the decision this way makes stale authority easier to notice during design reviews.

See the race in a small example

Consider a simplified document deletion flow:

1. API checks that Alice may delete document 900.
2. API places "delete document 900" on a queue.
3. An administrator removes Alice from the document's team.
4. A worker receives the queued job.
5. Worker deletes document 900 because step 1 already allowed it.

Nothing requires the first check to be incorrect. The failure is that the worker treats an old decision as if it were still current.

The queue creates an obvious delay, but the same pattern can occur inside one request. A service might read permissions, call another service, perform expensive validation, and then update a record. Concurrent requests can change authorization-relevant state during that interval.

The attacker or failure condition does not need to break the permission rule itself. It only needs a useful state change to occur between the check and the protected effect.

Put the decisive check close to the protected effect

The main defensive rule is simple: evaluate mutable authorization facts as close as practical to the operation that relies on them.

For the queued deletion, the worker should not accept “the API allowed this earlier” as sufficient authority. The job can carry the identity and requested action, while the worker obtains current authorization state before deleting the document:

job: principal=alice, action=delete, resource=document-900

worker:
    load current document
    load current authority for principal
    authorize principal to delete current document
    if denied:
        stop without deleting
    perform deletion

This example is deliberately framework-neutral. In production, the authorization function should use the same policy semantics as other entry points rather than duplicating a weaker version of the rule in the worker.

Rechecking changes the failure mode. Revoking Alice’s team membership before the worker authorizes the operation now causes the deletion to be rejected instead of using stale authority.

Rechecking is strongest when the state cannot change underneath it

Moving the check later narrows the race window, but it does not automatically remove it.

Imagine this sequence:

1. Worker reads Alice's role: editor.
2. Worker authorizes deletion.
3. Administrator revokes the role.
4. Worker deletes the document.

There is still a gap between authorization and use. Whether that gap matters depends on the application’s required semantics and storage model.

When a permission change must take effect before a sensitive state change can commit, the authorization-relevant state and protected update need stronger coordination. Common approaches include a database transaction, conditional update, lock, or version check. The exact mechanism is platform-specific; the security property is what matters: the operation must not commit based on authorization state that changed in a way that invalidates the decision.

One useful design is optimistic concurrency. Suppose the membership record has version 17. The worker authorizes using version 17, then performs the protected change only under a condition that the relevant authorization version is still 17. If a revocation changes it to 18, the conditional operation fails and the worker can reevaluate the request.

A transaction can provide a similar property when the database and isolation strategy allow the relevant authorization state and protected data to be coordinated correctly. Do not assume that merely opening a transaction freezes every value that matters; transaction isolation and data placement determine what guarantees actually exist.

Decide which state must remain stable

Not every input to a policy needs transactional coordination. Focus on facts whose change would alter the authorization result.

For a transfer approval, those facts might include:

account status
principal's role
resource ownership
approval threshold
whether the transfer was already completed

A user’s display name probably does not matter. Their suspension status probably does.

This distinction keeps the control practical. Trying to freeze all application state can add contention and complexity without improving the authorization guarantee. Instead, identify the minimum set of mutable facts that the policy relies on and protect the relationship between those facts and the sensitive effect.

This is also why copying roles or ownership into a long-lived job can be dangerous. A copied value is a snapshot. If the current value is authoritative, load or validate the current value when the action is executed.

Be explicit about revocation semantics

“Revocation should be immediate” sounds precise, but distributed systems need a clearer definition.

Suppose a user starts a large export while authorized, then loses access halfway through. Several policies are defensible:

  • Authorization at start: an operation that legitimately began may finish.
  • Authorization at commit: the final sensitive effect requires authority to remain valid.
  • Authorization throughout: long-running work periodically checks whether authority still exists.

The right choice depends on the consequence of continuing and the cost of interruption. A read-only report generated from a fixed snapshot may reasonably use start-time authorization. Changing a payment destination may require authority at the final commit. A long-running stream of sensitive records may need repeated checks or a revocable session mechanism.

The important point is to choose the rule deliberately. Without an explicit rule, different components often make different assumptions about how long an authorization decision remains valid.

Treat asynchronous work as a new authorization boundary

Queues, schedulers, and background workers deserve special attention because they naturally separate checking from execution.

A message such as this is risky when interpreted as a permanent authorization grant:

delete document 900 because request 123 was authorized

Prefer representing the requested operation and enough stable identity to evaluate it again:

principal: alice
action: delete
resource: document-900
request_id: 123

The worker then makes a current decision before producing the sensitive effect.

There are exceptions. Some systems intentionally issue a capability or signed delegation that grants authority for a defined scope and lifetime. In that design, the credential itself is the authority, rather than a cached answer from an earlier permission check. Its scope, expiry, revocation model, and protection then become part of the threat model. Do not accidentally turn an ordinary queue message into such a capability by trusting an authorized=true field indefinitely.

Understand what this control does not solve

Point-of-use authorization reduces the risk from stale permission decisions and authorization-relevant race conditions. It does not repair a policy that grants too much authority in the first place.

It also does not solve object-selection bugs. If the application authorizes document 900 and later operates on document 901 because an identifier changed or was confused, the check and use still refer to different resources. Bind the authorization decision to the exact resource and action that will be used.

Rechecking also does not make an operation idempotent. A retried authorized request may still execute twice unless the application separately controls duplicate effects.

Finally, this technique cannot promise instantaneous revocation across every distributed component. Cached policy data, replicated databases, network partitions, and already-running operations all affect how quickly a change becomes observable. Define an acceptable revocation delay for the system and design caches and execution paths around that requirement.

Avoid two misleading shortcuts

The first shortcut is checking only at the user-facing API because “the worker is internal.” Internal location does not preserve the freshness of an earlier decision. If a worker performs a security-sensitive effect later, it needs authority that is valid under the system’s execution-time policy.

The second shortcut is simply checking twice without coordinating mutable state. Two checks can reduce the window, but if the required guarantee is “permission must still be valid when the change commits,” the design must connect authorization state to that commit through appropriate concurrency control.

Extra checks are useful only when you can explain which race they close and which race remains.

Test the state change, not just the allow and deny cases

Normal authorization tests usually verify a stable allowed case and a stable denied case. Add a test that changes authority between stages.

For asynchronous work, a useful test is:

1. Grant a principal permission.
2. Submit the sensitive operation.
3. Pause before the worker's decisive authorization step.
4. Revoke the permission.
5. Continue the worker.
6. Verify that the protected effect does not occur.

If the design uses a version or conditional write, also test that changing the relevant version after authorization causes the commit to fail and forces reevaluation.

These tests verify the security property directly. They are more informative than merely asserting that an authorization helper was called.

Use stronger coordination only where the threat model needs it

For low-impact operations where a short revocation delay is acceptable, a fresh check at execution time may be sufficient. This is often much safer than carrying an old decision through a queue and much simpler than coordinating every permission read with every write.

For high-impact operations where authority must remain valid through commit, use stronger coordination around the small set of authorization-relevant state. That may mean transactional checks, conditional writes, versioned policy state, or another mechanism with equivalent guarantees on your platform.

Defense in depth can add short-lived credentials, audit events, approval workflows, or monitoring for unusual sensitive actions. Those controls address different failure modes; they do not replace correct point-of-use authorization.

Conclusion

Authorization is not just a yes-or-no answer. When permissions, ownership, account state, or policy can change, an authorization result is also tied to time.

For sensitive operations, carry the principal, action, and resource to the component that performs the effect, then evaluate current authority there. If your requirement says authority must remain valid through commit, coordinate the authorization-relevant state with that commit rather than relying on a check that can become stale.

The practical question to ask in a design review is: What could change between this authorization check and the protected effect, and would that change invalidate the decision? If the answer is meaningful, the check is probably too far from the use or needs stronger concurrency control.