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 Alter call passes through a grant — the SDK looks it up, decrypts the credential, and injects it 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)A row tying that credential to a principal (user / group / system / agent), with a grant_id”This Stripe key is bound to Alice” — one row, one grant_id
SDK callapp.request() resolves the binding from JWT or explicit grant_id, decrypts the credential, 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
Managed secretUser, Group, System, or Agent

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. Createdstatus = active.
  2. Used — every successful app.request() updates last_used_at.
  3. Expired — OAuth grants auto-refresh; managed-secret grants stay valid until rotated.
  4. Revoked — by the end user (in the Wallet), by an operator (in the portal or via SDK), or by policy (TTL elapses, scope mismatch detected).

A revoked grant raises GrantRevokedError on the next call.

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_id:

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, write the same grant_id, 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 Anthropic 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 from the portal or the SDK. The only path to the plaintext is the outgoing HTTP request Alter makes on behalf of a grant. This eliminates “the operator emailed the API key” as a credential exposure path.

A connection is the underlying stored-credential row. One connection can back many grants — for OAuth, one credential row per (user, provider, account) holds the encrypted token material; the grants attached to it 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.request(..., provider="github") — 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) impersonate Alice somehow (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 the audit row carries 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 both the grant row and a delegation row binding that grant to that agent.

A user can delegate a grant to many agents (each with its own delegation row), and an agent can receive delegations from many users (the agent ends up able to act on behalf of each).

The agent process calls agent.request() against the provider as usual. 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.

The agent’s first call 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.