DEV Community

Anton Staykov
Anton Staykov

Posted on

How an AWS-hosted Entra Agent ID drops the client secret

After I published One identity, two clouds: an AWS-hosted agent that authorizes with Microsoft Entra Agent ID, most of the follow-up questions were not about the cross-cloud authorization model.

They were about the client secret.

Where should it be stored? How should it be rotated? Should it live in AWS Secrets Manager? What happens when it expires?

Those are sensible production questions. They were also questions about a demonstration compromise, not the architecture I wanted people to take away. The point of the sample was to show that an agent hosted in AWS can carry a governed Microsoft Entra Agent ID. The secret was plumbing used to keep the first proof of concept focused.

Release v2.0 of the sample removes that distraction. The agent now uses Microsoft Entra workload identity federation with AWS IAM Outbound Identity Federation. AWS attests to the workload with a short-lived JSON Web Token (JWT). Microsoft Entra accepts that JWT only when it matches an explicitly configured trust relationship.

There is no Entra client secret in the runtime path.

The interesting part is not that a secret moved somewhere safer. It is that the two clouds stopped sharing one.

The client secret was never the identity

An Agent ID blueprint needs to authenticate before it can acquire tokens for the agent identities it governs. Microsoft documents federated identity credentials, certificates, and client secrets as supported blueprint credential types, while the agent identity itself does not hold a credential of its own (Agent identities in Microsoft Entra Agent ID).

The first version authenticated the blueprint with a client secret. That proved the Agent ID token flow, but it also introduced a long-lived value with the usual lifecycle:

  1. Generate it in Microsoft Entra.
  2. transport it to AWS;
  3. protect it at rest and in deployment;
  4. expose it to the process that needs it;
  5. rotate it before expiry; and
  6. coordinate the old and new values without interrupting the agent.

A capable secret manager improves several of those steps. It does not remove them. Microsoft's workload identity federation guidance makes the recommendation explicit: configure Entra to trust tokens from the workload's external identity provider, then exchange those tokens for Microsoft identity platform access tokens.

For this sample, the external identity provider is not another directory bolted onto the design. It is AWS itself.

What version 2.0 changes

The core agent architecture remains the same. The agent still runs in Amazon Bedrock AgentCore Runtime, still has a Microsoft Entra Agent ID, and still obtains resource-specific tokens for downstream calls.

Only the blueprint authentication step changes:

  • Before, the AgentCore process presented an Entra client secret.
  • Now, the process asks AWS Security Token Service for a short-lived web identity token representing its IAM role.
  • Microsoft Entra validates that AWS token against a federated identity credential (FIC) configured on the Agent ID blueprint.
  • After that validation, the established Agent ID blueprint-to-agent token flow continues.

Microsoft now lists AWS workloads using IAM Outbound Identity Federation as a supported workload identity federation scenario (Workload identity federation concepts). AWS documents the corresponding outbound identity federation setup and the AWS Security Token Service (STS) GetWebIdentityToken API.

The flow looks like this:

sequenceDiagram
    participant Agent as AgentCore runtime
    participant STS as AWS STS
    participant Entra as Microsoft Entra token endpoint
    participant API as Downstream API

    Agent->>STS: GetWebIdentityToken using the runtime IAM role
    STS-->>Agent: Short-lived AWS-signed JWT
    Agent->>Entra: Blueprint token request with AWS JWT as client_assertion
    Entra->>Entra: Match issuer, subject, and audience
    Entra-->>Agent: Blueprint token (T1)
    Agent->>Entra: Agent token request with T1 as client_assertion
    Entra-->>Agent: Agent identity access token
    Agent->>API: Call with agent identity access token

There are two assertions here, and conflating them hides the actual security model.

The AWS JWT proves which AWS workload is calling. The blueprint token proves that the authenticated blueprint is allowed to acquire a token for a particular agent identity. Neither token is the final downstream access token.

Configure AWS to attest to the workload

AWS IAM Outbound Identity Federation lets an IAM principal request an OpenID Connect (OIDC)-compatible token for an external service. The account must first have outbound web identity federation enabled, and the AgentCore runtime role must be allowed to call sts:GetWebIdentityToken (Enabling AWS IAM Outbound Identity Federation).

