Name: Towards AI Legal Name: Towards AI, Inc. Description: Towards AI is the world's leading artificial intelligence (AI) and technology publication. Read by thought-leaders and decision-makers around the world. Phone Number: +1-650-246-9381 Email: pub@towardsai.net
228 Park Avenue South New York, NY 10003 United States
Website: Publisher: https://towardsai.net/#publisher Diversity Policy: https://towardsai.net/about Ethics Policy: https://towardsai.net/about Masthead: https://towardsai.net/about
Name: Towards AI Legal Name: Towards AI, Inc. Description: Towards AI is the world's leading artificial intelligence (AI) and technology publication. Founders: Roberto Iriondo, , Job Title: Co-founder and Advisor Works for: Towards AI, Inc. Follow Roberto: X, LinkedIn, GitHub, Google Scholar, Towards AI Profile, Medium, ML@CMU, FreeCodeCamp, Crunchbase, Bloomberg, Roberto Iriondo, Generative AI Lab, Generative AI Lab VeloxTrend Ultrarix Capital Partners Denis Piffaretti, Job Title: Co-founder Works for: Towards AI, Inc. Louie Peters, Job Title: Co-founder Works for: Towards AI, Inc. Louis-François Bouchard, Job Title: Co-founder Works for: Towards AI, Inc. Cover:
Towards AI Cover
Logo:
Towards AI Logo
Areas Served: Worldwide Alternate Name: Towards AI, Inc. Alternate Name: Towards AI Co. Alternate Name: towards ai Alternate Name: towardsai Alternate Name: towards.ai Alternate Name: tai Alternate Name: toward ai Alternate Name: toward.ai Alternate Name: Towards AI, Inc. Alternate Name: towardsai.net Alternate Name: pub.towardsai.net
5 stars – based on 497 reviews

Frequently Used, Contextual References

TODO: Remember to copy unique IDs whenever it needs used. i.e., URL: 304b2e42315e

Resources

Free: 6-day Agentic AI Engineering Email Guide.
Learnings from Towards AI's hands-on work with real clients.
Latest   Machine Learning

Minting an Entra Agent Token From GitHub Actions: No Secret, No Certificate, Nothing Stored

Last Updated on October 6, 2026 by Editorial Team

Author(s): suman saha

Originally published on Towards AI.

Minting an Entra Agent Token From GitHub Actions: No Secret, No Certificate, Nothing Stored

Third in a series on Entra Agent ID. The first part covered the two-leg exchange and where the client assertion comes from, using a managed identity. The second covered the three configuration steps that have to be complete before the resulting token carries any authorisation. This part runs the same exchange from outside Azure, from a GitHub Actions workflow, and records what is different — which is less than one might expect in the protocol, and more than one might expect in the tooling.

What changes, and what does not

The exchange itself does not change. Leg 1 authenticates as the blueprint and names the agent identity in fmi_path; leg 2 authenticates as the agent identity using the token leg 1 returned. That is identical whether the workload runs on an Azure VM, in a GitHub-hosted runner, or anywhere else.

What changes is step 0 — where the client assertion comes from. With a managed identity, the assertion is issued by the Azure IMDS endpoint and its iss is https://login.microsoftonline.com/{tenant}/v2.0. On GitHub Actions the assertion is issued by GitHub, its iss is https://token.actions.githubusercontent.com, and its sub describes the repository, the ref, and optionally the environment that produced it.

That difference is the whole of it at the protocol level. The federated identity credential on the blueprint is configured with a different issuer and a different subject; everything downstream is the same.

What also changes is what the tooling will do for you, which is where the time goes.

Where the agent is, before we start

One thing to settle first, because it is the question the rest of this reads against. The word “agent” carries three meanings here that this flow quietly collapses. The agent identity is a servicePrincipal object in Entra — durable, holding no credential, doing nothing on its own; it is a name to act under. The workload is the compute that authenticates as that identity and then does the work — here, a GitHub Actions job, which lives for the length of a run and is then gone. The agent in the sense most people mean — code that reasons and acts — is whatever runs holding the resulting token.

The workflow below is the agent’s runtime for the length of the job; the agent identity is the durable name it borrows. There is no separate agent process in this demonstration — the token is the deliverable. Production agent code would run in the step after leg 2, using that token to call the resource. What binds a particular workflow to a particular agent identity is not a software artifact but two configuration values: the federated credential’s subject, which pins which workflow may authenticate, and the fmi_path in leg 1, which names which agent it acts as.

This is also why GitHub Actions makes the point more sharply than a managed identity did. There, the compute had its own durable identity, and the separation between the workload and the identity it borrows stayed invisible. A runner that exists for ninety seconds cannot hide it.

