Skip to content

Access Caching Strategy

This document is FINAL and ENFORCEABLE for access caching.

It operationalizes the cache behavior already defined in:

  • 2.1.-Backend-Auth.md
  • 2.2.-Backend-Core.md
  • 2.2.1.-Backend-Core-API.md
  • 2.3.-Backend-Base.md
  • 2.4.-Access-Control-Integration.md
  • 2.5.-Access-Matrix.md
  • 2.6.-Middleware-Implementation.md

This document defines how access caching must work across the platform.

It covers:

  • Redis key structure
  • cached payload shape
  • TTL rules
  • invalidation triggers
  • version-based cache safety
  • service responsibilities
  • failure behavior
  • recommended implementation rules

stale access = security issue

Cache is allowed for performance only.

Cache is never the source of truth.

If cache validity cannot be proven:

  • do not use it
  • rebuild it
  • if rebuild fails, deny request

Auth owns:

  • resolving effective access
  • writing access cache
  • reading access cache
  • invalidating access cache when Auth-side access data changes
  • checking tokenVersion / accessVersion / entitlementVersion compatibility

Core owns:

  • company entitlements
  • entitlementVersion

Core must not manage Auth cache directly through user-level logic, but its entitlement changes must trigger cache invalidation behavior.


Backends may consume Auth-resolved access, but they must not become the source of truth for access cache.

If they maintain local short-lived request cache, it must obey the same version rules.


The cache stores resolved effective access for a specific:

  • user
  • company
  • token version
  • entitlement version
  • access version (if used)

This means the cached object is the final output of Auth access resolution.

It is not just:

  • membership data
  • raw grants
  • raw permissions
  • raw entitlements

It is the merged result.


{
"userId": "uuid",
"companyId": "uuid",
"tenantRole": "ADMIN",
"modules": ["basic", "finance"],
"permissions": [
"basic.dashboard.view",
"finance.expense.view"
],
"delegation": {
"canManageUsers": true,
"canBuyAddons": false,
"grantableModules": ["basic"],
"grantablePermissions": ["basic.dashboard.view"]
},
"meta": {
"tokenVersion": 3,
"accessVersion": 14,
"entitlementVersion": 8,
"generatedAt": "2026-04-16T05:00:00Z",
"cached": true
}
}

Recommended final key:

access:{userId}:{companyId}:{tokenVersion}:{accessVersion}:{entitlementVersion}

Example:

access:d7b61435-d9cc-4162-9346-d5300e13b553:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:3:14:8

This is the safest key because it prevents accidental reuse across version changes.


If accessVersion is not yet implemented:

access:{userId}:{companyId}:{tokenVersion}:{entitlementVersion}

Example:

access:d7b61435-d9cc-4162-9346-d5300e13b553:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:3:8

These are useful for invalidation.

access-index:user:{userId}

Value:

  • set of access cache keys for that user
access-index:company:{companyId}

Value:

  • set of access cache keys for that company
access-index:membership:{membershipId}

Value:

  • set of access cache keys for that membership

These index sets help delete many keys safely during invalidation.


Recommended TTL for resolved access cache:

60 seconds

This is the default recommendation.


Acceptable short TTL range:

  • 30 seconds
  • 60 seconds
  • 120 seconds

Do not use long TTLs for access cache unless invalidation is extremely reliable.


TTL is not the main correctness mechanism.

Correctness depends on:

  • version-aware keys
  • invalidation triggers
  • fail-closed rebuild behavior

TTL only limits stale exposure if invalidation is missed.


8. Version fields that must influence cache validity

Section titled “8. Version fields that must influence cache validity”

Owned by Auth.

This changes when:

  • sessions are revoked
  • logout-all is used
  • security reset occurs
  • token lifecycle policy forces invalidation

If tokenVersion changes:

  • old access cache must not be reused

Owned by Core.

This changes when:

  • Basic activated/deactivated
  • add-on activated/deactivated
  • package mapping changes affect active companies
  • add-on mapping changes affect active companies
  • entitlement repair occurs

If entitlementVersion changes:

  • old access cache must not be reused

Owned by Auth.

This changes when:

  • membership module grants change
  • membership permissions change
  • delegation policy changes
  • tenant role changes
  • membership status changes

If accessVersion changes:

  • old access cache must not be reused

A cached access object is valid only if:

  • userId matches request user
  • companyId matches x-org
  • tokenVersion matches current token state
  • entitlementVersion matches current company entitlement state
  • accessVersion matches current membership access state (if implemented)

If any mismatch exists:

  • cache is invalid
  • rebuild is required

When Auth resolves /auth/me/access:

  1. validate JWT
  2. resolve current tokenVersion
  3. resolve membership
  4. resolve current accessVersion
  5. resolve current entitlementVersion from Core
  6. build cache key
  7. check Redis
  8. if hit → return cached object
  9. if miss → recompute access and store
  10. return rebuilt object

Auth should not “trust old cached access and then compare later.”

It should build the correct cache key first from current versions, then lookup.

That avoids stale-key reuse.


When no valid cache key exists:

  1. Auth loads:
    • membership
    • module grants
    • permissions
    • delegation
    • entitlements from Core
  2. Auth computes effective access
  3. Auth writes cached object under correct key
  4. Auth updates index sets
  5. Auth returns access result

Every successful cache write should:

  • set main access key with TTL
  • add key name to:
    • user index
    • company index
    • membership index (if available)

This makes invalidation efficient.


