Skip to content

Guides

Provision Secrets for Backend Services

Store API keys and service tokens in Alter instead of environment variables, scoped to principals.

By the end of this guide, a backend service calls a third-party API using an operator-provisioned credential — a Datadog API key, a Stripe secret key, an AWS access key — without that credential ever appearing in the application’s environment, config files, or logs.

The flow:

  1. The operator stores the credential once in the developer portal.
  2. Storing it creates a system-bound grant; the operator issues further grants only to bind the credential to another principal (user, group, or agent). One provider can hold several credentials, each with its own grants.
  3. The backend calls app.request() with the resulting grant_id. Alter injects the credential into the outgoing call.
  • An app with a runtime API key (alter_rk_…). The call in step 3 needs the tokens:retrieve and audit:emit scopes; the optional SDK grant call in step 2 also needs secrets:write. The portal’s Backend service key template covers step 3. An app key in the older alter_key_… format was created before the September 2026 key-security upgrade and no longer authenticates — mint a new one.
  • A managed-secret provider configured — this guide uses Datadog as the example.
  • The credential itself, generated at the provider. For Datadog, create an API key and, for API operations that require user-level authorization, an application key.

In the developer portal:

  1. Open the app, go to Managed Secrets, and click Add Provider.
  2. On Add Managed Secret Provider, pick Datadog from the catalog (or Custom for anything not listed — see Connect a custom API).
  3. Paste the API key into the API Key field.
  4. If the intended Datadog operations require an application key, paste it into Application Key. Alter stores and injects it separately.
  5. Click Next, review the access policy, and click Create Provider.

The plaintext is encrypted and sent to the vault. The portal will not display it again. The stored key becomes the provider’s first credential, labelled Default unless another label is given. A provider can also be created with no key yet and have credentials added afterwards.

A credential is usable only through a grant that binds it to a principal. Saving the provider already issued one: a system grant, callable by anyone holding the app key with no user identity required. That is the grant a backend service or cron job uses, so for this walkthrough there is nothing to create. Its grant_id is listed under the provider’s Grants, or from the CLI:

Terminal window
alter managed-secrets grants list <provider> --output json

<provider> is the provider’s slug or its id. Either is accepted by the alter managed-secrets commands that take a provider argument, by --secret on alter policy commands, and by secret:<provider> references and bindings.json targets in policy files. alter grants list --provider takes the slug only, and alter audit --provider takes the template id (for a custom provider, its slug). The provider’s page in the dashboard shows both, as Slug and Provider ID, each with a copy button.

Issue an additional grant only to bind the credential to another principal. From Managed Secrets → Datadog → Grants → New Grant:

  • System — another system grant, for example one per service so each can be revoked on its own.
  • User — bind to one named user (resolved from their JWT). Useful when each user gets a per-user Datadog key.
  • Group — bind to an IDP group. All current and future members of the group can use it. Only available when the identity provider supports group grants (Clerk, or Okta/WorkOS with stable-ID group keying — see the support table) and its webhook integration is enabled. (Auth0 group grants are not currently supported.)
  • Agent — bind to a managed agent. Only that agent can reach the credential.

Agent-bound grants are created through the developer portal or the Alter CLI. The SDK’s create_managed_secret_grant / createManagedSecretGrant methods create user-, group-, or system-bound grants; an agent principal is rejected.

Equivalent SDK call for an additional system grant (operator path; the key needs secrets:write):

import asyncio, os
from alter_sdk import App
from alter_sdk.models import SystemPrincipal
async def main():
async with App(api_key=os.environ["ALTER_API_KEY"]) as app:
result = await app.create_managed_secret_grant(
managed_secret_id="11111111-2222-3333-4444-555555555555",
principal=SystemPrincipal(label="prod-monitoring"),
)
print(result.grant_id)
asyncio.run(main())
import { App } from "@alter-ai/alter-sdk";
const app = new App({ apiKey: process.env.ALTER_API_KEY! });
try {
const result = await app.createManagedSecretGrant("11111111-2222-3333-4444-555555555555", {
principal: { type: "system", label: "prod-monitoring" },
});
console.log(result.grantId);
} finally {
await app.close();
}

Export the system grant’s ID from step 2:

Terminal window
export DATADOG_GRANT_ID="<grant id from step 2>"

The application calls request() against the provider, passing the grant_id:

import asyncio, os
from alter_sdk import App, HttpMethod
async def main():
async with App(api_key=os.environ["ALTER_API_KEY"]) as app:
response = await app.request(
HttpMethod.POST,
"https://api.datadoghq.com/api/v1/events",
grant_id=os.environ["DATADOG_GRANT_ID"],
json={"title": "Deployment", "text": "Service deployed to prod"},
)
print(response.status_code, response.json())
asyncio.run(main())
import { App, HttpMethod } from "@alter-ai/alter-sdk";
const app = new App({ apiKey: process.env.ALTER_API_KEY! });
try {
const response = await app.request(
HttpMethod.POST,
"https://api.datadoghq.com/api/v1/events",
{
grantId: process.env.DATADOG_GRANT_ID!,
json: { title: "Deployment", text: "Service deployed to prod" },
},
);
console.log(response.status, await response.json());
} finally {
await app.close();
}