Step 0 — the assertion GitHub issues

A workflow job with id-token: write permission can request an OIDC token from GitHub, scoped to an audience of its choosing:

permissions:
id-token: write
contents: read
curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=api%3A%2F%2FAzureADTokenExchange"

Both environment variables are injected by the runner and exist only when id-token: write is granted. Without that permission they are absent and the request fails with no obvious indication of why — the variable is simply empty.

The audience parameter must match the audiences value on the federated identity credential. For Entra workload identity federation that is api://AzureADTokenExchange, which is why it appears URL-encoded above.

The resulting token is short-lived, issued on demand, and never stored. Decoded, its claims are:

iss https://token.actions.githubusercontent.com
sub repo:<org>/<repo>:ref:refs/heads/<branch>
aud api://AzureADTokenExchange

The federated credential, and what its subject pins

On the blueprint — not on the agent identity:

POST /beta/applications/{blueprintObjectId}/federatedIdentityCredentials
Content-Type: application/json
{
"name": "fic-github-<workload>",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:<org>/<repo>:ref:refs/heads/<branch>",
"audiences": ["api://AzureADTokenExchange"]
}

The subject must match the assertion’s sub exactly. There is no wildcard matching and no partial match: a credential pinned to refs/heads/main will not accept an assertion from refs/heads/feature/x, and the failure is a rejection at leg 1 rather than anything more descriptive.

That strictness is the security property rather than an inconvenience. The subject is the only thing distinguishing this workload from any other that can reach the token endpoint. Pinned to a repository and a ref, it means a fork, a pull request from an untrusted contributor, or a different branch of the same repository cannot authenticate as this agent. Pinned only to repo:<org>/<repo>, it means any branch can — including one a contributor created.

Write on Medium

For workflows that run on pull requests or in a deployment environment, GitHub’s subject format differs (pull_request, environment:<name>). The practical approach is to print the subject the workflow actually presents and create the credential from that, rather than constructing it by hand:

echo "repo:${GITHUB_REPOSITORY}:ref:${GITHUB_REF}"

One caution on that subject format: an organisation can customise the OIDC subject claim GitHub issues, so on a repository where that has been done, sub is not what the template above predicts. Printing the actual value is not only convenient; it is the only reliable source.

The two legs

Leg 1 — authenticate as the blueprint, name the agent

curl -X POST "https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token" \
-d grant_type=client_credentials \
-d client_id="${BLUEPRINT_APP_ID}" \
-d scope='api://AzureADTokenExchange/.default' \
-d client_assertion_type='urn:ietf:params:oauth:client-assertion-type:jwt-bearer' \
--data-urlencode client_assertion="${GITHUB_OIDC_TOKEN}" \
--data-urlencode fmi_path="${AGENT_APP_ID}"

Note client_id is the blueprint's application ID. The agent identity appears only in fmi_path. The response is an exchange token whose client-identity claim is the blueprint and whose sub is a federated managed identity path ending in the agent's application ID:

/eid1/c/pub/t/<tenant-segment>/a/<app-segment>/<agentAppId>

Leg 2 — authenticate as the agent

curl -X POST "https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token" \
-d grant_type=client_credentials \
-d client_id="${AGENT_APP_ID}" \
-d scope='https://graph.microsoft.com/.default' \
-d client_assertion_type='urn:ietf:params:oauth:client-assertion-type:jwt-bearer' \
--data-urlencode client_assertion="${T1}"

The exchange token from leg 1 is used as the agent identity’s own client assertion. The result is a resource token for the requested audience.

Three things the tooling will not do for you

MSAL cannot send fmi_path. It is a Microsoft extension to the token request and is not part of any MSAL surface. Both legs therefore have to be raw form POSTs. This matters more in CI than elsewhere, because the reflex in a workflow is to reach for azure/login or an MSAL-based helper, and every such sample will be missing the one parameter that names which agent to act as. The symptom is not an error about fmi_path; it is a successful token for the wrong identity, or a failure at leg 1 with no indication that a parameter was dropped.

scope must end in /.default. Naming an individual permission is rejected before any scope evaluation happens:

AADSTS1002012: The provided value for scope https://graph.microsoft.com/User.Read is not valid.
Client credential flows must have a scope value with /.default suffixed to the resource identifier.

The message reads like a permissions problem and is a syntax rule. Anyone who arrives at it after configuring permissions will spend time re-checking the permissions.