This is the most important section.

Invalidate when any of these happen:

  • login causing version reset policy
  • tokenVersion bump
  • logout-all
  • membership created
  • membership deactivated
  • membership reactivated
  • membership deleted
  • tenant role changed
  • membership module grant added
  • membership module grant removed
  • membership permission added
  • membership permission removed
  • delegation policy changed
  • business-unit linkage affecting access changes
  • invitation accepted if it creates or changes membership access

Invalidate when any of these happen:

  • Basic activated
  • Basic deactivated
  • Basic expired
  • add-on activated
  • add-on deactivated
  • add-on expired
  • package remapped affecting active company
  • add-on remapped affecting active company
  • company entitlement repair
  • company commercial status changed in any way affecting enabled modules

Section titled “12.3 Frontend-related events that should cause access refresh”

These are not Redis invalidations directly, but must trigger frontend reload:

  • company switch
  • user management change
  • module grant/revoke change
  • permission change
  • add-on purchase/upgrade
  • invitation acceptance
  • backend 403 that may indicate stale access

13.1 Best strategy — versioned key + targeted delete

Section titled “13.1 Best strategy — versioned key + targeted delete”

Use:

  • versioned key so old entries become unreachable
  • targeted delete for cleanup

This is the safest pattern.

Even if delete misses, the stale key is not reused because versions changed.


When a specific user/membership changes:

  1. find access-index:user:{userId}
  2. delete all referenced access keys
  3. delete the user index itself
  4. optionally delete matching membership index

When company entitlements change:

  1. find access-index:company:{companyId}
  2. delete all referenced access keys
  3. delete the company index itself

This ensures all user/company access states for that company are rebuilt.


When one membership changes:

  1. find access-index:membership:{membershipId}
  2. delete all referenced access keys
  3. delete the membership index itself

This is more precise than deleting by company.


If Redis is unavailable:

  • Auth may recompute access directly from source systems
  • if recomputation succeeds → request may proceed
  • if recomputation fails → deny request

If Redis is available but current access cannot be validated:

  • do not trust stale object
  • recompute

If recomputation fails because Auth or Core dependency fails:

  • return 503

Never allow request from stale cache when current validity cannot be proven.

Use:

  • string for access payload (JSON serialized)

Use:

  • set for user/company/membership indexes

Main key:

access:user123:company456:3:14:8

Main value:

{ ... full access object ... }

User index:

access-index:user:user123

Set members:

access:user123:company456:3:14:8
access:user123:company999:3:15:5

Track:

  • cache hit rate
  • cache miss rate
  • cache rebuild time
  • invalidation count
  • invalidation failures
  • Auth access resolution latency
  • Core entitlement fetch latency
  • /auth/me/access 503 rate

Log:

  • access cache hit
  • access cache miss
  • access cache write
  • tokenVersion mismatch
  • entitlementVersion mismatch
  • accessVersion mismatch
  • user invalidation
  • company invalidation
  • membership invalidation

Alert when:

  • /auth/me/access 503 rate spikes
  • Redis unavailable
  • cache hit rate drops abnormally
  • Core latency spikes
  • Auth access rebuild time spikes

17. Suggested implementation pattern (Node)

Section titled “17. Suggested implementation pattern (Node)”
function buildAccessCacheKey(params: {
userId: string;
companyId: string;
tokenVersion: number;
accessVersion?: number;
entitlementVersion: number;
}) {
return [
"access",
params.userId,
params.companyId,
params.tokenVersion,
params.accessVersion ?? 0,
params.entitlementVersion,
].join(":");
}

async function writeAccessCache(
redis: any,
key: string,
ttlSeconds: number,
payload: unknown,
indexes: { userId: string; companyId: string; membershipId?: string }
) {
const multi = redis.multi();
multi.set(key, JSON.stringify(payload), "EX", ttlSeconds);
multi.sadd(`access-index:user:${indexes.userId}`, key);
multi.sadd(`access-index:company:${indexes.companyId}`, key);
if (indexes.membershipId) {
multi.sadd(`access-index:membership:${indexes.membershipId}`, key);
}
await multi.exec();
}

func BuildAccessCacheKey(userID, companyID string, tokenVersion, accessVersion, entitlementVersion int) string {
return fmt.Sprintf("access:%s:%s:%d:%d:%d", userID, companyID, tokenVersion, accessVersion, entitlementVersion)
}

func InvalidateSet(ctx context.Context, rdb *redis.Client, setKey string) error {
members, err := rdb.SMembers(ctx, setKey).Result()
if err != nil {
return err
}
if len(members) > 0 {
keys := make([]string, 0, len(members)+1)
keys = append(keys, members...)
keys = append(keys, setKey)
return rdb.Del(ctx, keys...).Err()
}
return rdb.Del(ctx, setKey).Err()
}

QA must validate:

  • cache hit on repeated request with same versions
  • cache miss after tokenVersion change
  • cache miss after entitlementVersion change
  • cache miss after membership permission change
  • cache miss after module grant change
  • user-target invalidation works
  • company-target invalidation works
  • membership-target invalidation works
  • Redis down but Auth recompute works → access still works
  • Redis down + Auth/Core rebuild fails → 503
  • stale object present but versions changed → stale object not used

Use short TTL.
Use versioned keys.
Invalidate aggressively.
Fail closed when validity cannot be proven.

Resolved access may be cached in Redis, but only when tied to current token, membership, and entitlement versions, and only while invalidation remains reliable.