Skip to content

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.

LayerWhat it holdsExample
CredentialThe encrypted material — an OAuth token pair, an API key, a Basic-auth pairA 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 callapp.request() resolves the binding from JWT or explicit grant_id; Alter decrypts the credential and the SDK injects itawait 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.

CredentialCan back which principals
OAuthUser, System, or Agent
Managed secretUser, 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.

A grant is owned by a principal — the who the grant belongs to. Four kinds:

PrincipalWhat it representsCreated by
UserAn end user authenticated via the app’s identity providerOAuth flow, or operator binds a managed secret to one user
GroupA set of users defined in the identity providerOperator binds a managed secret to an IDP group
SystemThe app itself — no human or agentOperator provisions a managed secret with no principal binding
AgentA named, operator-provisioned workload identityOperator binds a managed secret to an agent

Principal kind decides how the SDK resolves the grant at call time. See Identity resolution.

  1. Active — the grant is created with status = active; successful credential use updates last_used_at.
  2. Expired — a grant-level lifetime (TTL) has elapsed. OAuth token expiry is different: Alter refreshes the token while the grant remains active.
  3. 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.

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.

Three triggers, all producing the same grant model and a grant_id on completion:

TriggerBest forHow it’s reached
Embedded widgetSPAs and web appsAlter Connect — a popup or redirect launched from a button click
Server-side redirectServer-rendered apps, email linksapp.create_connect_session() returns a connect_url; the backend redirects the user there
HeadlessCLIs, scripts, one-time setupapp.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.

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=).

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.

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.

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.

  • 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.

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 toWho can use itTypical use
UserOne named user, resolved by their JWTA per-user Stripe API key
GroupMembers of an IDP groupA shared Datadog key for everyone in the support group
SystemThe app itself (no user in scope)A background-job AWS credential
AgentOne named agentAn 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.

Managed secrets ship with templates for common header shapes:

TypeWhat Alter injects
Bearer tokenAuthorization: Bearer <token>
API key (custom header)A configured header name, e.g. X-API-Key: <key>
Basic authAuthorization: Basic <base64(user:pass)>
AWS SigV4A 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.

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.

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.

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.

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 + label resolves a grant the same way grant_id does, 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.

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 duplicate label raises the typed SiblingLabelConflictError. Revoking a sibling frees its label.

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.

# 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.

A user connects GitHub once. The developer wants a research agent that can only read, and a publishing agent that can also write:

  1. The user’s original consent produces the connection and the default grant.
  2. Mint label="readonly" with allowed_methods: ["GET", "HEAD"], and label="publisher" with no method restriction but a 30-day TTL.
  3. Delegate readonly to the research agent and publisher to the publishing agent (each delegation is a one-click user consent).
  4. 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’s POST attempts are denied before they ever reach GitHub.
  5. Revoking the publisher sibling ends the publishing agent’s access; the research agent and the user’s own grant are untouched.

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. One click mints (or reuses) the labeled sibling and writes the delegation. If the user isn’t connected, the normal OAuth flow runs first and the same request applies afterwards.

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 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.

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 name; on Approve, Alter writes the grant and binds it to that agent.

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).

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:

  • The user sets it at consent time — app.create_connect_session(..., agent=<agent_id>, delegable=True) (or in the Wallet) — to allow the consented agent to pass the grant on.
  • When an agent re-delegates, it independently decides whether its child may delegate further, by setting delegable on the agent.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.

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 a managed secret’s operator ceiling forbids it — see below), the call raises GrantNotDelegableError; the remedy is to request a delegable grant, not to retry.

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.

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.

The agent process calls agent.proxy_request() against the provider. Delegated grants are proxy-only so their restrictions cannot be bypassed. The backend:

  1. Resolves the calling agent from the API key signature.
  2. Looks up the delegations visible to that agent.
  3. Picks the grant matching the provider (and optionally the user, if the agent bridged a user_token).
  4. 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).

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 GrantRevokedError if the connection itself was revoked).

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(), producing a delegation that lets the agent use the managed secret on the user’s behalf.

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.

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.

Report an issue with this page

Necessary

Required for sign-in, security, authorization, and remembering your choices.

Always active

Analytics

Helps us understand which product and documentation features are useful.

Performance diagnostics

Uses performance tracing and privacy-masked session replay to diagnose problems.

You can change these choices at any time from Cookie settings.