The permission should constrain what the role can request. In this design, the audience is:

api://AzureADTokenExchange
Enter fullscreen mode Exit fullscreen mode

That is the audience Microsoft recommends for an external assertion presented to its token exchange (Configure an app to trust an external identity provider). Restricting the AWS permission to this audience prevents the role from using the same permission to mint arbitrary outbound identity tokens for unrelated relying parties.

The runtime obtains the assertion from STS:

import boto3

sts = boto3.client("sts")

response = sts.get_web_identity_token(
    Audience=["api://AzureADTokenExchange"],
    SigningAlgorithm="RS256",
    DurationSeconds=300,
)

aws_assertion = response["WebIdentityToken"]
Enter fullscreen mode Exit fullscreen mode

The method and response shape are defined by the AWS GetWebIdentityToken API. A five-minute lifetime is not a universal requirement, but it keeps the assertion useful only for the immediate exchange and limits the value of a captured token.

The token has three claims that matter to the Entra trust:

  • iss identifies the account-specific AWS outbound identity federation issuer.
  • sub identifies the AWS IAM principal, in this case the AgentCore runtime role.
  • aud identifies Microsoft Entra's token exchange as the intended recipient.

AWS signs the token and publishes the metadata Microsoft Entra needs to validate it. The application does not create or sign the JWT itself.

Configure Entra to trust one AWS role

The corresponding federated identity credential belongs on the Agent ID blueprint's application registration. It contains the expected issuer, subject, and audience:

{
  "name": "agentcore-runtime",
  "issuer": "https://<account-specific-id>.tokens.sts.global.api.aws",
  "subject": "arn:aws:iam::<account-id>:role/<agentcore-runtime-role>",
  "audiences": [
    "api://AzureADTokenExchange"
  ]
}
Enter fullscreen mode Exit fullscreen mode

These are not descriptive labels. Microsoft Entra compares them with the iss, sub, and aud claims in the incoming AWS token. The values must match exactly and case-sensitively (Workload identity federation concepts).

That exact match is the authorization boundary.

Do not set the subject to an account-wide wildcard. Federated identity credentials do not support wildcards anyway, and that limitation is useful here (Configure an app to trust an external identity provider). Trust the runtime role that represents this workload. If another role needs the same blueprint, make that an explicit trust decision rather than an accidental side effect of a broad pattern.

The trust can now be stated without cloud branding:

Accept a token from this issuer, for this subject, intended for this audience.

That is the part workload identity federation standardizes. Microsoft Entra does not need the AWS access key. AWS does not need the Entra client secret.

The most important code is in the MSAL constructors

The raw HTTP requests explain the protocol, but they are not how version 2.0 implements it. The important part of the sample is how it composes two long-lived msal.ConfidentialClientApplication instances with two callable client-assertion providers.

There are two documentation layers behind this code. The Microsoft Authentication Library (MSAL) team's FIC and federated managed identity (FMI) guidance for agentic scenarios describes the Agent ID-specific blueprint, fmi_path, blueprint token (T1), and child-agent exchange. The MSAL Python ConfidentialClientApplication reference describes the generic confidential-client constructor and its client_credential parameter. The v2.0 sample joins those two layers by supplying functions as the client assertions.

That distinction matters because a client assertion is short-lived. Passing the current JWT as a static string would make the MSAL client depend on an assertion that eventually expires. Passing a function lets MSAL ask for an assertion when it actually needs to send a request to the token endpoint.

The resulting chain is:

sequenceDiagram
    participant Agent as Agent CCA
    participant AgentProvider as _agent_assertion()
    participant Blueprint as Blueprint CCA
    participant AwsProvider as _get_blueprint_assertion()
    participant STS as AWS STS

    Agent->>AgentProvider: Request client assertion
    AgentProvider->>Blueprint: Acquire T1 for fmi_path
    Blueprint->>AwsProvider: Request client assertion
    AwsProvider->>STS: GetWebIdentityToken
    STS-->>AwsProvider: Fresh AWS JWT
    AwsProvider-->>Blueprint: AWS client assertion
    Blueprint->>Blueprint: Exchange AWS JWT for T1
    Blueprint-->>AgentProvider: T1
    AgentProvider-->>Agent: T1 client assertion
    Agent->>Agent: Continue OBO request