The Datadog API key is injected as DD-API-KEY: …; when configured, the application key is injected as DD-APPLICATION-KEY: …. The public SDK response never exposes either value. Because request() uses retrieve mode, the SDK briefly holds and injects the credential inside the application process. Use proxy_request() when the credential must remain outside that process.

For a product where each user has a distinct API key at a provider, there are two ways to get each key in. The operator can add one credential per user to a single provider and issue each credential a user-bound grant for its user (or create a separate provider per user). Or each end user stores their own key through a managed-secret Connect session: the hosted Connect page asks the user for the key and creates their grant on the provider — see End users’ own keys. Either way, the end user can consent to delegate their grant to an agent. At call time, the SDK resolves the user-bound grant from the user’s JWT exactly as it would for an OAuth grant — see Call APIs on behalf of users.

Instead of tracking a grant_id per user, you can resolve the current user’s grant by the provider’s slug — the unique identifier shown as Slug on the provider’s page (e.g. stripe-production), not the template id (stripe):

response = await app.request(
HttpMethod.POST,
"https://api.stripe.com/v1/charges",
provider="stripe-production", # the provider's slug, unique per app
user_token=current_user_jwt, # the end-user's identity token
json={"amount": 1000, "currency": "usd"},
)
const response = await app.request(HttpMethod.POST, "https://api.stripe.com/v1/charges", {
provider: "stripe-production",
userToken: currentUserJwt,
json: { amount: 1000, currency: "usd" },
});

The slug resolves to exactly one provider, and the user’s JWT to that user’s grant on it. Credentials are always reached through a grant: by grant_id, or by the provider’s slug plus the user’s token (provider= + user_token=), which resolves one grant. Several matching grants return HTTP 409 ambiguous_grant and raise AmbiguousGrantError, whose candidates list the matching grants; narrow the call with label, account or grant_id. If a single user holds grants on several providers, enumerate them with list_grants() (scope to the user with end_user_token=…) — each item carries its managed_secret_slug and grant_id — then call by the specific grant_id. (Filtering list_grants(provider_id="stripe-production") narrows to that one slug, so it doesn’t enumerate the others.)

One provider can hold several credentials — a production and a staging key, or one key per customer account — under one slug, one injection configuration and one host allowlist. Add one from the provider’s Credentials section in the developer portal, or with the CLI:

Terminal window
alter managed-secrets credentials add <provider> --label staging
alter managed-secrets credentials list <provider>

An added credential issues no grant. Bind it explicitly: in the dashboard, pick the credential when creating the grant; from the CLI, alter managed-secrets grants create <provider> --credential-id <credential-id> …; from the SDK, pass credential_id (credentialId in TypeScript) to create_managed_secret_grant. Once a provider holds more than one credential, a grant request that names none is refused with credential_id_required (HTTP 409), whose details list each credential’s id and label.

Deleting a credential (alter managed-secrets credentials delete, which requires --confirm <label>) revokes every grant it serves and destroys the stored key; the provider’s other credentials and their grants are untouched.

When the credential at the provider rotates:

  1. Generate the new credential at the provider.
  2. Open Managed Secrets → [Provider], choose the credential and select Rotate (or run alter managed-secrets credentials rotate).
  3. Paste the new value and save.

Every existing grant_id served by that credential keeps working — the key is replaced in place, the grant identity is unchanged. No application restart, no env-var update. Grants served by the provider’s other credentials are not affected.

AWS managed-secret providers carry extra structure because Alter computes SigV4 signatures per request. See AWS provider reference.

For a “support team” Datadog key, bind the grant to an IDP group. The external_group_id is the IDP-side stable group identifier — a Clerk or WorkOS organization ID (org_…) or an Okta group ID (00g…):

from alter_sdk.models import GroupPrincipal
await app.create_managed_secret_grant(
managed_secret_id="11111111-2222-3333-4444-555555555555",
principal=GroupPrincipal(
external_group_id="00gabc123def456", # the IDP's stable group id (e.g. Okta 00g…)
idp_id="11111111-2222-3333-4444-555555555555", # the AppIdentityProvider id (UUID)
label="support-team",
),
)
await app.createManagedSecretGrant("11111111-2222-3333-4444-555555555555", {
principal: {
type: "group",
externalGroupId: "00gabc123def456", // the IDP's stable group id (e.g. Okta 00g…)
idpId: "11111111-2222-3333-4444-555555555555", // the AppIdentityProvider id (UUID)
label: "support-team",
},
});

Any user whose JWT group claim carries that group ID can now resolve this grant under JWT identity.

ErrorLikely causeFix
GrantNotFoundErrorgrant_id is wrong or the grant was revoked.Re-issue the grant from the portal.
ProviderAPIError (401)The credential at the provider was revoked or expired.Generate a new credential at the provider, update the stored value.
ProviderAPIError (403)The credential lacks permission for the route.Check the credential’s permission scope at the provider.
Application can read the credentialMisconfiguration.Managed secrets are write-only; if anything in the application has plaintext access, the storage path is wrong.

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.