← mohdarif.com · All notes
Product Security · Authorization

When Authorization Goes Stale: Revocation, AI Answer Caches and Async Exports

A permission check is a snapshot. This post follows three bugs where the snapshot outlived the permission, and shows how to fix them and how to rate them.

This post is for developers and security engineers who build features that copy, cache or queue data: search indexes, AI answers, exports, background jobs. You do not need to have read the companion post, Reviewing a Google Drive Integration, though it sets up the product we use as an example: a multi-tenant SaaS app that syncs a customer's Google Drive and offers AI search over it. We take three bugs in turn. They look unrelated, and they share one cause. After the third, we look at how to decide how serious a bug like this really is.

1. The idea: a permission check is a snapshot

When code asks "may this user read this document?", the answer is true at that moment. Permissions change: someone is removed from a folder, an employee leaves, a share is revoked. Any copy of the data, any saved result and any queued job keeps working from the answer it had earlier.

Security people call the general problem TOCTOU, short for "time of check, time of use". You check a condition, then act on it later, and the condition changed in between. In this post the condition is always "is this user allowed?". The related term authorization drift means the permissions your system believes in slowly differ from the real ones.

Our three examples differ in where the old answer is stored:

2. Problem one: access removed in Google Drive, still searchable in the SaaS

The platform copies Google Drive documents every six hours and indexes them for AI search. Consider this timeline:

The engineering manager says: "The next sync is at 3:00 PM and will update the permissions. A few hours is fine, because we are not reading from Google Drive anymore." The root cause is that the platform holds a copy of the document and a copy of who may read it, and the copy of the permissions is out of date. The boundary that matters is between Google's current authorization state and the one the SaaS cached. "We synced it earlier" does not justify access now, because the permission Alice had at 9:00 AM was a decision about 9:00 AM.

Two different things changed, and only one of them is content

It is tempting to say "just re-sync more often" or "re-index the document". Look at what actually changed: the content of the document did not, but its access list did.

Document contentDocument permissions
What it isWhat the document saysWho may read it
Changed at 10:00 AM?NoYes, Alice was removed
Fixed by re-indexing the content?Not neededNo. Re-indexing does not change who may see it

The document also still exists and other people may still have access to it. So the answer is not "delete it from the index". The platform has to update who may retrieve the content, which is a separate piece of data from the content itself.

A design that handles it

Use three separate mechanisms:

  1. Detect changes. Google Drive offers a change feed (changes.list) and push notifications (changes.watch). Be careful with what notifications are. Google's documentation says they carry no content or metadata, only a signal that something changed, so you must call the API to find out what. Notification channels also expire (up to one week for changes) and must be recreated, so run a regular reconciliation too, in case a notification was missed.
  2. Store permissions separately from content. Keep a local copy of each document's access list. Update it from the change feed on its own schedule, without re-indexing content. When it changes, invalidate caches that depended on it.
  3. Enforce at retrieval time. Before any retrieved chunk goes to the AI model or back to the user, check that this user may currently read that document, using the local permission copy. For especially sensitive documents, require fresher evidence, or fail closed: if the platform cannot tell whether access is still allowed, it does not show the content.

Keep three timestamps in mind: when the content was last synced, when permissions were last verified, and whether any change is pending. They are different facts.

A complication that decides the design: can you see the permissions at all?

The platform connects with one Google account's credentials. To reason about other users' access, it needs the document's full access list, and Google does not always provide it. In the Drive API, a file's permissions list is only available if the requesting user can share the file, and it is not populated for items in shared drives. So a read-only integration account may not be able to see who else has access.

Mapping is another problem: Drive permissions are written in terms of Google accounts, groups and domains, while the SaaS has its own users. Matching them reliably means linking each SaaS user to a Google identity. This means "check Google permissions at retrieval" is a design with real work behind it. Options include connecting with each user's own account (so a check uses their own access), using an administrator-level integration that can read permissions, and indexing only documents whose permissions the integration can see. Whichever you choose, decide what happens when the answer is unknown. The safe default is to hide the document.

Choosing between the options

ApproachGoodLimit
Sync every 15 minutesSimplePermissions can be about 15 minutes stale, plus processing delay
Drive change notificationsMuch faster detectionNot an instant guarantee. Signals only, with expiry, so reconciliation is still needed
Check Google permissions on every searchFreshest answerAdds latency, uses API quota and makes search depend on Google being up
Local permission copy plus change feedFast search that scalesYou must keep the copy consistent and reconcile it
Fail closed when freshness is unknownNo access on uncertain dataSearch may be briefly unavailable

My usual choice is a local permission copy kept current with the change feed, enforced at retrieval time, with stricter freshness rules for sensitive documents. Whatever you pick, write down the maximum acceptable revocation delay and what happens when Google is unreachable. "How fast must a revoke take effect?" is a product question, and the technical design should answer it with a number.

