OAuth flow security
How the OAuth dance is brokered, where tokens live, how refresh works, and what happens when a user revokes.
For providers that authenticate via OAuth — Notion, Google Drive, the Composio-managed catalog — Vendo brokers the authorization flow on the tenant's behalf and stores the resulting credential so your tool can use it via the proxy or the credentials worker. This page covers the boundaries of that flow.
The flow
A tenant initiates an OAuth connection from the dashboard. The dashboard opens a popup to one of two paths:
- First-party providers (e.g. Notion): the popup goes to
vendo.run/api/oauth/<provider>/authorize, which builds the upstream authorize URL with Vendo's client id, a one-timestate, and the callback URLvendo.run/api/oauth/<provider>/callback. The user signs in at the provider; the provider redirects back to Vendo's callback with acode; Vendo exchanges the code for tokens server-side and writes aconnectionsrow. - Composio-managed providers: the popup goes to
vendo.run/api/oauth/composio/<provider>/start, which calls Composio'sconnectedAccounts.initiate()and redirects to Composio's hosted OAuth page. On return, Composio holds the credential; Vendo writes aconnectionsrow with the Composio account id but no encrypted credential of its own.
In both cases, the popup runs in vendo.run's origin, not in your deployment's origin. Your tool never sees the OAuth code, the state, or the redirect URL — it can't be tricked into intercepting them by, say, opening a same-origin frame, because the dashboard is on a different domain.
Callback URL handling
Authorize URLs only accept callback URLs under vendo.run. The OAuth client registrations at each provider list a fixed set of vendo.run/api/oauth/<provider>/callback entries; the provider rejects anything else. There is no path by which a tenant or a deployment can register a callback that points at attacker-controlled infrastructure.
The first-party OAuth state is an HMAC-SHA256-signed cookie keyed by INTEGRATION_ENCRYPTION_KEY, scoped to the callback path and bound to the provider, with a 10-minute TTL. Mismatched provider, mismatched state nonce, or an expired/missing cookie all abort the callback before any token exchange.
For Vendo's own session minting (proxy session JWTs, used when a tenant opens an iframe-embedded preview), the redirect parameter is allow-listed: the destination must be HTTPS and its hostname must equal vendo.run or end with .vendo.run. Open-redirect attempts return an error before any token is issued.
Token storage and decryption
For first-party OAuth (oauth_app_install profile), the access token is stored in connections.encrypted_credential, AES-256-GCM encrypted at rest. The schema also carries an encrypted_refresh_token column for providers whose access tokens expire and need rotating; today's only first-party provider is Notion, which issues non-expiring access tokens and no refresh token, so that column is left null. Decryption happens in the credentials worker at the moment a request needs the cleartext — the worker decrypts in memory, returns a short-lived response to the calling SDK, and does not persist the cleartext anywhere.
For Composio-managed providers, Vendo holds no token. The credentials worker calls Composio's REST API with Composio's account id, receives a freshly refreshed access token, and caches it per Cloudflare colo until ~60 seconds before expiry. The refresh token never reaches Vendo.
Token rotation and refresh
How refresh works depends on the profile:
- Composio-managed connections — Composio holds the refresh token and runs the rotation. The credentials worker fetches a fresh access token from Composio's REST API when needed, caches it per Cloudflare colo, and evicts the cache entry 60 seconds before the upstream
expires_at. Your tool never sees the refresh token; it never reaches Vendo. oauth_app_installconnections — today this means Notion only, and Notion access tokens don't expire, so there is no live refresh path: the credentials worker decrypts the stored access token and returns it as-is withexpires_at: null. When a future first-party provider with expiring tokens lands, Vendo will refresh server-side, re-encrypt the new tokens, and serve the new access token through the same call. We'll document the refresh semantics for that provider when it ships.
Your tool calls vendo.token("notion") (or hits credentials.vendo.run/<provider> with its vendo_sk_* bearer) and receives { access_token, expires_at, token_type }. You do not implement refresh. For refresh-bearing providers (Composio today), don't cache the access token past expires_at; for Notion's non-expiring tokens, expires_at is null and you can use the response until the next call (the credentials worker is the cache).
What happens on revocation
A tenant revokes a connection from the dashboard (DELETE /api/connections/[id]). In order:
- Upstream revocation is attempted best-effort, and only for providers whose upstream supports it from our side. For Composio-managed connections, Vendo calls Composio's
connectedAccounts.delete. For Telegram (abyok_staticprovider), Vendo clears the bot's webhook so the upstream stops delivering updates. For Notion (oauth_app_install), there is no current upstream revocation call — the tenant retains the option of revoking Vendo's integration directly from Notion's UI. Failure of the upstream step does not block the rest — the connection is still removed from Vendo's side. - Bindings are deleted. Every
app_connection_bindingsrow referencing the connection is dropped. From this moment, any proxy call from a deployment that was using this connection returnsbinding_missing(HTTP 403). - The connection row is marked
revokedand the encrypted credential columns are cleared. - The proxy KV cache is invalidated. Cached connection lookups have a 60-second TTL, so any worker that had the old value will refresh within the window. Bindings are not cached on the proxy hot path, so the 403 from step 2 takes effect on the next request.
After revocation, a deployment that tries to use the now-revoked integration receives a clear 403, not a stale credential. There is no path by which the deployment continues to consume the connection.
Connections that need re-authentication
If a provider expires a token and Vendo's refresh attempt fails (refresh token revoked upstream, scope changed, provider outage), the connection transitions to needs_reauth. Proxy calls then return 401 with that status. The dashboard surfaces a "Reconnect" prompt for the tenant. The deployment's tool code does not have to implement any recovery — it sees 401s until the tenant reconnects, at which point traffic resumes without redeploying.
What your tool can and cannot do
- It can request a fresh token via the SDK or the credentials worker and use it to call the upstream provider for the duration of the token's validity.
- It can observe the
connection_statusfield returned with bindings (e.g. viavendo.workspace.activeBindings()) and degrade gracefully when a binding isneeds_reauthorrevoked. - It cannot read the refresh token. It cannot trigger an OAuth flow from inside the deployment — only the dashboard can initiate one. It cannot rebind a connection it doesn't own.
If your tool needs a scope you can't request via the dashboard's existing OAuth client, the provider has to be added at the platform level — opening it up to all tenants. That's a Vendo-side change, not a tenant-side one.