workflow_dispatch only appears in the Actions tab when the workflow file is on the default branch. A probe workflow developed on a feature branch is registered but has no Run button, which reads as a broken file. Triggering on a push to a sentinel file is the workaround, and it has a second benefit: the run then carries the feature branch's ref, which is what the federated credential is pinned to. Putting the file on main to get the button would invert that — the run would present main's ref and leg 1 would fail on a subject mismatch.

What the token contained

Decoding the leg 2 token, with the blueprint’s inheritance configured as described in the previous part:

aud https://graph.microsoft.com
iss https://sts.windows.net/<tenant>/
appid <agent appId> - the agent, not the blueprint
oid <agent object id>
idtyp app - no user in this token
roles ["Group.Read.All", "User.Read.All"]
scp (absent)
lifetime 65 minutes

Three observations about those claims.

The client-identity claim is the agent, not the blueprint. Leg 1 authenticates as the blueprint; the token that comes out of leg 2 identifies the agent. That flip is what makes per-agent attribution possible at a resource server, and it is what a downstream gateway should pin on. A resource that authorises on the blueprint would treat every agent beneath it as the same caller.

app_displayname is present. The agent identity's display name travels to the resource. If a resource server logs the caller, the agent's name appears in that resource's logs — which makes the name a wider concern than an internal index key.

scp is absent, and always will be in this flow. Delegated permissions produce a scp claim only in a token minted in a user's context. client_credentials is app-only from end to end, so a delegated permission configured on the blueprint is provisioned, inheritable, and will not appear. Configuring one and expecting it to work is a failure with no error at any layer.

A behaviour worth reproducing deliberately

Inheritance missing. With the declaration and the role grants in place but no inheritablePermissions entry, leg 2 returns HTTP 200 and a well-formed token whose roles claim is present but empty — "roles": []. Nothing errors — not the response, not the sign-in logs, not the portal. This is the state the previous part examined: not a failure, but a valid token that conveys no permissions. It is worth reproducing from CI specifically, because a workflow that checks if [ "$CODE" = "200" ] will report success.

Practical consequences

Assert on the claim, not the status code. The behaviour above returns a 2xx at the layer a workflow is most likely to check. A CI job that obtains a token and does not decode it has verified that the token endpoint is reachable, not that the token is usable.

Pin the credential to a ref, not a repository. repo:<org>/<repo> alone accepts any branch. In a repository that accepts external contributions, that is the difference between a credential a maintainer controls and one a contributor can reach.

Do not test with a token obtained before a configuration change. Resource tokens are valid for roughly an hour and are not re-evaluated after issuance. After changing inheritance, obtain a fresh assertion and a fresh exchange token before concluding anything.

The credential lives on the blueprint, not the agent. The federated credential is configured on the blueprint object, and the agent is named per request in fmi_path. That is the design: one blueprint can parent many agent identities, and the credential authenticates the blueprint. ⟦CHECK 3⟧

Method notes

All calls were made against the Microsoft Graph beta endpoint and the v2.0 token endpoint, in a non-production tenant, from a GitHub-hosted ubuntu-latest runner. Tokens were decoded in the workflow rather than through an online decoder. The federated credential used a repository-and-ref subject; I have not tested the environment: or pull_request subject forms.

Scope of what was tested: a single tenant; application permissions only; Microsoft Graph as the resource; a single agent identity beneath the blueprint. I have not tested a resource in another tenant, a certificate credential, or a self-hosted runner.

Observed in a single tenant, October 2026. Verify current behaviour against your own.

The views and findings in this article are my own and do not represent those of my employer. All testing was done in a non-production environment using generally available Microsoft services. This article is not affiliated with or endorsed by Microsoft.

Join thousands of data leaders on the AI newsletter. Join over 80,000 subscribers and keep up to date with the latest developments in AI. From research to projects and ideas. If you are building an AI startup, an AI-related product, or a service, we invite you to consider becoming a sponsor.

Published via Towards AI


Towards AI Academy

We Build Enterprise-Grade AI. We'll Teach You to Master It Too.

15 engineers. 100,000+ students. Towards AI Academy teaches what actually survives production.

Start free — no commitment:

→ 6-Day Agentic AI Engineering Email Guide — one practical lesson per day

→ Agents Architecture Cheatsheet — 3 years of architecture decisions in 6 pages

Our courses:

→ AI Engineering Certification — 90+ lessons from project selection to deployed product. The most comprehensive practical LLM course out there.

→ Agent Engineering Course — Hands on with production agent architectures, memory, routing, and eval frameworks — built from real enterprise engagements.

→ AI for Work — Understand, evaluate, and apply AI for complex work tasks.

Note: Article content contains the views of the contributing authors and not Towards AI.