Is it a release blocker? Not just because the sync interval is six hours. The questions that decide it: how confidential are the documents, can one user see content another user is no longer allowed to see, how many customers are affected, how long is the exposure, did customers expect revocation to propagate promptly, and what other controls exist. For an enterprise AI search product over confidential files, launching without retrieval-time authorization and a defined revocation mechanism is something I would push hard against.

3. Problem two: an answer cache that skips the check

The team now enforces authorization before sending chunks to the model. Then someone adds a cache to save money:

def answer_question(user, question):
    cache_key = hash(question)
    cached_answer = redis.get(cache_key)
    if cached_answer:
        return cached_answer

    chunks = vector_db.search(question)
    authorized_chunks = [c for c in chunks if can_access(user, c.document_id)]
    answer = llm.generate(question, authorized_chunks)
    redis.setex(cache_key, 3600, answer)
    return answer

The developer says: "We check authorization before generating answers, and the cache only lasts an hour, so this is secure." Look at the cache key: hash(question). It contains nothing about who asked.

1. Alice, Tenant A, asksabout annual revenueretrieval uses A's documentsanswer cached under hash(question)2. Bob, Tenant B, asksthe same questionhash(question) is the same key3. Cache hitBob receives Alice's answerno authorization check runs
Figure 1. Same question, different people, one cache entry. No revocation is needed for this to leak.

The authorization check only runs on a cache miss. On a hit, the saved answer is returned directly. An optimization silently removed the control. Notice that this needs no permission change at all: two users asking the same question is enough. Answers are not like public web pages. An answer built from Tenant A's confidential documents is only fit for people who can read those documents.

Why "add the tenant to the key" is not enough

Putting tenant_id in the key stops cross-tenant leaks, but two users in the same tenant can still have different document access. A manager's answer about salaries should not be served to someone in a different team. Here are the options:

Cache keyResult
Question onlyUnsafe for any answer that depends on permissions
Tenant and questionFixes cross-tenant leaks. Still unsafe when people in one tenant see different documents
User, tenant and questionGood isolation, but a cached answer can still outlive a revoked permission
Permission context plus source document versionsAllows safe reuse, but needs reliable invalidation
No caching for sensitive answersSimplest model, costs more per request

For a first safe version I would cache per user and tenant with a short lifetime, and not share entries between users until the design is tested. Store, with each cached answer, the list of source documents (and their versions) it was built from. On every cache hit, check that the user can still read all of those documents, and drop the entry when any permission or document changes. Checking only one document is not enough, because an answer can combine several.

A note on retrieval order. Fetching candidate chunks before checking authorization is not a leak by itself. What matters is whether unauthorized content ever reaches the model or the user. A sound design either searches only within an already-authorized set or filters the candidates before any content is used.

