OAuth, scopes and trust for AI tools
When a model acts on someone's account, identity decisions become product decisions. Least privilege, token handling, account linking, logging and consent, from systems that handle real subscriptions.
An AI tool sits between three parties: a person, a model acting for them, and a service that holds their data. OAuth answers one question well: did this person authorize this client? It doesn't say what the model may do with that authorization, which account a token belongs to when one person has two, or what you may write down along the way. Those gaps are where I've seen trust break.
Least privilege lives in the tool surface
A scope string is a promise, and the tool list is where you keep it.
I map scopes to tools, not to the API. Read tools sit under read scopes, and each write tool has its own scope. A write scope is requested the first time a write is actually needed, not at connect time. A reviewer should be able to read the consent screen and predict the tool list.
Then remove every parameter that would let a model exceed its grant. The tools module behind DeepChamp's assistant opens with the line "UID and document paths are server-owned." (DeepChamp is the sports research and analytics app I build and run.) The user id comes from the verified token, never from an argument, and the same goes for the clock, the timezone and the context version. A model can't ask for someone else's data when there's no field to put their id in.
The safest permission check is a tool the model can't see. Before sign-in, our voice assistant runs a public preview, and that preview's tool list contains only public tools. Profile reads, saved items and preference writes aren't denied there. They simply aren't present.
Writes get two more layers. A write tool only creates a confirmation card, and the server saves after the user confirms that exact card. Editable fields are an enum, and no tool can write entitlement or financial fields. The database enforces the same line: server-owned fields such as entitlements, verified links and consent flags reject client writes, pinned by a rules test suite with over a thousand cases.
Tokens: short-lived, server-side, never in text
I hold these rules without exception:
- Tokens live on the server. They never appear in client config, in a prompt or in a tool result.
- Logs never contain them, even indirectly. An exception message can include a URL with a key in its query string, so our webhook forwarder logs the exception class and nothing more.
- Check presence, not values. Our credential status helpers return
presentormissing, which means a health page can't leak a key. - Prefer no long-lived keys. Our email-sending role in one cloud trusts the workload identity from another, so there's no access key to leak. Internal service calls use short-lived identity tokens bound to a single audience.
- Put the token fetch inside the budget. A deadline that starts after the token is minted doesn't actually bound the call.
- Make rotation a script. Ours adds a secret version, rolls every service that references it and prints the revoke step. Deploy scripts pass secrets by reference, so a deploy can't put plaintext back.
For a tool that connects through OAuth, that means the authorization code flow with PKCE, short-lived access tokens, rotated refresh tokens stored encrypted, one audience per token, and revocation on disconnect.
Identity linking: when accounts collide
The hardest trust bugs I've fixed didn't involve attackers. They involved one honest person with two accounts.
Apple lets an app attach an opaque UUID, the appAccountToken, to a purchase, and then signs it into every later notification about that subscription. We derive it deterministically from the signed-in user:
def canonical_token(uid: str) -> str:
return str(uuid5(TOKEN_NAMESPACE, uid)).lower()Now picture someone who subscribes on one account, later signs in to a second account on the same phone, and buys again. Apple treats both purchases as one subscription lineage. Our stored history tied it to the first account; Apple's signed token on the new purchase named the second. (Vendor history sometimes filed payments under a login-less alias, too.) When the signed token and stored history disagreed, the webhook quarantined the event. That's the right instinct for ambiguity, but here it left a paying customer without access.
The fix was to rank evidence by who could have produced it. Only the app, signed in as that user, can set their canonical token, and Apple signs it. Unsigned history filed by a vendor is weaker evidence.
if token_maps_to_exactly_one_user and token == canonical_token(user):
resolve(user) # signed canonical token outranks stored history
elif token_is_ambiguous or history_disagrees:
quarantine(event) # a person decidesQuarantined events go to an operator tool that is deliberately narrow:
- It runs as a dry run by default.
- It grants access only when all of these hold: Apple's verified current transaction carries the user's canonical token, that token maps to exactly one user, and the subscription is active or in grace and not revoked.
- It writes through the same code path as the webhook, and its reports name users by hash prefix.
It also refuses a case that looks obvious to a human: a token that maps to neither of two accounts, even when both clearly belong to the same person. Choosing one account is a judgment call, so a person makes it. Nothing merges automatically.
These lessons carry straight over to account linking for any AI tool:
- Bind the link at authorization time to a value derived from the authenticated subject, the way OAuth binds
stateto a session. - Store the provider's stable subject rather than an email address.
- Treat any field the client can write as a claim, not a link.
That last rule came from a messaging channel. The system matched each inbound sender to an account using profile fields the client itself could write, so any account could claim an address it didn't own. Now a sender links to an account only through verified evidence:
- a single-use, expiring code issued inside the signed-in app and redeemed in a transaction,
- a verified checkout,
- a one-time passcode, or
- a verified sign-in email.
Anything else is stored as a claim, and the sender keeps a guest identity scoped to that channel. Code redemption fails closed, is rate-limited per conversation, and never moves a number off another account.
Privacy: log decisions, not content
Logs are where good intentions leak, so ours record states and decisions, not content:
- A link attempt logs its status (
already_linked,expired,replayedand so on) and nothing else. - A provider error is logged as a bounded code plus truncated error text, never with request state or credentials.
- A voice session has one trace id and hashed turn ids, so we can follow a turn across services without storing the transcript.
- Operator reports identify users by hash prefix.
Minimize what reaches models, too. Our model-scoring scripts carry a standing rule: no emails, names or user ids in the state they send. Where a gateway supports zero data retention and no training on prompts, we ask for both.
Keep what you need to prove what happened, and nothing more. For signed webhooks we keep the original signed payload and its hash, which later let us replay missed notifications through the full verification path.
Consent is versioned data
Consent isn't a boolean. When we started using a summary of chat activity for a new purpose, that became a new consent version. Each request carries the version the user actually agreed to, and servers reject versions they don't recognize. The old version stays valid for what it covered, so nobody had to be asked again. The feature that relies on the new purpose runs only for users on the new version, and everyone else falls back to rules.
Consent also has UX states, and none of them should look like an error:
- Needing consent is a pending state. Once the user allows sharing, re-run the exact request they made.
- Nothing starts before the grant: no microphone, no provider session, no quota spend.
- A deferred start stays fenced to the account that was signed in when the user asked.
- Declining leaves the feature idle, with a way to try again.
Consent flags are server-owned. A client can't forge or clear marketing consent, and each grant is recorded with its evidence.
For a connected AI tool, all of this starts at the OAuth consent screen. It should describe in plain words what each tool reads and writes, and its scopes should map one-to-one onto the tool list.
Trust is the sum of small refusals: the tool the model can't see, the field it can't write, the link it won't make without proof, and the log line that leaves out the message.