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 |
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 |
// 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.
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 |
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
- 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.