Tests: Alice asks twice (reuse is fine). Bob from another tenant asks the same question (must not get Alice's answer). Bob from the same tenant without access asks (must not get restricted content). Revoke Alice's access after caching (the cached answer must not bypass it). Change a source document (the old answer must be invalidated or versioned). Take the cache offline (the system must fall back safely, not skip checks).

4. Problem three: a job that outlives the check

Users can export confidential documents. Exports can be huge, so they run in the background:

@app.post("/api/exports")
def create_export(request):
    user = validate_jwt(request.headers["Authorization"])
    document_ids = request.json["document_ids"]
    for document_id in document_ids:
        if not can_access(user, document_id):
            raise Forbidden()
    job_id = queue.publish({"user_id": user.id, "document_ids": document_ids})
    return {"job_id": job_id}

def process_export(job):
    documents = database.get_documents(job["document_ids"])
    archive = create_zip(documents)
    url = upload_archive_to_s3(archive)
    notify_user(job["user_id"], url)

The developer says: "We verify authorization for every document before queueing, and the worker only processes approved IDs, so it is secure." Now picture Alice requesting an export of 100 confidential documents at 10:00 AM. At 10:01 her access to 20 of them is revoked. At 10:05 the worker runs.

10:0010:0110:05RequestAPI checks all 100 documentsAll allowed, job is queuedRevokeAccess to 20 documentsis removed. The queued jobstill lists all 100Worker runsReads all 100 documentsNo second checkZIP and a 24 hour URL
Figure 2. The check happens once, at the start. The data is packaged later, under a different set of permissions.

The root cause is the gap between authorization at request time and authorization at execution time. When the API checks Alice, the decision is about Alice. When the worker runs, it uses the worker's own broad database access, which says nothing about whether Alice may still read these documents. The trust boundary that changes is the one between the API and the worker.

"Check each file as the worker reads it" is better, not complete

A natural improvement is to check access right before each file is added to the ZIP. That shrinks the window, but does not close it:

The check is still a snapshot. So before designing a fix, decide which promise the product makes. Does revocation need to be respected at the time of the request, when each file is read, when the export is complete, or when the user downloads it? These are four different policies, and the engineering team has to choose one on purpose.

A layered design

  1. API check. Authenticate the user, validate the document list and authorize the export action.
  2. Worker re-check. Look up the trusted job record (not just the message), confirm the requester and the export are still valid, and fetch only the documents they may read. A database-level filter (such as row-level security scoped to the requester) can enforce this even if the worker code has a bug.
  3. Detect changes during the job. Record a permission version when authorization is checked. If a relevant permission changes before the job finishes, cancel it or rebuild it.
  4. Check again before publishing. Confirm the document set is still authorized and the permission versions are unchanged. If not, discard the archive.
  5. Control the download. Keep the ZIP private, and make access to it a separate authorized action.

Even a final check has a gap before the user actually downloads. If revocation must apply to every new download, check authorization again at download time. For very strict requirements the revoke and the download need to coordinate through locking or other transactional means. And once a file has been downloaded, nothing can recall it. Which of these you need is a risk decision, not a technical default.

The archive and its link are part of the design

The worker uploads the ZIP to S3 and tells the user. Questions to ask about that last step:

Also connect the lifecycle: when an employee is deprovisioned, cancel their pending jobs and invalidate unredeemed export links.

5. How serious is it? Rating severity with evidence

Suppose you report the export problem and the engineering manager disagrees:

"A user can only request an export for documents they are already allowed to see. The attack needs someone to revoke access while the job is running, which is an unlikely race. The ZIP is in a private S3 bucket. This is not high severity."

Your job is to turn that argument into facts. You investigate and find:

Theoretical race or practical attack?

The "unlikely race" argument assumes a window of milliseconds. Here the window is up to 30 minutes in the queue, followed by a 24-hour link. And the trigger is not an unusual coincidence: it is a normal business event. Someone who is leaving requests an export, and their access is removed as part of offboarding. No special skill is needed. That moves it from "a race we hope never happens" to "a scenario that matches how people actually leave companies".

The private bucket does not change this, because the link is the key. A presigned URL grants access to whoever holds it, regardless of the bucket's privacy.

What I would say about severity, and what I would not

It is tempting to answer "critical" at once. A more careful position is that the evidence so far shows a potentially high-impact authorization flaw: the worker lacks a second check, and the delivery link outlives permission changes. It does not yet prove every step needed for a revoked person to actually obtain the data. For example, a user who legitimately downloaded files before revocation is not exploiting this flaw. Whether the person can still get the link after losing access depends on how the link is delivered: to an external email address, through an app session that ends at offboarding, or with the job cancelled during deprovisioning.

So I would call it potentially High, and raise it to Critical once reproduced if it reliably exposes regulated or highly sensitive data to a former user. The evidence to gather, in a staging environment with synthetic documents:

  1. Sign in as Alice and request an export of confidential synthetic documents.
  2. While the job is queued, revoke Alice's permissions (and, as a second run, deactivate her account).
  3. Check whether the worker still builds the archive.
  4. Check whether Alice can obtain the download URL after losing access, and by which route (email, notification, API).
  5. Try the download using whatever access path a former user would still have, and record the result.

If all five steps work, you have strong evidence of a practical post-revocation disclosure.

The factors behind a rating

FactorQuestion to answerHere
ExploitabilityWho can do it, with what prerequisites, in what window?Any user with export rights, up to 30 minutes plus a 24 hour link
ImpactHow sensitive is the data, and how much?Financial and legal documents, personal data, thousands of files
ScopeOne user or many customers?Any customer using exports
RealismDoes it match a normal event?Yes, offboarding
DetectionWould you notice it?Likely not, since it looks like a normal export
Compensating controlsWhat else limits it?Not established yet. Check.
Fix costHow hard is a real fix?Moderate: re-check, versioning, controlled download

Containment if the full fix cannot ship

Disabling exports is a valid answer, but a good reviewer offers narrower choices as well, so product leaders can pick the least disruptive safe one:

MitigationWhat it reducesCost
Disable exports temporarilyEverything, if every export path is coveredThe feature is gone
Cancel queued jobs when access is revoked or the user is deprovisionedMany stale jobsNeeds reliable revocation events
Re-check at job start and again before deliveryStale authorization at both endsEngineering work
Stop emailing direct presigned URLs. Use an authenticated, short-lived downloadThe 24 hour windowSome infrastructure and UX work
Exclude the most sensitive document classes from exportWorst-case impactReduced export features

For this data I would require a mitigation that prevents the problem before launch, not only monitoring after the fact. If no narrow control can be built and verified in time, disabling the export is justified.

How I would put it to product leadership: "The export workflow relies on an authorization check done up to 30 minutes before the data is packaged, and the delivery link is valid for 24 hours. A user who is losing access could request an export while allowed, then keep the result after being removed. I am treating this as potentially high severity and will confirm by reproducing the full flow. Because the documents are financial and legal, I need a preventive control before launch. The best fix is a re-check at execution and delivery, cancelling stale jobs, and authenticated downloads. If we cannot do that safely in time, I recommend turning exports off until we can."

6. One checklist for all three

Summary

References