There is no special MSAL class for "AWS-hosted Agent ID." Both objects are normal confidential clients. Their client_credential configuration delegates assertion acquisition to application code.

The blueprint confidential client application delegates its credential to AWS STS

The blueprint confidential client application (CCA) is created once per process:

_blueprint_app = msal.ConfidentialClientApplication(
    BLUEPRINT_CLIENT_ID,
    client_credential={
        "client_assertion": _get_blueprint_assertion
    },
    authority=AUTHORITY,
)
Enter fullscreen mode Exit fullscreen mode

Notice what is passed as client_assertion. It is the function object _get_blueprint_assertion, not the result of calling that function.

The constructor therefore does not contact AWS STS and does not capture one JWT forever. It stores a callable that can provide the credential later. This extends the generic client_credential assertion mechanism with a provider backed by AWS. In the v2.0 implementation, that provider asks STS for a five-minute assertion:

def _get_blueprint_assertion(*args, **kwargs) -> str:
    response = _ensure_sts_client().get_web_identity_token(
        Audience=[FIC_AUDIENCE],
        SigningAlgorithm="RS256",
        DurationSeconds=300,
    )
    return response["WebIdentityToken"]
Enter fullscreen mode Exit fullscreen mode

MSAL invokes the provider when it needs a client assertion for an Entra token-endpoint request. The application deliberately does not add another cache around the AWS JWT. MSAL caches the Entra token produced by the exchange, while a later token-endpoint request can obtain a fresh AWS assertion.

The *args, **kwargs signature is intentional. The sample tolerates MSAL Python versions that invoke the assertion provider with no arguments and versions that provide assertion context. The provider does not need that context because its issuer, audience, signing algorithm, and lifetime are fixed by the workload-federation configuration.

This is the first delegation point:

When the blueprint client needs to authenticate, delegate credential production to AWS STS.

The blueprint remains the confidential client from Entra's perspective. AWS STS is simply the component that supplies its short-lived proof.

The agent client delegates its assertion to the blueprint client

The second constructor repeats the pattern at the next identity boundary:

_agent_app = msal.ConfidentialClientApplication(
    AGENT_IDENTITY_ID,
    client_credential={
        "client_assertion": _agent_assertion
    },
    authority=AUTHORITY,
)
Enter fullscreen mode Exit fullscreen mode

This client represents the child Agent ID. It cannot ask AWS STS for its client assertion directly because AWS attests to the runtime role, while the Entra FIC is configured on the blueprint. The agent client's assertion must be T1, the token that proves the blueprint-to-agent relationship.

Its provider therefore delegates back to the persistent blueprint client:

def _agent_assertion(*args, **kwargs) -> str:
    blueprint_app = _ensure_blueprint_app()
    t1_result = blueprint_app.acquire_token_for_client(
        scopes=["api://AzureADTokenExchange/.default"],
        fmi_path=AGENT_IDENTITY_ID,
    )
    if "access_token" not in t1_result:
        raise TokenAcquisitionError(
            "FMI Stage 1 failed: {} - {}".format(
                t1_result.get("error"),
                t1_result.get("error_description"),
            )
        )
    return t1_result["access_token"]
Enter fullscreen mode Exit fullscreen mode

The fmi_path value tells Entra which child Agent ID the blueprint is acquiring T1 for. This is the Agent ID-specific extension described in the MSAL team's FIC and FMI agentic-scenario guidance, not an ordinary resource scope or OAuth OBO parameter. If the blueprint CCA must call the token endpoint, it invokes its own _get_blueprint_assertion provider and receives a fresh AWS JWT. _agent_assertion then returns the resulting T1 to the agent CCA.

This is the second delegation point:

When the agent client needs to authenticate, delegate assertion production to the blueprint client.

The two callbacks model the two trust transitions directly in code:

