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. The operator issues a grant binding the credential to a principal (system, user, group, or agent).
  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_…, or legacy alter_key_…).
  • A managed-secret provider configured — this guide uses Datadog as the example.
  • The credential itself, generated at the provider (Datadog → API Keys → New Key).

In the developer portal:

  1. Open the app and go to Managed Secrets.
  2. Pick Datadog from the catalog (or Custom for anything not listed).
  3. Paste the API key into the Credential field.
  4. Click Store.

The plaintext is encrypted and sent to the vault. The portal will not display it again.

A stored credential is not usable until a grant binds it to a principal. From Managed Secrets → Datadog → Grants → New Grant:

  • System — the credential is callable by anyone holding the app key, with no user identity required. Use this for backend services and cron jobs.
  • 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 with stable-ID group keying — see the support table).
  • Agent — bind to a managed agent. Only that agent can reach the credential.

For a system-bound grant, the portal returns a grant_id. Copy it into application config.

Equivalent SDK call (operator path):

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="ms_datadog_abc",
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("ms_datadog_abc", {
principal: { type: "system", label: "prod-monitoring" },
});
console.log(result.grantId);
} finally {
await app.close();
}

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: … (per the Datadog template). Application code never holds it.

For a product where each user holds their own API key at a provider, bind a managed secret per user. The user uploads the credential through a UI flow; the backend stores it bound to the user’s identity. At call time, the SDK resolves the 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 secret by its slug — the unique identifier shown on the secret’s page (e.g. stripe-production):

response = await app.request(
HttpMethod.POST,
"https://api.stripe.com/v1/charges",
provider="stripe-production", # the secret'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 secret, so this is unambiguous. If a single user holds several secrets, 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.)

When the credential at the provider rotates:

  1. Generate the new credential at the provider.
  2. Open Managed Secrets → [Provider] → Update credential.
  3. Paste the new value and save.

Every existing grant_id keeps working — the credential is replaced in place, the grant identity is unchanged. No application restart, no env-var update.

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

For a “support team” Datadog key, bind the grant to an IDP group:

from alter_sdk.models import GroupPrincipal
await app.create_managed_secret_grant(
managed_secret_id="ms_datadog_abc",
principal=GroupPrincipal(
external_group_id="support",
idp_id="11111111-2222-3333-4444-555555555555", # the AppIdentityProvider id (UUID)
label="support-team",
),
)
await app.createManagedSecretGrant("ms_datadog_abc", {
principal: {
type: "group",
externalGroupId: "support",
idpId: "11111111-2222-3333-4444-555555555555", // the AppIdentityProvider id (UUID)
label: "support-team",
},
});

Any user whose JWT carries the support group claim 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.