A report is slow, so someone adds caching. The next person opens the dashboard and gets a result immediately. That feels like a successful optimization until they notice the first person's customer list.
An older Streamlit forum thread contains reports of users seeing one another's data and a long discussion about session state and caching. It does not establish a single diagnosis for every app in that thread. It does suggest a useful test for any internal tool that started life with one user.
Name the audience of a cached result
Two questions belong in the design review: who may read this result now, and which previous computation can supply it?
The first is authorization. The second is cache identity. Neither can substitute for the other.
Current Streamlit caching documentation says cached values are shared across users by default, distinguishes copied data from shared resources, and offers session scope. Check the options supported by your installed version. Copying a cached object protects against some shared mutations; it does not decide whether its contents belong to the current reader.
For a private report, consider the tenant, authenticated principal or effective visibility scope, requested filters, and data revision. If two users have different row-level permissions, a tenant ID alone is insufficient. A shared database connection is not proof that its query results have the same audience.
Derive these values from trusted server-side context. A caller supplying a different user ID must not acquire that user's cached report.
Keep the access check on the path that runs
A cache hit can skip the function body. An authorization check inside that body may therefore disappear precisely when a result is available quickly.
This small standard-library example demonstrates the placement of the check. It is an executable teaching example with fictional users, not a Streamlit integration or a complete access-control system:
from functools import lru_cache grants = {("alice", "team-a"), ("bob", "team-a")} rows = { ("team-a", "alice", 1): ("A-001",), ("team-a", "bob", 1): ("B-001",), } @lru_cache(maxsize=128) def cached_rows(tenant, principal, revision): return rows[(tenant, principal, revision)] def report(principal, tenant, revision): # Production: resolve identity and current grants on the server. if (principal, tenant) not in grants: raise PermissionError("Access denied") return cached_rows(tenant, principal, revision) expected = {"alice": ("A-001",), "bob": ("B-001",)} for order in (("alice", "bob"), ("bob", "alice")): cached_rows.cache_clear() for principal in order: assert report(principal, "team-a", 1) == expected[principal] grants.remove(("alice", "team-a")) try: report("alice", "team-a", 1) # Alice's result is still cached. except PermissionError: pass else: raise AssertionError("A warm cache bypassed revocation") Both users belong to the same tenant and have different rows. Local checks on Python 3.14.4 passed this exact example in both warm-up orders. A deliberately broken cache that ignored the principal returned the first user's rows to the second and failed the assertions. The correct key passed again, including the warm-cache revocation check. These are local standard-library checks, not a live Streamlit test.
Production policy can be more granular than this example. Partial row-level revocation is outside this demonstration. If a user retains access to the report while losing access to some rows, the cache identity needs the effective permission scope or a trustworthy policy revision as well. Check current authorization before returning the value, and arrange invalidation when the visibility rules change.
Data freshness is a separate decision. A TTL limits age; it does not enforce access. A source revision should identify the data actually queried. Reading a revision and then querying a moving dataset needs a consistency rule too.
Use two independent sessions
Start with two test accounts that have deliberately different visible records. Warm the cache as the first account, then request the equivalent report as the second. Reverse the order. Change a filter, change the dataset, and revoke access while the cached value remains warm.
If your app supports logout and another login in the same browser, test that transition as well. Session-local storage still needs a lifecycle: remove or rebind private values when the authenticated identity changes. A fresh browser tab is a useful test setup, not a security boundary for a badly scoped server cache.
Keep assertions about the returned records, not just the page heading or selected username. The dashboard can greet Bob perfectly while displaying Alice's rows.
Streamlit documentation checked on 6 November 2026; version-specific caching options should be verified against the version you deploy.
I use modern AI tools to help make my technical writing easier to read.