flowchart LR
    OBO["Agent CCA<br/>OBO request"]
    AP["_agent_assertion()"]
    BP["Blueprint CCA<br/>FMI request"]
    AWS["_get_blueprint_assertion()"]
    STS["AWS STS"]

    OBO -->|"needs T1"| AP
    AP --> BP
    BP -->|"needs AWS assertion"| AWS
    AWS --> STS
    STS -->|"AWS JWT"| AWS
    AWS --> BP
    BP -->|"T1"| AP
    AP --> OBO

This is dependency injection applied to client authentication. MSAL owns token acquisition, protocol parameters, and its token cache. The application owns how a valid assertion is produced at each boundary.

Construct once, refresh through the callbacks

Both confidential clients are lazy-initialized singletons in the container process. That is not just a small optimization.

Reusing the blueprint CCA preserves its in-memory cache for T1. Reusing the agent CCA preserves its in-memory cache for downstream user tokens. Constructing either CCA for every request would discard the corresponding cache and force avoidable token-endpoint calls.

The locks around both constructors make the initialization safe when concurrent invocations reach a newly started process:

with _blueprint_lock:
    if _blueprint_app is None:
        _blueprint_app = msal.ConfidentialClientApplication(...)
Enter fullscreen mode Exit fullscreen mode

The inner check matters because another invocation might have initialized the client while the current invocation waited for the lock.

The callbacks do not mean AWS STS is called for every tool call. The normal path is:

  1. try the relevant MSAL cache;
  2. return a usable cached token when one exists;
  3. invoke the assertion provider only when MSAL needs a token-endpoint request; and
  4. let that provider produce a current assertion rather than reuse an expired string.

This is why the constructor wiring is the center of the implementation. The code does not contain a scheduled client-secret rotation job because the credential lifecycle has become part of token acquisition itself.

Assertion delegation is not user delegation

The word "delegation" can hide two different mechanisms in this sample.

The callback functions delegate credential production between Python components. _get_blueprint_assertion supplies the AWS workload assertion, and _agent_assertion supplies T1. No user identity is involved in either callback.

OAuth 2.0 on-behalf-of (OBO) user delegation happens later, when the persistent agent CCA calls:

tr_result = agent_app.acquire_token_on_behalf_of(
    user_assertion=inbound_user_token,
    scopes=scopes,
)
Enter fullscreen mode Exit fullscreen mode

At that point MSAL has two different inputs:

  • the agent CCA's callable client_assertion, which produces T1 and authenticates the Agent ID; and
  • user_assertion, which is the inbound user token and carries the delegated user context.

T1 answers, "Which agent is making this request, and may this blueprint act for it?" The user assertion answers, "On behalf of which signed-in user is the agent acting?" The final resource token combines those parts of the flow for the requested downstream scope.

Keeping these inputs separate is what makes the sample useful. The AWS workload assertion is not treated as a user token. T1 is not treated as the downstream access token. The inbound user token is not used as the agent credential. Each assertion has one job.

Exchange the AWS assertion for the blueprint token

Underneath those MSAL constructors, the AgentCore runtime presents the AWS JWT to the Microsoft identity platform token endpoint as a client assertion. This is the client credentials flow with a federated credential, not a custom token format or a vendor-specific backchannel.

For an Agent ID blueprint, the first request also identifies the target agent identity through fmi_path:

POST https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded

client_id=<agent-blueprint-client-id>
&scope=api://AzureADTokenExchange/.default
&grant_type=client_credentials
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=<aws-sts-jwt>
&fmi_path=<agent-identity-client-id>
Enter fullscreen mode Exit fullscreen mode

Microsoft documents the fmi_path parameter as the instruction that tells the blueprint which agent identity it is acting for (Authenticate and acquire tokens for autonomous agents).

If the AWS token's signature is valid and its issuer, subject, and audience match the federated identity credential, Entra returns the blueprint token, often called T1 in the Agent ID flow.

That is where the workload federation step ends.

Continue with the Agent ID token exchange

T1 is not the token the agent sends to Microsoft Graph, an MCP server, or another protected API. The runtime uses T1 as the client assertion in a second request, this time as the agent identity:

