How to review a feature you have never seen: what to ask first, which findings matter most, and how to say what is proven and what is only possible.
This post is for security engineers and developers who are asked to review a feature they have never seen before. It follows one realistic review from the first question to the findings. The feature connects a customer's Google Drive to an AI search product. You do not need to know OAuth or vector databases in advance. Each term is explained when it first appears, and each finding builds on the one before it.
The review uses a method that matters as much as the findings: for every weakness, say what is proven and what is only possible. It is easy to jump from "a check is missing" to "an attacker can steal everything". Reviewers who do that lose credibility with the engineers they work with. We will practice the careful version throughout.
The product is a multi-tenant SaaS application. "SaaS" means customers use it over the internet instead of installing it. "Multi-tenant" means many customer organizations (tenants) share the same running system, so one customer's data must never reach another.
The new feature lets a customer connect their Google Drive. The platform copies the documents, splits them into pieces, indexes them, and lets users search and summarize them with AI. The engineering team describes it like this:
You will not be handed a list of threats. A reliable approach is to ask three kinds of questions before judging anything:
The answers in this case:
sub, tenant_id, role and an expiry. The backend checks the signature, issuer, audience and expiry. The tenant comes from the verified token, not from a query string. (The JWT post explains how this can go wrong. Here it is done correctly.)tenant_id column. There is no row-level security and no database per tenant.tenant_id as metadata, and the backend adds a filter for the caller's tenant on every search.tenant_id, user_id and connection_id. Workers have no user JWT.drive.readonly scope (read access to Drive files), and it indexes everything it can reach. Users cannot pick folders.POST /api/integrations/google-drive/sync with a connection_id to queue a sync.A common first reaction is: "AI search is based on meaning, so a user could just ask for another tenant's documents and get them." That sounds right and is not what the evidence shows.
results = vector_db.search(
query=user_question,
filter={"tenant_id": authenticated_user.tenant_id},
)
The filter is applied by the database, outside the model. It limits which chunks are even considered. A question that mentions another company does not change that filter, because the filter is not part of the question. The same is true of a prompt-injection instruction hidden in a document: text that the model reads cannot change a rule that the database enforces.
So the honest statement is: if the filter is applied everywhere and the metadata is correct, cross-tenant retrieval is not possible through the question alone. To claim a leak you need evidence of another weakness, such as:
tenant_id (for example, taken from a job the user could influence),For stronger isolation, some teams use a separate namespace or collection per tenant instead of one shared index with a filter. That moves the boundary from a parameter every query must remember to a structure the query cannot skip. It costs more to operate. The point for a reviewer is to check the filter on every path, not to assume it, and to treat the shared index as a place where a single missing parameter becomes a leak.
A rule that helps: never let the model be the thing that enforces access. The model only sees what the application hands it. Authorization has to happen before that.
This is the first place I would spend time, because it joins three risky facts: the user chooses an ID, workers are shared across tenants, and workers can use every stored refresh token. Here is the code the team shows:
@app.post("/api/integrations/google-drive/sync")
def trigger_sync(request):
user = validate_jwt(request.headers["Authorization"])
connection_id = request.json["connection_id"]
queue.publish({"connection_id": connection_id, "requested_by": user.id})
return {"status": "queued"}
def process_sync_job(job):
connection = database.get_connection(job["connection_id"])
refresh_token = decrypt(connection.refresh_token)
google_token = refresh_google_access_token(refresh_token)
documents = fetch_google_drive_documents(google_token)
index_documents(documents, tenant_id=connection.tenant_id)
The developer says: "The user is authenticated, and we always index with the tenant ID stored in the database. So this is secure."
What does the attacker control? Not the user ID: that comes from the verified token. They control connection_id, which comes from the request body. This is the same object-level authorization problem described in the access control post: the API checks that the caller is logged in, but never checks that this caller may use this connection.
Now trace what happens when a user from tenant A sends tenant B's connection ID:
This is the careful reading. Proven: tenant A can make the platform use tenant B's Google connection to run a sync. The effects can include extra Google API calls (and possible quota exhaustion or rate-limiting for tenant B), disrupted or unexpected syncs, and processing another customer's data without their authorization. Not proven: that A receives B's documents, because they are indexed with connection.tenant_id, which belongs to B. A confidentiality impact needs a second weakness, such as a retrieval path that ignores tenant, an exposed job result, or a bug in how tenant_id is set.
The way to say it in a review: "An attacker can trigger an unauthorized sync of another tenant's connection. Whether that becomes a data disclosure depends on where the results go and whether another access control weakness exposes them."
At the API: load the connection, then decide whether this user may sync it. Do not assume the rule is "the user owns it". In an enterprise product, several admins might manage one organization-wide connection, so the real rule comes from your integration policy: tenant membership, role, and any sharing rules. When the answer is no, return the same "not found" response you return for a connection that does not exist.
connection = database.get_connection(connection_id)
if connection is None or not can_sync_connection(user, connection):
raise NotFound()
queue.publish({"connection_id": connection.id, "requested_by": user.id})
At the worker: a queue message is input, not proof. Anyone or anything that can write to the queue can forge one. The worker should load the canonical connection record, confirm the job is genuine (for example by signing jobs or by looking the request up in a trusted table), check the connection is still active and the requester is still allowed, and only then decrypt the token. For scheduled syncs with no user, authorize against the connection's own sync policy.
Notice what the worker check protects. A request that the API rejects never reaches the queue. A forged or stale message that does reach the queue is stopped before any token is decrypted or any Google call is made. That order matters, because the sensitive operation is using the credential.
Even with correct checks, the design has a large blast radius. Every worker can decrypt every tenant's refresh token with one shared key, so one compromised worker exposes all of them. Encryption at rest protects a stolen disk. It does nothing against a compromised application, because the application holds the key. Ways to reduce this:
How to verify: create two tenants with separate test connections and synthetic documents. Then check that tenant A syncing its own connection works, tenant A submitting tenant B's ID is denied, a same-tenant user without integration rights is denied, an admin with rights is allowed, a connection revoked before the job runs makes the worker refuse, and a forged queue message naming another tenant's connection is rejected. For every denied case, also check that no token was decrypted, no Google call was made and nothing was indexed. A clean error response is not enough: the privileged action itself must not happen.
Next, look at how a customer connects Google in the first place. The app starts the flow like this:
GET https://accounts.google.com/o/oauth2/v2/auth
?client_id=OUR_CLIENT_ID
&redirect_uri=https://saas.example.com/oauth/callback
&response_type=code
&scope=https://www.googleapis.com/auth/drive.readonly
After the user approves, Google redirects the browser back to /oauth/callback?code=AUTHORIZATION_CODE. The backend swaps the authorization code (a one-time value) for tokens and stores the refresh token under the currently logged-in SaaS user. The manager says: "Google handles authentication and authorization, so our OAuth integration is secure."
Google handles the consent screen. It does not know which SaaS account started the flow. That link is the application's job, and here nothing protects it. There is no state parameter and no PKCE.
This is called account-linking CSRF (cross-site request forgery: making a logged-in user's browser perform an action they did not intend).
Required conditions: the code is valid and unused, the victim's session is present on the callback, and the app does not check that the callback belongs to a flow this user started. Note what this is and is not. It does not steal the victim's Google credentials. It makes the victim's account trust the attacker's data source, which can fill the victim's search with attacker-controlled documents or support a prompt-injection attempt. The actual impact depends on what the integration does after linking.
state and PKCE dostate is a random value your app creates, stores against the user's session, sends to Google, and checks when the callback arrives. If the value does not match the one stored for this session, the callback is rejected. It should be unpredictable, single-use and short-lived. Google's own documentation tells clients to prevent CSRF and suggests state, and RFC 9700 (the current OAuth security best practice) says clients must prevent CSRF, either with a one-time state value or by relying on PKCE after confirming the server supports it. Note that state does not validate redirect URIs and does not stop open redirects. Those need their own controls.
PKCE (pronounced "pixie") protects the code itself, so a stolen or injected code is useless to the attacker. It works like this, and the roles are easy to mix up:
BASE64URL(SHA256(verifier)). This is the S256 method.Memory aid: the verifier is the secret, and the challenge is derived from it. An attacker who sees only the challenge cannot work backwards to the verifier. (For a longer walkthrough with a live demo, see the PKCE post.) RFC 9700 requires PKCE for public clients and recommends it for all kinds of clients, including web applications. Using both state and PKCE is a sensible default.
Tests for this step: a callback with no state, with another session's state, and with a reused state must all be rejected. A wrong PKCE verifier must fail the token exchange. A replayed code must fail. An unregistered redirect URI must be refused by Google. Starting a flow as user A and finishing it in user B's session must not create a connection.
Suppose the team adds everything above: random single-use state, PKCE with S256, strict redirect URIs, one-time codes and encrypted tokens. There is one more business rule:
"An organization can connect multiple Google accounts. Any organization member can start the flow. Once connected, the new connection is available to the organization's shared sync system."
The developer says: "We have secured OAuth. No account-linking concerns remain." The OAuth flow is now sound. The problem is somewhere else.
OAuth answers one question: did this person let our app read this Google account? It does not answer: should an ordinary member be allowed to add an organization-wide data source? Those are separate decisions, and only the second one is the SaaS product's business.
Here is the attack, and it needs no protocol bug. An ordinary employee, or a malicious insider, connects a Google account of their choice and completes OAuth correctly. The platform syncs the account's documents and indexes them into the organization's shared search. Two kinds of harm follow:
The impact depends on how the platform shares indexed documents. It is not guaranteed by OAuth succeeding. But it is a product decision nobody made explicitly, which is why it is a review finding.
| Operation | What it should require |
|---|---|
| Connect a personal Google account | The user's consent, and permission to create a personal integration |
| Register an organization-wide data source | An explicit organizational decision: an administrator or designated integration manager approves it |
| Make documents searchable by other people | Document-level visibility rules, so sharing in the SaaS follows who may see each document |
A sensible design lets ordinary members connect personal sources that only they can search by default. Organization-wide sources need an approver. Every connection, approval, scope change and disconnect is written to an audit log.
A scope is the permission level an app asks for. drive.readonly lets the app read all the files the connected account can reach. Google classifies it as a restricted scope. For comparison, Google recommends drive.file for most use cases and classifies it as non-sensitive. It limits the app to files the user opens with the app or shares through the Google Picker, so the user chooses which files the app can see.
This feature indexes everything it can reach and gives users no way to choose. In a personal Drive, that includes tax papers, medical records and unrelated personal files. The user did click "Allow" on Google's consent screen, so this is not automatically a privacy violation. The question is whether what the app asks for, what it processes, how long it keeps it and what it discloses match what the user and the organization reasonably expect. Better designs:
drive.file) where the product allows it.Everything that comes out of Google Drive is content someone else wrote. Indirect prompt injection means hiding instructions in a document that the AI later reads as part of its context, hoping it obeys them. It helps to ask what the injected text can actually reach. Three outcomes are very different:
| What the document does | What it needs to succeed | Typical impact |
|---|---|---|
| Influences the answer | The chunk is retrieved and the model follows it | Misleading or manipulated summaries |
| Makes the model use a privileged tool | The assistant has tools (send, share, call APIs) with real permissions | Actions taken on the user's behalf |
| Reaches another tenant's data | A separate authorization failure in retrieval or tools | Cross-tenant disclosure (not caused by the document alone) |
The third row is the one people over-claim. A hidden instruction cannot by itself override a filter the database enforces, or choose which tenant a worker runs as. It only becomes dangerous when the surrounding system gives the model powers or data it should not have. The defense is to keep the model's inputs and powers small: retrieval limited to what the user may see, no tools that act on behalf of the user unless necessary, and confirmation for sensitive actions. The AI document platform threat model goes deeper on this chain.
tenant_id set from trusted data when chunks are written?state bound to the session, plus PKCE, strict redirect URIs and single-use codes?Two further topics deserve their own deep dive, because the bugs are subtle and the impact is high: permissions that change after content is synced, cached AI answers, and long-running jobs that outlive the authorization that started them. They are covered in the companion post, When Authorization Goes Stale.
state and PKCE to protect the OAuth flow, and remember that a correct flow still does not decide who may add an organization-wide data source.