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.
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) | 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 call | app.request() resolves the binding from JWT or explicit grant_id, decrypts the credential, 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 |
| Managed secret | User, 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.
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”- Created —
status = active. - Used — every successful
app.request()updateslast_used_at. - Expired — OAuth grants auto-refresh; managed-secret grants stay valid until rotated.
- 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.
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_id:
| 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, write the same grant_id, 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 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.
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.
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 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.
Connections
Section titled “Connections”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.
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 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.request(..., provider="github")— 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. 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.
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) 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.
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 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).
How it’s used at runtime
Section titled “How it’s used at runtime”The agent process calls agent.request() against the provider as usual. 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.
The agent’s first call after revocation raises NoDelegatedGrantError (or GrantRevokedError if the connection itself was 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(), producing a delegation that lets the agent use the managed secret on the user’s behalf.
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.