POST https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded

client_id=<agent-identity-client-id>
&scope=<downstream-resource>/.default
&grant_type=client_credentials
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=<agent-blueprint-token-T1>
Enter fullscreen mode Exit fullscreen mode

That second request is the documented agent identity token request. The resulting access token represents the Agent ID and is scoped to the downstream resource.

This separation matters operationally:

  1. AWS proves the runtime role to the blueprint.
  2. The blueprint proves its authority to act for the child agent identity.
  3. Microsoft Entra issues a resource-specific token for that agent identity.

Workload identity federation replaces the blueprint credential. It does not flatten the Agent ID parent-child model, bypass consent, or grant the agent new downstream permissions. The agent still needs the appropriate application permissions and administrator authorization described in the autonomous agent authorization flow.

Authentication proves which workload and which agent are involved. Authorization still decides what that agent may do.

What became safer, and what did not

The most visible gain is the removal of a long-lived Entra client secret from the AWS runtime. There is no secret value to copy into deployment configuration, retrieve from a secret store, rotate on a calendar, or accidentally leave valid after the workload changes.

The assertion is also bound to the AWS identity that requested it. Microsoft Entra accepts it because the configured subject identifies the expected IAM role, not because the bearer knows a shared string.

But "secretless" should not be read as "trustless" or "credential-free."

The AgentCore runtime's access to its IAM role is now the root of trust. A process that can run as that role and call GetWebIdentityToken can request the same AWS assertion. The AWS role assignment, its trust policy, the permission to call STS, and the audience and duration conditions therefore need the same review you would give any production identity boundary.

Short-lived tokens also remain bearer material while they are valid. Do not log the AWS assertion, T1, or the final access token. Do not pass them into model context or tool output. Cache them only for their useful lifetime and keep token acquisition in the trusted application layer.

Federation removes an entire secret lifecycle. It does not excuse weak runtime isolation.

Failure modes worth testing

The happy path is short. The useful tests are mostly about proving that the trust is narrow.

Change the audience

Request an AWS token for an audience other than api://AzureADTokenExchange. The Entra exchange should fail because the incoming aud no longer matches the federated identity credential. Audience checking is a required part of the configured trust (Configure an app to trust an external identity provider).

Run under another IAM role

Obtain a valid AWS token as a different role. Signature validation should still succeed, but the token exchange should fail because the sub claim does not match. This test demonstrates the difference between trusting AWS as an issuer and trusting every AWS workload.

Point to another AWS issuer

Use a token from another AWS account's outbound identity issuer. The subject might look familiar, but the iss value will differ. Entra uses the issuer and subject combination to identify the external workload (Configure an app to trust an external identity provider).

Request a permission the agent does not have

Keep the workload federation valid and ask for a downstream scope or application permission that has not been granted. The Agent ID token request should fail independently of AWS authentication. This is evidence that workload authentication has not bypassed the Agent ID authorization model.

Remove sts:GetWebIdentityToken

The failure should happen in AWS before any call reaches Microsoft Entra. That gives operators a clean boundary when diagnosing incidents: AWS controls whether the runtime can obtain an assertion, while Entra controls whether that assertion is trusted and what the resulting Agent ID may access.

The configuration is the architecture

The code change in version 2.0 is not large. That is exactly why the configuration deserves attention.

The security properties live in four places:

  • the IAM role attached to the AgentCore runtime;
  • the role's permission and conditions for sts:GetWebIdentityToken;
  • the Entra federated identity credential's exact issuer, subject, and audience; and
  • the permissions granted to the resulting Agent ID.

Reviewing only the Python token requests misses three of those four boundaries.

The first version asked, "Can an AWS-hosted agent authenticate and authorize as a Microsoft Entra Agent ID?" The answer was yes.

Version 2.0 asks the production question: "Can it do that without distributing a Microsoft credential into AWS?" The answer is also yes.

AWS attests to the workload it runs. Microsoft Entra decides whether to trust that attestation. The Agent ID blueprint then acquires a token for the child agent identity through its established flow.

No shared secret crosses the cloud boundary. Trust does.

References

Top comments (0)