Identity & credentials
The CredentialProvider port — one method. A tool declares what it needs; the framework resolves it before the tool runs and hands it over as ctx.credential. Machine vs user is decided per REQUEST, consent pauses by default, and a vended token never enters the trace.
Outbound auth: get a token so a tool can call a downstream service — GitHub, Slack, an internal API — on behalf of the agent or the end user.
This is distinct from Governance & policy. Governance answers is this tool allowed to run. Identity answers get me a token to call X. They are different ports, deliberately, and a deployment usually needs both.
The port
One property and one method:
interface CredentialProvider {
readonly id: string;
getCredential(req: CredentialRequest): Promise<CredentialResult>;
}The request — CredentialRequest is { service, scopes?, mode?, identity?, forceReauth? }.
service is the downstream service id the provider understands ('github').
The result is a two-arm union, and the second arm is the whole reason this port is not just "return a string":
| Arm | Shape | Meaning |
|---|---|---|
'issued' | { status, credential, expiresAt? } | Here is a credential. The happy path — 2-legged, or 3-legged with a live refresh token |
'authorization-required' | { status, authorizationUrl, sessionId } | A person has to click a link before this can proceed |
Narrow it with isCredentialIssued(result).
The credential carries its kind and a universal applicator, so nothing
downstream ever switches on the kind:
interface Credential {
readonly kind: string; // 'bearer' | 'apiKey' | 'basic' | 'headers' | <yours>
toHeaders(): Record<string, string>; // the way to USE it
}Four kinds ship as factories on agentfootprint/security — bearer(token),
apiKey(key, headerName?), basic(user, pass), headers(map). A custom kind is
any object implementing the protocol; no library change is needed. Secret-bearing
fields are defined non-enumerable, so an accidental JSON.stringify emits
{"kind":"bearer"} and never the secret.
A vended token is a secret, and the port is built around that
Use it locally — headers: cred.toHeaders() inside a tool's execute. Never
write it to tracked scope. Tracked writes flow to the commit log, recorders and
observability exporters, which would put the token in the trace. A Credential
carries a function, so structuredClone rejects it outright and it cannot
enter tracked scope by accident. Implementers have a matching contract: a thrown
error's message reaches the model and the agentfootprint.credential.failed
event, so a provider must never echo a token or an Authorization header into
one.
Declare-and-push: a tool never fetches its own token
A tool declares what it needs; the framework resolves it before invoking the
tool and injects the result as ctx.credential.
import { defineTool } from 'agentfootprint';
const createIssue = defineTool({
schema: { name: 'create_issue', description: 'File a GitHub issue', parameters: { /* … */ } },
needs: { credential: 'github', scopes: ['repo'], mode: 'user' },
async execute(args, ctx) {
const res = await fetch('https://api.github.com/repos/o/r/issues', {
method: 'POST',
headers: { ...ctx.credential!.toHeaders(), 'content-type': 'application/json' },
body: JSON.stringify(args),
});
return res.json();
},
});CredentialNeed is { credential, scopes?, mode? }. mode omitted means
'machine' — declare mode: 'user' explicitly for delegated access, or you
will silently get a machine token.
When no provider is attached, ctx.credentials is not undefined — it is
unconfiguredCredentialProvider, which throws loudly on every call. That is
deliberate: there is no silent optional-chaining bypass, and a tool that needs a
credential without one configured fails loud, not open. A tool that
genuinely supports a degraded mode branches on ctx.hasCredentials.
Axis 1 — mode: machine or user, decided per request
Mirrors OAuth's two flows (and AgentCore's M2M vs USER_FEDERATION):
mode | Flow | Returns | Choose it when |
|---|---|---|---|
'machine' (default) | 2-legged, client-credentials | A token directly | The agent acts as itself — an internal service, a shared integration |
'user' | 3-legged, user-delegated | A token, or authorization-required | The agent acts on behalf of a person, and the audit trail must say whose access was used |
This is a property of the request, not of the provider. One provider serves both, and one agent can hold tools that declare each — which is the point: a read-only lookup can run as the service while the write runs as the user.
Axis 2 — consent: pause, or tell the model
When a 'user' request comes back authorization-required, someone has to click
a link. AgentOptions.onAuthorizationRequired decides what the run does about it:
| Mode | What happens | Choose it when |
|---|---|---|
'pause' (default) | The run stops at the block. agent.run() returns a pause outcome, a host answers with { awaiting }, and agent.resume(checkpoint) re-resolves the credential and runs the tool that was waiting. The model is never told; nothing is fabricated. | Almost always — the work is not lost, and the answer arrives on human time |
'tell-model' | The model reads a bracketed refusal and may route around the block. The turn still cannot report a clean completion: it raises CredentialConsentRequiredError. | The agent has other useful work it can genuinely do without that service |
Setting onAuthorizationRequired without a credentials provider is refused at
construction — a dial with no switch behind it.
The URL is a bearer capability, not a message
The consent URL carries a session-correlating state parameter: whoever holds
it can complete the flow. So it goes to the caller and to nowhere else — on
PendingAsk.pauseData under 'pause', on CredentialConsentRequiredError under
'tell-model'.
It rides pauseData under a named key rather than a free-form shape, for one
reason: agentfootprint.pause.request mirrors the whole of pauseData into its
payload, so the emitter has to be able to find the consent block and withhold the
URL from the event stream. A shape that could not be bounded by name is how the
URL leaked into every observer before 8.6.0 — along with the conversation
history, stream.tool_end, agent.iteration_end, the commit log, the snapshot
and any recording, because it used to be interpolated into the tool-result string.
What the model reads instead names the service, says a person is handling it, and says what to do next — the same shape as the permission refusal it already knows how to read:
[authorization required: 'github' — a person must consent before this tool can
run. The caller has been told; you cannot do it yourself. Continue with work that
does not need 'github', or say plainly that you could not finish this step.]The model is the one party that cannot act on it.
Axis 3 — transient failure: retry the transport, not the human
withCredentialRetry(provider, options) is the credential twin of
withRetry for LLM providers — same option vocabulary (maxAttempts,
initialDelayMs, backoffFactor, maxDelayMs, shouldRetry, onRetry) and
the same default transience policy: skip AbortError and 4xx except 429; retry
5xx, network errors and unknown shapes.
import { agentCoreIdentity, withCredentialRetry } from 'agentfootprint/security';
const credentials = withCredentialRetry(agentCoreIdentity({ region: 'us-east-1' }), {
maxAttempts: 3,
onRetry: (err, attempt, ms) => console.warn(`credential retry ${attempt} in ${ms}ms`, err),
});Only thrown errors retry. Both result branches return immediately: issued
is success, and authorization-required is a human flow — retrying it would
hammer the IdP without anybody having authorized anything.
The adapters
| Adapter | Door | Peer dep | What it does | Status |
|---|---|---|---|---|
staticTokens(creds, opts?) | agentfootprint/security | none | A service → credential map. A plain string is treated as a bearer token. Always 2-legged; throws for an unknown service | Shipped — dev/test |
agentCoreIdentity(opts) | agentfootprint/security | @aws-sdk/client-bedrock-agentcore | AWS Bedrock AgentCore Identity — workload token vault + OAuth2 | Shipped; contract-mapped and injection-tested. The JWT exchange (9.12.0) is contract-shaped and tested against the installed SDK's own request/response shapes; awaiting field use |
completeAgentCoreAuthorization(opts) | agentfootprint/security | @aws-sdk/client-bedrock-agentcore | Closes an AgentCore consent round-trip from your own callback route (see below). Takes CompleteAgentCoreAuthorizationOptions | 9.66.0 — command name and request shape verified against SDK 3.1118.0 |
entraIdentity(opts) | agentfootprint/security | @azure/identity | Microsoft Entra ID — access tokens for Azure APIs, from whatever credential the environment already has (the DefaultAzureCredential chain). Vends the deployment's identity: mode: 'user' and a per-request userToken are refused by name | 9.74.0 — contract-shaped and injection-tested; awaiting field use |
vaultCredentials(opts) | agentfootprint/security | none — plain HTTP | HashiCorp-Vault-compatible KV v2, read over Vault's own API. V1 is token auth, no leases; every other auth method is refused by name | 9.8.0 — contract-shaped and tested; awaiting field use |
unconfiguredCredentialProvider | agentfootprint/security | none | The fail-closed default when nothing is attached | Shipped |
withCredentialRetry(inner, opts?) | agentfootprint/security | none | Decorator — retries transient getCredential failures | Shipped |
Three ways AgentCore vends, and where consent ends
A mode: 'machine' request gets the agent's own token. A mode: 'user' request
takes one of two routes, chosen by agentCoreIdentity({ userFlow }):
'consent'(default) — the person approves once at a screen, and the grant is remembered until it expires.'exchange'— the login the person already has is traded for a scoped downstream token, with no screen at any point. It works only where the credential provider was configured for that exchange, and it is a deliberate choice rather than a nicer default: the consent screen is the thing that asks the person, and this flow is your deployment saying it does not need to.
Services whose credential is an API key rather than an OAuth token are named
in apiKeyServices and come back as an apiKey credential — AgentCore keeps
both kinds in one vault behind two different operations, and the provider name
alone does not say which, so there is no auto-detection to rely on.
When a consent screen is shown, the round-trip finishes in your web app.
The first vend answers authorization-required with a URL and a session id; the
person approves in their browser; AgentCore redirects them to a callback route
you own; that route confirms whose browser it is and calls:
import { completeAgentCoreAuthorization } from 'agentfootprint/security';
await completeAgentCoreAuthorization({ sessionId, userId: user.id, region: 'us-east-1' });Nothing comes back — success is an acknowledgement. The token now exists in the
vault, and the way to get it is to run the agent's request again: the vend that
answered authorization-required answers issued. In agentfootprint terms the
consent is a pause and this is what makes the resume succeed. Pass exactly
one of userToken (the person's own JWT, preferred) or userId; both, or
neither, is refused before any call, because the field it maps to accepts one.
// dev
import { staticTokens, apiKey } from 'agentfootprint/security';
const credentials = staticTokens({ github: 'ghp_dev_xxx', internal: apiKey('k', 'x-internal-key') });
// prod — the tool code does not change
import { agentCoreIdentity } from 'agentfootprint/security';
const credentials = agentCoreIdentity({
region: 'us-west-2',
workloadName: 'workflow_assistant_agent',
userIdFor: ({ principal }) => principal, // per-(workload, user) token vault
});
Agent.create({ provider, model, credentials }).build();Acting for a real person: the JWT exchange (9.12.0)
An agent that authenticated somebody has two things it could hand downstream, and they are not the same thing:
- an assertion —
identity.principal, a string this process wrote down.GetWorkloadAccessTokenForUserIdtakes the agent's word for it. - a proof — the token that person's identity provider signed.
GetWorkloadAccessTokenForJWTexchanges it for, in the service's own words, "an opaque token representing the identity of both the workload and the user".
Until 9.12.0 only the first was reachable. Now the second is, and it changes
nothing else: the exchanged token flows into the same
GetResourceOauth2Token call, out through the same Credential, and onto a
downstream request through the same toHeaders().
const credentials = agentCoreIdentity({
region: 'us-west-2',
workloadName: 'workflow_assistant_agent', // the exchange needs this
requireUserToken: true, // optional: refuse a delegated call without one
});
// Inside a tool — the JWT rides the REQUEST, because the person is per request.
const result = await ctx.credentials.getCredential({
service: 'google',
mode: 'user',
userToken: callersJwt, // what the IdP signed, as it arrived at your door
});The whole flow, end to end:
flowchart LR
U["User<br/>signs in at your IdP"] -->|"JWT"| D["Your front door<br/>(runtime / gateway)"]
D -->|"the JWT, plus<br/>X-Amzn-…-Runtime-User-Id"| A["Agent<br/>(a tool's execute)"]
A -->|"getCredential({ userToken })"| I["AgentCore Identity"]
I -->|"GetWorkloadAccessTokenForJWT"| W["workload + user token"]
W -->|"GetResourceOauth2Token"| C["Credential"]
C -->|"toHeaders()"| R["Downstream resource<br/>(Google, GitHub, …)"]Read it as one sentence: a user acts through an agent, which authenticates as a workload on that user's behalf, to reach a resource. Every arrow is a different party, and the vault entry at the end belongs to the person rather than to the agent — which is what makes revoking their access actually revoke it.
The mode rides the request, not the provider. One agentCoreIdentity serves
every caller; a JWT in its construction options would be one user's live session
serving all of them. So presence of userToken is what selects the exchange, and
requireUserToken is the opt-in that turns its absence on a mode: 'user'
call from a quiet fallback into a refusal — for a deployment where the front door
really does authenticate everybody. mode: 'machine' is never affected.
Where the JWT may travel, and where it may not
The framework does not thread it for you. ctx.credentials fills in the
service, the scopes and the identity — never the token — because the only routes
from your door to a tool are tracked scope and the run input, and both flow to
the commit log, the recorders and every observability exporter. A tool that needs
it captures it at the door in its own closure, as above.
Neither the inbound JWT nor the exchanged token appears in anything this adapter
throws. A failed exchange is described by the response's shape — how many
fields came back and what they are called — and an SDK failure keeps its
exception name and HTTP status and loses its text, because AWS clients echo
request detail into failure messages and a getCredential message is read by the
model, emitted on agentfootprint.credential.failed, and kept by every sink
attached to it.
agentCoreIdentity — ops coverage
Three of AgentCore Identity's six data-plane operations, named honestly:
| Need | Dispatched command | Notes |
|---|---|---|
| User-delegated workload token, from a proof | GetWorkloadAccessTokenForJWTCommand | 9.12.0. { workloadName, userToken } → { workloadAccessToken }. Engaged by req.userToken; wins over the by-userId path when both are present |
| User-delegated workload token, from an assertion | GetWorkloadAccessTokenForUserIdCommand | Keyed by userIdFor({ principal }) |
| OAuth2 / API-key credential | GetResourceOauth2TokenCommand | Requires a workload identity token first; its absence is refused by name before the call |
| not covered | GetWorkloadAccessToken | The workload's own token, with no user — mode: 'machine' reaches the vault through the static workloadIdentityToken instead |
| not covered | GetResourceApiKey | API-key credential providers. Vend those with staticTokens or vaultCredentials for now |
| not covered | CompleteResourceTokenAuth | Confirms a 3LO session server-side. This adapter surfaces the consent URL and lets the caller complete it |
expiresAt is absent on issued credentials from this adapter, and that is
honest rather than missing: GetResourceOauth2TokenResponse has no expiry field,
so there is nothing to report.
The bug this adapter is the worked example of (9.4.0)
Until 9.4.0 it built a BedrockAgentCoreClient and then called
client.getResourceOauth2Token(...) — a method that is never there, because a
bare @aws-sdk/client-* client is command-based (send / destroy only).
The documented path failed 100% of the time on the very first call, with a
message blaming the SDK version. Every AWS adapter now pins the SDK command
constructors it dispatches in test/adapters/aws/awsCommandPin.ts, and a
completeness test fails the build for any src/** file that loads an
@aws-sdk/* package without a row. See AWS & Bedrock AgentCore.
Provisioning the identity resources (CreateWorkloadIdentity,
CreateOauth2CredentialProvider) is control-plane work — the AWS SDK or CDK, not
this library.
entraIdentity() — Entra tokens for an Azure API (9.74.0)
import { entraIdentity } from 'agentfootprint/security';
const agent = Agent.create({ provider, credentials: entraIdentity() })
.tool(defineTool({
name: 'ask_foundry',
needs: [{ credential: 'azure-ai' }],
execute: async (args, ctx) =>
fetch(url, { headers: ctx.credential!.toHeaders() }).then((r) => r.text()),
}))
.build();The narrow adapter, and Google's sibling in every way that matters: it vends
Entra access tokens for Azure APIs, from whatever credential the environment
already has. That is the DefaultAzureCredential chain, in its own order —
environment service principal, workload identity, managed identity, VS Code,
Azure CLI, Azure PowerShell, Azure Developer CLI. On a developer's machine that
means az login; on a deployed container it means the managed identity, with no
configuration in between. @azure/identity is an optional peer, required the
first time this provider vends, so nothing else in the library pays for it.
EntraIdentityOptions is three fields — scopes (default [AZURE_AI_SCOPE]; a
request's own non-empty scopes win), services and id — plus the internal
_credential / _sdk test seams (the latter typed as AzureIdentitySdkModule),
which are how the suite runs with no package and no Azure account, and which the
refusals name so a stuck reader has a way out. A bring-your-own TokenCredential for inference does not go here:
pass it straight to the provider, as foundry({ credential }) or
azureOpenai({ credential }) — see Microsoft Foundry.
One token, one audience — and they are not interchangeable
Azure mints a token for exactly one audience. AZURE_AI_SCOPE
(https://ai.azure.com/.default) is the data plane — every Foundry and Azure
OpenAI inference call takes it, and it is this provider's default, because vending
inference credentials is what an agent runtime does all day.
AZURE_MANAGEMENT_SCOPE (https://management.azure.com/.default) is the ARM
control plane — listing deployments, creating resources. A third constant,
AZURE_COGNITIVE_SERVICES_SCOPE
(https://cognitiveservices.azure.com/.default), names the classic Azure OpenAI
data-plane audience — azureOpenai({ credential }) defaults to it because that
is the audience the deployment-scoped route documents.
A token for one is a 401 on the other, and no retry fixes it. Both are exported by name rather than left as strings to remember:
import { AZURE_MANAGEMENT_SCOPE, entraIdentity } from 'agentfootprint/security';
const credentials = entraIdentity({
scopes: [AZURE_MANAGEMENT_SCOPE], // ARM, not inference
services: ['azure-management'], // and nothing else
});mode: 'user' is refused, not served with a machine token. Entra's
on-behalf-of flow needs a confidential client app registration this adapter does
not hold, so a delegated request is refused by name. Answering it with a
machine token would be the exact silent downgrade the port exists to prevent: the
call succeeds, the data comes back, and it ran with the agent's access rather
than the person's, with nothing downstream able to tell. A userToken on the
request is refused for the same reason rather than quietly dropped — there is
nothing here to exchange it against, and ignoring somebody's proof while vending
agent access is that downgrade in a quieter costume. OBO is a later train, and
when it lands it will be its own provider rather than a flag on this one.
Set services when Azure is not your only credential. Unset — the default —
this provider answers for any service, because the token is an Entra credential
and you know which Azure API you are about to call. In a deployment where some
tools declare needs: [{ credential: 'github' }], an unrestricted Entra provider
would hand an Entra token to the tool that wanted a GitHub one, and the failure
would surface as a puzzling 401 from GitHub instead of as a wiring error here.
It caches the credential, never a token. MSAL — the machinery inside
@azure/identity — caches and proactively refreshes tokens itself, so caching one
here would mean owning an expiry this adapter did not compute and cannot see
revoked. One credential is constructed for the life of the provider and serves
every scope set, because Azure scopes travel per getToken call. expiresAt is
reported when the SDK gives one and omitted when it does not — an invented expiry
is worse than none, because a caller would cache against it.
Secrets never appear in what it throws, on the same law as every other
credential-touching adapter here: the operation and the error's NAME come
through, its text does not, and the original is never attached as cause. The
two no-credential names get the fix instead of the diagnosis — "run az login,
or set the client/tenant/secret variables, or run where a managed identity
exists".
The caller's identity, and where else it shows up (9.11.0)
run({ identity }) is the inbound half of this page — not a token to call
something with, but the tuple that says who the run is for. It already scoped
three things: memory namespaces, PermissionRequest.identity, and
ctx.identity inside a tool.
Since 9.11.0 it scopes a fourth: every event of the run carries
meta.principal and meta.tenant, which is what turns the typed stream into
an audit log — who → what → when, on one wire.
await agent.run(message, {
identity: { tenant: 'acme', principal: 'alice@acme.test', conversationId },
});One rule governs all four consumers, and it is the same rule the credential half runs on:
Only an identity the caller NAMED. The run's internal identity is always populated — it defaults to
{ conversationId: '<runId>' }, or since 9.10.0 to{ conversationId: sessionId }on a session-bound run — and none of those four consumers is ever handed one of those. An anonymous run stamps nothing, and nothing is invented to fill the gap.
A session id is not a principal
sessionId is caller data: anyone who can reach the host can send any string,
including somebody else's. It is carried on the meta as itself, beside runId,
because that is the fact the transport delivered. Promoting it to "who did this"
would produce an audit trail that looks complete and names the wrong party.
Where it lands per sink — and which sink does not carry it — is on Observability sinks.
Making the caller's identity something you PROVED (9.26.0)
Everything above scopes on an identity somebody named. At a hosted door the
naming can now be checked: standingAgent({ identity: { verify } }) verifies
the request's Authorization: Bearer … before the run's identity is composed,
so the principal those four consumers see is one a token proved rather than
one a header claimed. jwksIdentity({ jwksUrl, issuer, audience }) from
agentfootprint/security is the shipped verifier — one adapter for cloud IdPs
and on-prem ones alike, because JWKS is the same protocol in both — and any
IdentityVerifier works in its place.
The refusal law, the failure vocabulary that never contains the token, and what
roles/claims do and deliberately do not reach are all on
Hosting & runtime → Verified identity.
Watching credentials fail
agentfootprint.credential.requested / .acquired / .failed bracket every
resolution. Subscribe to the group:
agent.on('agentfootprint.credential.failed', (e) => {
console.error(e.payload.service, e.payload.tool, e.payload.errorClass);
});Those events had payloads, registry entries and live emit sites for eight minors
with no dispatcher bridge and no domain wildcard, so agent.on(...) observed
nothing — the silence in which an identity adapter failed 100% of its calls.
Fixed in 9.4.0: a credentialRecorder is attached on every run, and
CredentialFailedPayload gained tool and errorClass. Defence in depth for
the payloads themselves: footprintjs
RedactionPolicy.emitPatterns: [/credential\.failed/].
Status
| Piece | Status |
|---|---|
CredentialProvider port, Credential protocol, four kinds | Shipped |
Declare-and-push (needs → ctx.credential), fail-closed default | Shipped |
mode: 'machine' | 'user' per request | Shipped |
onAuthorizationRequired: 'pause' | 'tell-model' + URL withholding | Shipped |
staticTokens, withCredentialRetry | Shipped |
agentCoreIdentity | Shipped; contract-mapped and injection-tested, command names pinned |
entraIdentity (DefaultAzureCredential chain, data-plane scope by default) | 9.74.0 — contract-shaped and injection-tested; awaiting field use. mode: 'user' and userToken refused by name until an on-behalf-of surface exists |
vaultCredentials (token auth, KV v2, no leases) | 9.8.0 — contract-shaped and tested; awaiting field use |
run({ identity }) → EventMeta.principal / .tenant | Shipped (9.11.0) — explicit identity only, never session-derived |
Next
- Governance & policy — the other half: who may call what
- Observability sinks — the actor on every event, and which sinks carry it
- Check in with the receipts — evidence-carrying human consent
- Tools & gateways —
gatewayTransportvends these per request - Security — the three surfaces, end to end
- AWS & Bedrock AgentCore — the worked provider
- Microsoft Foundry — the other side of the same Entra credential:
foundry({ credential })signs inference calls with it - On-premises & self-hosted —
vaultCredentialsin full: the V1 boundary, the field→kind table, and what its errors will never say
Sessions in a file — survive a restart with nothing to install
sqliteSessions({ file }) is the SessionLifecycle port backed by Node's built-in node:sqlite. Conversations and runs paused waiting on a person live in one table, so a restart, a crash or a deploy does not lose them. Zero dependencies, one machine, one file — and a loud refusal instead of a silent fallback.
Governance & policy
Four places a tool call can be stopped — visibility, the in-process permission gate, the middleware chain, and a gateway upstream where this library has nothing to attach. What enforces what, the exact refusal each one hands the model, and why agentCorePolicy was retired.
