Concepts
Credentials
Stored credentials, bindings to principals, and the grants those bindings produce.
A credential is stored once in the vault; bindings determine who can use it; each binding is a grant. The same primitive backs OAuth tokens an end user authorized, managed secrets an operator provisioned, and delegations a user extended to an agent.
A grant is identified by a UUID (grant_id), refers to (but does not contain) the encrypted credential, and is revocable independently of the underlying credential. Every credential-use call passes through a grant — Alter resolves it and decrypts the credential, then either the SDK or Alter’s proxy injects the credential on the outgoing request.
The unified model
Section titled “The unified model”| Layer | What it holds | Example |
|---|---|---|
| Credential | The encrypted material — an OAuth token pair, an API key, a Basic-auth pair | A Slack refresh+access token; a Stripe sk_live_… |
| Binding (grant) | An authorization tying that credential to a principal (user / group / system / agent), with a grant_id | ”This Stripe key is bound to Alice” — one binding, one grant_id |
| SDK call | app.request() resolves the binding from JWT or explicit grant_id; Alter decrypts the credential and the SDK injects it | await app.request("GET", url, provider="stripe") |
The credential type (OAuth vs managed secret) and the principal type (user / group / system / agent) are independent axes. A user-principal grant can be backed by either an OAuth credential or a managed secret; the call site is the same either way.
| Credential | Can back which principals |
|---|---|
| OAuth | User, System, or Agent |
| Managed secret | User, Group, System, or Agent |
An OAuth grant is a user grant when a person consented, a system grant when the application connected an account with no end user attached, and an agent grant when a user’s grant was delegated onward to a named agent (an agent-typed OAuth grant is always a delegation child of a parent grant, never created directly). Group is the one combination OAuth does not support — group principals are managed-secret only.
A user-principal grant backed by a managed secret resolves under JWT identity the same way an OAuth grant does — the principal type drives resolution, not the credential type.
Principals
Section titled “Principals”A grant is owned by a principal — the who the grant belongs to. Four kinds:
| Principal | What it represents | Created by |
|---|---|---|
| User | An end user authenticated via the app’s identity provider | OAuth flow, or operator binds a managed secret to one user |
| Group | A set of users defined in the identity provider | Operator binds a managed secret to an IDP group |
| System | The app itself — no human or agent | Operator provisions a managed secret with no principal binding |
| Agent | A named, operator-provisioned workload identity | Operator binds a managed secret to an agent |
Principal kind decides how the SDK resolves the grant at call time. See Identity resolution.
Grant lifecycle
Section titled “Grant lifecycle”- Active — the grant is created with
status = active; successful credential use updateslast_used_at. - Expired — a grant-level lifetime (TTL) has elapsed. OAuth token expiry is different: Alter refreshes the token while the grant remains active.
- Revoked — an end user or operator explicitly revokes the grant, or revocation cascades from an ancestor grant or connection.
A revoked grant raises GrantRevokedError on the next call.
Provider scope mismatch is also separate from lifecycle status: it blocks access with a reauthorization signal until consent is repaired, without relabeling the grant as revoked.
OAuth grants
Section titled “OAuth grants”An OAuth grant is the result of an end user authorizing the app to call a provider on their behalf. Alter stores the resulting access and refresh tokens encrypted in the vault and returns a grant_id. Subsequent calls reach the provider via app.request(); the SDK looks up the grant, refreshes the token if it has expired, and injects the credential.
How a user creates an OAuth grant
Section titled “How a user creates an OAuth grant”Three triggers, all producing the same grant model and a grant_id on completion:
| Trigger | Best for | How it’s reached |
|---|---|---|
| Embedded widget | SPAs and web apps | Alter Connect — a popup or redirect launched from a button click |
| Server-side redirect | Server-rendered apps, email links | app.create_connect_session() returns a connect_url; the backend redirects the user there |
| Headless | CLIs, scripts, one-time setup | app.connect() opens a local browser and polls until complete |
All three end at the same OAuth flow, create the same kind of grant, and produce the same revocation surface in the Wallet.
Multi-account
Section titled “Multi-account”A single end user can hold many OAuth grants for the same provider — personal Gmail and work Gmail, two GitHub orgs, three Stripe accounts. Each is a separate grant with its own grant_id and account_identifier.
When the SDK resolves a grant via JWT identity and finds more than one match, it raises AmbiguousGrantError whose candidates carry each matching grant’s id, label, and account so application code can prompt the user to pick (or retry with account= / label=).
Automatic refresh
Section titled “Automatic refresh”OAuth access tokens expire. Alter refreshes them transparently:
- On each
app.request(), the backend checks token age before injecting. - If the access token has expired (or is within a refresh buffer), the backend uses the stored refresh token to get a new one.
- Concurrent requests for the same grant hold a single distributed lock so only one refresh runs at a time; the others wait for the result.
If the refresh fails permanently (the user revoked the app at the provider, the refresh token was invalidated), Alter raises CredentialRevokedError and the user must re-authorize.
End-user revocation
Section titled “End-user revocation”Every OAuth grant an app holds is visible to the end user in the Wallet dashboard. The user can revoke, see when the grant was last used, and review the activity reason for each call. Revocation is immediate; the next app.request() against a revoked grant raises GrantRevokedError.
Managed secrets
Section titled “Managed secrets”A managed secret is a credential that the operator stores in Alter instead of an environment variable or external secret manager. Same vault, same app.request() surface, no end-user OAuth flow.
Where an OAuth grant is a credential the end user authorizes for themselves, a managed secret is a credential the operator already holds — a Stripe API key, a Datadog token, an AWS access key. Alter stores it encrypted, binds it to a principal, and returns a grant_id that the SDK uses the same way it uses any other grant.
When to use a managed secret
Section titled “When to use a managed secret”- The credential already exists (generated at the provider’s console).
- The credential is per-service or per-tenant, not per-end-user.
- Centralized credential management is preferable to scattering API keys across environments.
For credentials that end users authorize on their own behalf (Gmail, Slack, GitHub), use OAuth grants instead.
Principal binding
Section titled “Principal binding”A managed secret is stored once and issued as one or more grants, each bound to a different principal. The bindings decide who can use the credential:
| Bound to | Who can use it | Typical use |
|---|---|---|
| User | One named user, resolved by their JWT | A per-user Stripe API key |
| Group | Members of an IDP group | A shared Datadog key for everyone in the support group |
| System | The app itself (no user in scope) | A background-job AWS credential |
| Agent | One named agent | An agent-owned Tavily API key |
The same credential can back many grants. Issuing a new grant against an existing secret is a metadata operation; the underlying credential stays put.
Credential types
Section titled “Credential types”Managed secrets ship with templates for common header shapes:
| Type | What Alter injects |
|---|---|
| Bearer token | Authorization: Bearer <token> |
| API key (custom header) | A configured header name, e.g. X-API-Key: <key> |
| Basic auth | Authorization: Basic <base64(user:pass)> |
| AWS SigV4 | A full AWS Signature Version 4 computed per request |
For providers not in the catalog, the Custom template supports any header name, any injection format, and multi-header or query-parameter injection — see Connect a custom API.
Rotation
Section titled “Rotation”When a credential at the provider rotates, update the stored value in the developer portal. Every existing grant_id for that secret keeps working — the credential is replaced in place; the grant identity is unchanged.
Write-only storage
Section titled “Write-only storage”Once stored, a managed secret can never be read back through a public portal or SDK method. Its only use is credential injection for an outgoing request authorized by a grant. In retrieve mode the SDK process briefly holds the credential while making that request; proxy mode keeps it on Alter’s backend. This eliminates “the operator emailed the API key” as a credential exposure path.
Connections
Section titled “Connections”A connection is the underlying stored credential. One connection can back many grants — for OAuth, one connection per (user, provider, account) holds the encrypted token material; the attached grants are the bindings that let principals exercise it.
This distinction matters most at revocation. Revoking a grant tears down one binding; the credential survives if other grants reference it. Revoking the underlying connection (the Wallet’s “Disconnect”) tears down every grant against it at once.
A user owns one connection per (provider, account) pair, and any number of grants against it — see the next section.
Multiple grants on one connection
Section titled “Multiple grants on one connection”A grant is credential + policy. Connecting an account once produces the connection and a first grant; additional sibling grants can then be minted on the same connection without another OAuth flow. Each sibling carries its own:
- Label — the sibling’s address.
provider + labelresolves a grant the same waygrant_iddoes, and the label is unique among the connection’s active grants. - Policy — TTL, human-in-the-loop approval, and restrictions (allowed HTTP methods and/or endpoint path patterns). A sibling can only tighten access relative to the connection — never widen it.
- Delegations — which agents may use it.
Revoking one sibling never touches the others; the stored credential is destroyed only when the last grant on the connection is revoked.
Minting a sibling
Section titled “Minting a sibling”readonly = await app.mint_grant( source_grant_id, label="readonly", grant_policy={"restrictions": {"allowed_methods": ["GET", "HEAD"]}},)The source grant is the proof of access: the new sibling shares its connection and principal. Minting is available to the developer (SDK) and to the end user (in the Wallet, via “New grant on this connection” — optionally delegating it to an agent in the same step). Agents cannot mint; they use the grants delegated to them. A sibling does not inherit policy rules attached to its source grant, so the SDK refuses to mint from a source grant that carries grant-level rules (PolicyViolationError, error mint_source_has_grant_rules).
A duplicate label raises the typed SiblingLabelConflictError. Revoking a sibling frees its label.
Restricted grants are proxy-only
Section titled “Restricted grants are proxy-only”A grant whose policy carries restrictions can only be exercised through Alter’s server-side execution path, where the method and endpoint of every call are checked before the provider is contacted. Raw-token retrieval against a restricted grant raises the typed RestrictedGrantRequiresProxyError — a token handed to the caller could not be held to the restriction. There is no opt-out.
Addressing a sibling
Section titled “Addressing a sibling”# By label — the human-stable selector:await app.request("GET", "https://api.github.com/user", provider="github", label="readonly")
# By grant_id — the production path, as always:await app.request("GET", "https://api.github.com/user", grant_id=readonly.grant_id)With several active siblings, provider alone is ambiguous by design: the call raises AmbiguousGrantError whose candidates list your own matching grants with their labels. Alter never guesses.
Walkthrough: one connection, two agents
Section titled “Walkthrough: one connection, two agents”A user connects GitHub once. The developer wants a research agent that can only read, and a publishing agent that can also write:
- The user’s original consent produces the connection and the default grant.
- Mint
label="readonly"withallowed_methods: ["GET", "HEAD"], andlabel="publisher"with no method restriction but a 30-day TTL. - Delegate
readonlyto the research agent andpublisherto the publishing agent (each delegation is a one-click user consent). - Each agent calls
agent.proxy_request(..., provider="github")— delegated grants are proxy-only, and inside each agent’s world there is exactly one GitHub grant, so resolution is unambiguous. The research agent’sPOSTattempts are denied before they ever reach GitHub. - Revoking the publisher sibling ends the publishing agent’s access; the research agent and the user’s own grant are untouched.
Requesting a sibling at consent time
Section titled “Requesting a sibling at consent time”When an agent needs access the user hasn’t delegated yet, the Connect session can carry the requested shape:
session = await app.create_connect_session( allowed_providers=["github"], user_token=user_token, agent=agent_id, requested_grant={ "label": "readonly", "restrictions": {"allowed_methods": ["GET", "HEAD"]}, },)If the user is already connected, the Connect popup skips the OAuth redirect entirely and shows a lightweight approval — the agent’s name, the connected account, and the human-readable access level. When the session delegates to an agent, the user first signs in through the application’s identity provider in that browser (see How a delegation is created); then one click mints (or reuses) the labeled sibling and writes the delegation. An identity provider that cannot sign users in makes the session skip this shortcut. If the user isn’t connected, the normal OAuth flow runs first and the same request applies afterwards.
Reconnecting never reshapes siblings
Section titled “Reconnecting never reshapes siblings”If a connection’s provider token dies (the user revoked the app at the provider, a password change invalidated sessions), one re-consent heals it: the stored tokens are refreshed in place and every sibling — labels, policies, delegations — is exactly as it was. A reconnect can never silently re-widen access that was deliberately narrowed.
Delegation
Section titled “Delegation”Delegation is a user authorizing a specific agent to use one of the user’s grants. It exists because two security models conflict otherwise: the user owns the credential, but an AI agent needs to call the provider on the user’s behalf — and the agent should never see the credential itself.
Without delegation, an agent that needed Stripe access on behalf of Alice would either (a) hold its own Stripe key (wrong principal, wrong audit trail) or (b) receive user context without an authorization edge (no isolation between the agent’s actions and Alice’s). With delegation, Alice explicitly grants the named agent permission to use her Stripe grant. The credential stays in the vault, the agent stays scoped, and audit events carry both identities.
How a delegation is created
Section titled “How a delegation is created”Delegation is consented to at the same moment the user creates the grant. In a Connect session, the operator passes agent=<agent_id>; the consent screen shows the user the agent’s name from its stored record (never a name the requesting application supplies); on Approve, Alter writes the grant and binds it to that agent.
Only the user can approve. The application or agent that requested the delegation holds the session token and usually the user’s token as well, so neither is proof that the user said yes. The approval must come from the user’s own browser, with proof that it is the user:
- Managed-secret Connect — the page asks the user to sign in through the application’s identity provider (a silent redirect when the user already has a session there), checks the signed-in account is the one the request is for, and binds the approval to that browser. This needs the identity provider’s sign-in client; without it, creating the session fails with
idp_signin_not_configured. - OAuth Connect with an agent — connecting an account at the provider is the proof: the user signs in at the provider’s own authorization screen. Only approving on an account the user already connected asks for the identity-provider sign-in above; without a sign-in client, the session sends the user through the provider’s authorization instead.
- Wallet — delegating from the Wallet needs proof of the user’s own Wallet sign-in; a request carrying only the user’s token is refused.
A failed sign-in never ends a Connect request: if the user signs in as someone else, nothing is granted and the page offers to sign in again, and the user can always cancel. See Approving Connect delegations for the identity-provider setup. The approval’s audit row records how the user proved it, when they signed in, and which application or agent asked. Onward delegations between agents need no new sign-in: the user’s delegable choice at the first hop authorizes them, within the operator’s ceiling.
A user can delegate a grant to many agents, and an agent can receive delegations from many users (the agent ends up able to act on behalf of each).
Delegation chains (multi-hop)
Section titled “Delegation chains (multi-hop)”The first delegation — user to agent — is one hop. An agent that holds a delegated grant can, in turn, delegate it onward to a second agent, forming a chain: user → agentA → agentB. Each link narrows: a child grant can drop scopes and shorten its lifetime relative to its parent, never widen them. The credential never enters an agent process at any hop; every agent in the chain calls the provider through Alter’s proxy, and the audit trail records which agent acted under whose authority at each link.
Onward delegation is opt-in at every hop, never automatic. A grant carries a delegable flag, chosen when the grant is created:
- For an OAuth connection requested through Connect, the application asks for it —
app.create_connect_session(..., agent=<agent_id>, delegable=True)— and the user decides on the consent screen, with the same May share this access with other agents checkbox and rules as for a managed secret (below). A session that asks always shows that screen, even when it setdirect=True, and the session reportsdirectasFalse. The checkbox appears only when the provider’s agent sharing setting allows onward sharing (see Operator ceilings), except that an existing delegation that can already be shared onward keeps it, turned on, when the setting no longer allows that, and the user can then only turn it off. The choice is the user’s alone, so the user signs in through the application’s identity provider before making it: the confirmation screen that follows a provider login shows Sign in to choose in place of the checkbox until the user signs in. Embedded Connect shows that confirmation screen in the window where the user signed in to the provider, so the sign-in and the checkbox are offered there too. Where a screen cannot run the sign-in (one shown inside the embedded Connect frame, such as the approval step for an account the user already connected, or an identity provider that cannot sign users in), it offers no checkbox: a new delegation is created as a leaf, and an existing one keeps its setting. An explicitdelegable=Falseis stored as given and shows no checkbox. In the Wallet, the user decides it when minting a grant for an agent. The result of the connection reports the stored setting onConnectResult.delegation(delegable), and the popup message and thereturn_urlcarry it too. - For a managed secret requested through Connect, the application asks for it —
delegable=Trueonapp.create_managed_secret_connect_session()oragent.create_managed_secret_connect_session()— and the user decides: the Connect page shows a May share this access with other agents (up to N more hops) checkbox and says next to it who asked for onward sharing: the application, or the agent itself when its own key asked. The checkbox starts from the user’s existing delegation to that agent when there is one; for a new delegation it starts on when the application’s key asked and off when the agent asked with its own key, so an agent never gets onward sharing for itself unless the user turns it on. The checkbox also appears, turned on, when the user’s existing delegation to that agent can already be shared onward. It appears only when the secret’s delegation policy allows onward delegation, except that an existing delegation that can already be shared onward keeps the checkbox, which the user can then only turn off. The page never grants more than the application asked for unless the existing delegation already had it. The approval tells the application the stored setting: the approvedpostMessageand thereturn_urlquery carrydelegable(trueorfalse) alongsideupdated.trueis the setting, not a promise: the secret’s delegation policy still decides every onward delegation, so a delegation that kepttrueafter the policy stopped allowing onward sharing cannot share onward until an administrator allows it again. - When an agent re-delegates, it independently decides whether its child may delegate further, by setting
delegableon theagent.delegate()call.
A grant created without delegable is a leaf: its holder may use it, but cannot pass it on. This makes a chain’s reach an explicit, per-link decision rather than something that propagates by default. Omit delegable to keep the existing delegation’s setting: renewing a delegation (re-consenting, re-approving or re-delegating to the same agent) without delegable never changes whether it can be shared onward, while an explicit False turns that off and later onward delegations from it are refused.
Onward delegation at runtime
Section titled “Onward delegation at runtime”An agent that holds a delegable grant re-delegates it with one call:
child = await agent.delegate( held_grant_id, to_agent_id, delegable=False, # the child is a leaf — it cannot re-delegate scope_constraint=["read"], # narrow to a subset of the parent's scopes (optional) ttl_seconds=3600, # shorten the lifetime (optional))The parent is the grant the calling agent already holds — Alter derives it from the caller’s own access, so an agent can only delegate what it actually has. A grant the agent does not hold is indistinguishable from one that does not exist. If the held grant is not delegable (or the credential’s operator ceiling forbids it — see Operator ceilings), the call raises GrantNotDelegableError; the remedy is to request a delegable grant, or ask an administrator to allow sharing, not to retry.
Delegation always starts with a person. A credential granted directly to an agent, with no consenting user, can be used by that agent but never delegated onward: no user would see that chain, revoke it from the Wallet, or end it by leaving the identity provider.
The returned DelegationResult carries the new child’s grant_id, its parent_grant_id, and its depth (distance from the credential root). A scope-narrowed or otherwise delegated grant is proxy-only — exercised through Alter’s server-side execution path, never handed back as a raw token, so the narrowing cannot be bypassed.
Reconstructing the tree
Section titled “Reconstructing the tree”Every grant in a list carries parent_grant_id (the grant it was minted under, or None/null for a root that was directly consented) and depth (its hop distance from the root). From the flat list of grants, an application reconstructs the full delegation tree — which agent delegated to which, and how deep each branch runs — without any separate call.
How it’s used at runtime
Section titled “How it’s used at runtime”The agent process calls agent.proxy_request() against the provider. Delegated grants are proxy-only so their restrictions cannot be bypassed. The backend:
- Resolves the calling agent from the API key signature.
- Looks up the delegations visible to that agent.
- Picks the grant matching the provider (and optionally the user, if the agent bridged a
user_token). - Injects the token from the user’s grant — never the agent’s own credentials, because the agent has none for this provider.
If the agent has delegations from multiple users on the same provider, the agent can disambiguate per-call via the user_token parameter (or by configuring user_token_getter at construction).
How it’s revoked
Section titled “How it’s revoked”Three revocation surfaces, each removing a different layer:
- User revokes the delegation (in the Wallet) — the agent loses access; the user’s underlying grant stays active.
- User revokes the underlying connection — both the user’s grant and every delegation against it are torn down.
- Operator revokes the delegation (
app.revoke_delegation(grant_id, agent_id)) — the same effect as the user revoking the delegation, performed admin-side.
Revocation cascades down the chain. Revoking a grant tears down not only its direct delegations but every onward hop minted beneath them — a child can never outlive the parent it was delegated from. Revoking the root (or the underlying connection) collapses the entire tree at once. There is no way for a deeper link to survive the loss of a link above it.
The first call by any affected agent after revocation raises NoDelegatedGrantError (or GrantDeletedError if the connection itself was revoked — an agent always receives the reason-neutral terminal, never the typed GrantRevokedError, so a delegated agent cannot probe whether its access was deliberately revoked).
Delegation on managed secrets
Section titled “Delegation on managed secrets”The same model applies to managed secrets bound to a user. The user can consent to delegate a managed-secret grant to an agent via app.create_managed_secret_connect_session() — or, when the agent asks for itself, agent.create_managed_secret_connect_session() — approving it on the Connect page after signing in, as above, or from the Wallet, producing a delegation that lets the agent use the managed secret on the user’s behalf. Connect and the Wallet apply one rule for which of the user’s grants may be delegated to the agent: the grant must be live, its secret’s delegation policy must allow the delegation (group grants, depth, the grant’s own expiry), and the agent’s capability allowlist must contain the secret’s stable, app-unique slug under managed_secret_slugs (not its display name or template ID), or, for an agent created before slugs, the secret’s display name under the legacy managed_secrets key. The Connect page lists only such grants and says why when there is none (a missing or expired grant is for the application’s administrator to issue or renew; the user cannot); a request made with a key bound to no managed agent (the application’s key, or an agent-typed alter_ak_… key from API Keys) for a user who has none is refused up front with no_eligible_grants, while a request from a managed agent’s own key is not (session creation never tells an agent what a user holds) and the page explains instead.
Managed secrets add an operator ceiling on top of per-grant consent. Because the operator — not the end user — owns the underlying credential, the operator decides whether grants under a secret may be delegated at all, and how deep any chain may run. Two controls, set on the secret’s delegation policy at create time or with alter managed-secrets set-delegation-policy:
delegable— whether grants under this secret may be marked delegable in the first place. Off by default; a secret that is not operator-delegable refuses every onward delegation regardless of what a user or agent requests.max_delegation_depth— the maximum chain depth permitted under the secret (capped at 8). A delegation that would exceed the ceiling is rejected. A delegation at the last depth the ceiling allows can never be shared further, so asking for it to be delegable (delegable=Trueonagent.delegate(), or onward sharing on the Connect page or in the Wallet) is refused withgrant_not_delegable; an explicitdelegable=Falseis never refused for that reason.
These bounds are a ceiling, not a default: a delegation only succeeds when both the per-grant delegable opt-in and the secret’s operator ceiling allow it. An onward delegation that the ceiling forbids raises GrantNotDelegableError, the same typed error a non-delegable grant produces.
Operator ceilings
Section titled “Operator ceilings”Both kinds of credential carry an operator ceiling on onward sharing, with the same two settings and the same rules:
- a managed secret’s delegation policy (above);
- an OAuth provider’s agent sharing setting, one per provider in each application, set on the provider’s page in the dashboard or with
alter providers set-delegation-policy. It turns onward sharing on or off for every OAuth grant of that provider in the application, and caps how deep a chain may run (1 to 8;1allows the user’s own delegation to an agent and nothing further). A provider configured after this setting was introduced starts with sharing off; providers that existed before it keep sharing on.
While a ceiling is off, the consent screens and the Wallet offer no onward-sharing choice, and every onward delegation of that credential is refused with GrantNotDelegableError, including a renewal of an existing delegation. Turning a ceiling off does not change what was stored: a delegation that kept delegable=True cannot share onward until an administrator allows sharing again, and the Wallet marks it No longer allowed to share this access with other agents. If the ceiling changes while a user is on the consent screen, the screen learns it when the user confirms: saving the choice is refused, the checkbox is redrawn as the setting now allows (hidden, or turned on and only able to be turned off), and the user confirms again. Only a change that lands between saving the choice and the confirmation itself reaches the end of the flow: the connection still completes with the delegation’s stored setting, the success page tells the user that sharing was not turned on, and the result’s delegation.policy_refused tells the application.
What’s next
Section titled “What’s next”- Identity — how the SDK picks a grant at call time, and what
AppvsAgentlook like at runtime. - Agents — the workload identity that receives delegations.
- Call APIs on behalf of users — end-to-end OAuth flow.
- Provision secrets for backend services — end-to-end managed-secret flow.
- Give an agent scoped access — delegation and agent-owned grants.
- Wallet dashboard — where end users see and revoke grants and delegations.