Caching is usually introduced as a performance decision. Once cached data participates in an authorization result, it also determines how long an old security fact remains usable.
This is Part 4 of Security Infrastructure in Practice, a series about what happens when security design meets production systems.
Caches are supposed to make authorization faster. They can also keep an old permission alive after the source of truth has revoked it.
This rarely begins as a security decision. A team sees latency from an identity service and adds a fifteen-minute cache. Another team caches final authorization results for an hour. Both changes improve performance.
Then a user changes departments or a device falls out of compliance. The source systems are correct. The authorization path continues using yesterday’s answer.
At that point, cache configuration has become access-control policy.
What Exactly Are You Caching?
There is a meaningful difference between caching a policy bundle, an attribute, and a final decision.
A cached policy can still evaluate the current request. A cached attribute carries a risk that the underlying fact has changed. A cached allow decision preserves both the old inputs and the old conclusion.
cache_key = (
subject_id,
resource_id,
action,
policy_version,
attribute_generation
)
If the decision cache does not include policy and attribute versions, it cannot know when its answer became obsolete.
One TTL Does Not Fit Every Attribute
A display name can remain stale without changing an authorization result. Employment status cannot. Device posture may change several times during a workday.
Assigning one cache lifetime to the entire identity object hides those differences.
freshness:
display_name: 86400
department: 14400
employment_status: 300
device_compliant: 120
These numbers are examples, not recommendations. The acceptable age comes from the operation’s risk and the behavior of the source system.
Freshness should be checked by policy. A restricted export may require newer device posture than an ordinary page view.
Revocation Changes the Calculation
A stale deny is inconvenient. A stale allow can preserve access after revocation.
That asymmetry is why I avoid caching allow decisions for long periods. When possible, push revocation events that invalidate affected entries. Keep the normal TTL anyway. Event delivery can fail.
def on_identity_change(event):
decision_cache.invalidate_subject(event.subject_id)
attribute_cache.invalidate(
subject_id=event.subject_id,
changed_fields=event.changed_fields
)
Invalidating by subject is simple but may be expensive. Invalidating by changed field is more selective but requires each policy to declare its dependencies. The right choice depends on scale and risk.
An Outage Needs a Cache Policy
When the identity source is unavailable, a cache miss is obvious. A stale hit is more dangerous because it looks successful.
Give cached values a soft expiry and a hard expiry. After the soft expiry, serve only where policy allows and refresh in the background. After the hard expiry, use the operation’s documented failure posture.
def resolve(attribute, operation, now):
cached = attribute_cache.get(attribute.key)
if cached and now < cached.soft_expiry:
return current(cached)
if cached and now < cached.hard_expiry:
if operation.permits_stale(attribute.name):
schedule_refresh(attribute.key)
return stale(cached)
return unavailable(attribute.name)
The policy engine should know that the value is stale. Returning it as current erases the information needed for a safe decision.
Put Cache State in the Evidence
When a decision is questioned, record whether each important attribute came from a live source or cache. Include its observation time and generation.
{
"attribute": "device_compliant",
"value": true,
"source": "cache",
"observed_at": "2026-09-09T18:02:00Z",
"age_seconds": 74
}
Do not place sensitive raw values in a broadly available log. The evidence record can use protected references where necessary.
Test the Old Answer
Cache tests often confirm hit rate and eviction. Authorization tests need a different case: a cached allow followed by revocation.
def test_revocation_invalidates_cached_allow():
authorize_and_cache(subject="user-1842", action="export")
publish_deactivation(subject="user-1842")
result = authorize(subject="user-1842", action="export")
assert result.effect == "deny"
Also test what happens when the invalidation event is delayed. That test forces the team to confront the maximum exposure window instead of assuming the event bus is perfect.
The cache is not sitting beside the security model. It decides how long old security facts remain usable. Review it with the same care as the authorization rule.
Who chooses authorization-cache lifetimes on your systems: the performance owner, the security owner, or whoever wrote the default?
Top comments (0)