Skip to content
All articles
Engineering26 June 2026·8 min read

Caching a permission check without caching a mistake

The check runs on every request, so it ends up cached. The interesting question is what you are allowed to be wrong about, and for how long.

Paycux engineering

An authorisation check runs on every request that touches anything, several times per request once a page loads a handful of resources. It is the most frequently executed piece of logic in most applications, and it involves joins across memberships, roles and grants. It will be cached; the only question is whether that happens on purpose.

Caching it on purpose means answering one question first: what are you willing to be wrong about, and for how long. Every other decision follows from that, and skipping it produces the specific failure where somebody keeps access for an unbounded period after it was removed.

Cache the inputs, not the verdict

Caching the answer to may this person do this to that resource seems natural and scales badly. The key space is the product of users, actions and resources, so hit rates stay low, and invalidation is impossible to reason about because a single role change invalidates an unknown set of entries you cannot enumerate.

Cache the inputs instead. A person's memberships, their roles within each organisation, the permission set attached to each role: small, stable, shared across many checks, and invalidated by events you can name. The evaluation itself is then in-process work against data you already hold, which is fast enough that the original problem disappears without an entry per decision.

Caching decisions gives you a key space you cannot enumerate. Caching inputs gives you one you can invalidate by name.

Staleness is a policy, so write it down

A cached grant means somebody can act on a permission that was revoked moments ago. That is acceptable for a bounded window, and the window has to be a number you chose rather than whatever the default lifetime happened to be. Thirty seconds is a very different product promise from ten minutes, and both are defensible for different operations.

Where an operation genuinely cannot tolerate staleness — deleting an organisation, exporting the audit log, changing another person's role — bypass the cache for that path specifically. It is a small number of endpoints and the traffic is negligible. The point is that the exceptions are chosen and listed, rather than the whole system being either uniformly stale or uniformly slow.

  • State the maximum staleness as a number, and put it in the documentation customers read
  • Bypass the cache on privileged and irreversible operations rather than everywhere
  • Memoise within a request first; it removes most repeated work and cannot go stale
  • Never cache the outcome of an authentication in the same layer as authorisation data

Invalidate on the write path, and accept that you will miss one

Every operation that changes access must publish an invalidation: role changed, membership removed, group synchronised, organisation policy edited. Doing that from the write path is the only way revocation is fast, and it is also the part that quietly rots, because a new endpoint added six months later forgets to do it.

Two habits contain the damage. Funnel every mutation through one function that owns both the write and the invalidation, so a new caller cannot skip it by accident. And keep a short absolute lifetime underneath the event-driven invalidation, so a missed event bounds itself at that number instead of persisting until the process restarts. Belt and braces here is cheap; the failure it prevents is a person retaining access nobody can explain.

Negative results deserve more suspicion

Caching a deny feels safe because the failure mode is a person seeing an error rather than seeing data. In practice it produces the worst support experience in the product: an administrator grants access, the user reloads, is still refused, and now nobody trusts the permissions screen. The remedy is to give denials a much shorter lifetime than grants, or to skip caching them altogether.

There is a related trap in caching the absence of something. If a lookup for a membership that does not exist yet is cached and then the membership is created, the invalidation must be keyed on something the creation path knows about. It is easy to invalidate by membership identifier when the cached entry was keyed on the user and the organisation, and the two never meet.

  • Give denials a shorter lifetime than grants, or do not cache them
  • Key invalidation on what the writer knows, not on what the reader happened to use
  • Include a version or generation counter per organisation for a cheap bulk invalidation
  • Instrument hit rate and staleness in production; both drift after a schema change

Everything here, already built

Sign-in, enterprise SSO, directory provisioning, roles and an audit trail behind one API. Start with the quickstart and have a working sign-in this afternoon.

Start selling to enterprise customers

Create an account, point sign-in at Paycux, and get back to the part of the product that is actually yours.