[Learning Notes][Golang] Authorization Challenges in the AI Agent Era: What is ID-JAG and Why I Re-implemented It in Go

작성자

카테고리:

← 피드로
DEV Community · Evan Lin · 2026-07-28 개발(SW)

Preface:

In the past six months or so, connecting AI Agents directly to internal systems to help get things done is no longer news. However, if you think one step further: in “whose identity” should the Agent call those APIs? If the permissions it receives are as broad as a human’s, once it is tricked into performing an operation it shouldn’t, the consequences could be more severe than a human’s accidental slip.

This is exactly the problem that ID-JAG (Identity Assertion JWT Authorization Grant) aims to solve. I recently organized the principles of this mechanism and re-implemented the MCP Server from the tutorial repo athenz-community/id-jag-the-hard-way using Go: kkdai/id-jag-mcp. This article aims to clarify the technical principles of ID-JAG: which RFC standards it is built upon, how it differs from OAuth2 / PKCE that I’ve written about before, what the actual token exchange flow looks like, and finally, a demonstration of how to run and test this Go project.

TL;DR

This article will introduce the following in order:

What is ID-JAG? Why is it needed?

ID-JAG is an authorization mechanism that allows AI Agents to access protected resources on behalf of users, with the keyword being “on behalf of.” Currently, it is still an IETF Internet-Draft and has not yet become an official RFC, but organizations like LY Corporation (on the Athenz authorization system) and Okta have already begun implementation, and the MCP (Model Context Protocol) specification has already cited this draft.

Traditional service-to-service authorization usually falls into two extremes: either the entire service shares a universal master key (API Key, Service Account), or the user’s session or long-lived token is directly lent to the program. The former has too much power, while the latter lacks an audit trail; if leaked, an attacker can almost completely impersonate the user, and it is very difficult to detect. When AI Agents start deciding which tools to call and which internal APIs to connect to on their own, the risks of both approaches are amplified: an Agent might perform operations the user never intended due to prompt injection or hallucinations. If the Agent holds a master key, the consequence is that all company data is at its mercy. In real-world scenarios, there is often a multi-layered architecture where an “Orchestrator Agent calls a Sub-Agent,” and the risk is passed all the way down.

What ID-JAG wants to achieve is: for every action an Agent takes, it must be able to prove that “this is a specific user, at a specific moment, authorizing me to do this specific thing,” and this authorization scope should be as narrow as possible, with a validity period as short as possible. It is built on top of OAuth2 token exchange (RFC 8693), adding a layer of proof that “this token is derived from a human’s identity assertion.” This is also why it is listed on OAuth.net’s Cross-App Access (XAA) page—this is precisely a new problem emerging in the Agent era.

Incidentally, ID-JAG also solves a very practical user experience problem: if an Agent had to pop up a browser window for the user to manually click “Agree” every time it needed to access a new service, the experience would quickly become exhausting, leading users to just agree to everything. ID-JAG consolidates the authorization action to the moment the user logs in via SSO. After that, when the Agent needs new access permissions, it uses the already issued identity assertion to exchange for a token with the authorization server, without requiring the user to pop up and click again.

From OAuth2 and PKCE to new problems in the Agent era

I previously wrote about How to develop OAuth2 PKCE via Golang, which covered the implementation experience of LINE Login adopting PKCE. The problem solved in that article and the one ID-JAG solves are actually on two different levels. Comparing them makes it clearer what is new about ID-JAG.

PKCE solves the problem of “whether the client identity is trustworthy”: for public clients like mobile apps that cannot safely store a client secret, the authorization code might be intercepted by a malicious app on the same phone during transmission. PKCE uses a code_verifier / code_challenge one-time pair to ensure that even if the code is stolen, it cannot be exchanged for a token without the correct verifier. The entire problem occurs in a “single hop” between the user and the app in their hand.

ID-JAG solves the problem of “whether this non-human service identity is qualified to act on behalf of this person for this task,” and it often spans several hops: User logs into IdP → AI Client Gateway → MCP Server → Final Resource Server. The caller at each hop is not the user themselves, yet each must prove they are “acting under authority.” The original design of OAuth 2.0 was for “human user ↔ application” scenarios and does not directly support this multi-layer Agent chain delegation scenario. PKCE protects the integrity of a single authorization exchange; ID-JAG protects the minimum necessary permissions for every link in an entire authorization chain. The two do not conflict; they are mechanisms solving problems at different stages under the same broad architecture.

Two RFC cornerstones: Token Exchange and JWT Bearer

ID-JAG did not invent a new protocol out of thin air; instead, it combines two existing IETF standards. Understanding these two cornerstones is necessary to understand what the subsequent complete exchange flow is doing.

The first is RFC 8693 — OAuth 2.0 Token Exchange, which defines a general protocol for “exchanging one type of token for another.” Conceptually, it’s like going to a currency exchange to swap Yen for Taiwan Dollars, except here you are swapping security tokens. A token exchange request looks roughly like this:

POST /token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=<Identity Assertion JWT>
subject_token_type=urn:ietf:params:oauth:token-type:jwt
requested_token_type=urn:ietf:params:oauth:token-type:access_token
scope=read:orders

Enter fullscreen mode Exit fullscreen mode

The subject_token contains the token representing the delegated identity, requested_token_type specifies what type of token you want to exchange it for, and scope can narrow down the permission range at the moment of exchange.

The second is RFC 7523 — JWT Bearer Grant, which allows a JWT itself to be used directly as an OAuth 2.0 authorization credential without having to go through an authorization code exchange first:

POST /token
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
assertion=<Signed Identity Assertion JWT>

Enter fullscreen mode Exit fullscreen mode

After receiving it, the authorization server verifies the signature of this JWT using the issuer’s (IdP) public key. If fields like audience, scope, and exp are reasonable, it can directly issue an Access Token without secondary user consent—because the IdP has already endorsed this identity assertion.

The “Identity Assertion JWT” to be exchanged usually contains the following fields:

{
  "iss": "https://enterprise.idp.example.com/v1",
  "sub": "[email protected]",
  "aud": "https://api.service.example.com",
  "exp": 1773839486,
  "iat": 1773825086,
  "jti": "abc123-7dc6-42ab-b326-uniqueid",
  "scope": "read:orders write:tickets"
}

Enter fullscreen mode Exit fullscreen mode

sub is the user being proxied, aud is which service this assertion is intended for, scope is the allowed permission range, and jti is a unique ID used to prevent the same assertion from being replayed. ID-JAG uses this short-lived identity assertion and, through the exchange mechanisms defined in the two RFCs above, gradually exchanges it for the Access Token that the Agent can actually use to call the API.

The complete ID-JAG token exchange flow

Using the architecture of the id-jag-the-hard-way tutorial repo as an example (using Athenz as the authorization server), the complete chain connects the previous two RFCs:

User logs into IdP (Keycloak)
   │ Obtains OIDC ID Token
   ▼
AI Client Gateway
   │ Uses RFC 8693 Token Exchange to swap ID Token for ID-JAG
   │ (grant_type=token-exchange, subject_token_type=id_token,
   │ requested_token_type=id-jag)
   ▼
Then uses RFC 7523 JWT Bearer to swap ID-JAG for a usable Athenz Access Token
   │ (grant_type=jwt-bearer, assertion=<ID-JAG>)
   ▼
AI Client calls MCP Server with this Access Token
   │
   ▼
After receiving the request, the MCP Server performs an RFC 8693 Token Exchange "itself"
   │ Swaps the received Access Token for a new Access Token with the "minimum scope needed for this tool"
   │ (This step uses the MCP Server's own mTLS service identity, not the user's credentials)
   ▼
Calls the final Resource Server with the downscoped Access Token

Enter fullscreen mode Exit fullscreen mode

Throughout the chain, the token is exchanged more than once; it is exchanged every time it crosses a trust boundary, and the scope becomes narrower with each exchange. This design intentionally ensures that the “credential” for each segment of the path is different—the token held by the AI Client Gateway cannot be used directly to trick the final Resource Server because the MCP Server stage forces re-verification and re-issuance. The issued Access Token usually records both sub (the proxied user) and act (the identity of the Agent actually performing the operation), so downstream services can clearly see that “Alice performed this operation through a certain Agent,” and the audit trail is not broken midway.

Downscoping permissions at every hop: How the Principle of Least Privilege is implemented

This is what I find to be the most beautiful part of the entire architectural design: least privilege is not just a principle written in a document; it is physically enforced by the token exchange mechanism.

Taking my re-implemented id-jag-mcp as an example, it provides three tools:

Tool Corresponding Athenz Scope get_k8s_docs api:role.docs-getter delete_k8s_doc api:role.docs-deleter post_k8s_doc api:role.docs-poster

When the MCP Server receives a request, it does not directly forward the Access Token sent by the AI Client to the upstream API—it will always use its own mTLS certificate to perform a token exchange with Athenz ZTS for the “scope actually needed by this tool.” Only the newly exchanged token will be used to call the upstream. Even if the token scope at the AI Client Gateway is broader (e.g., possessing both read and delete permissions), the MCP Server will only request the specific small piece of permission actually needed for each tool before forwarding.

In other words, no part of the entire system “happens” to have more power than its current task requires—this is not checked via code review or internal regulations, but is architecturally impossible to bypass.

Why re-implement this MCP Server in Go?

The original MCP Server in id-jag-the-hard-way (api_server/mcp/) was written in TypeScript + Express and used a hand-coded JSON-RPC 2.0 protocol (without the official SDK). I wanted to confirm two things: first, if this token exchange architecture is implemented in a different language with a different MCP SDK, can the logic truly be replicated; second, how the official modelcontextprotocol/go-sdk actually performs.

The final implementation maintains the original core logic (same scope mapping, same mTLS token exchange flow) but replaces the protocol layer entirely with the official Go SDK. The mTLS client was custom-built (without depending on Athenz’s official Go client library).

Hands-on: Installation, Execution, and Testing

The code is at kkdai/id-jag-mcp (Apache 2.0 license). The project structure and separation of responsibilities are roughly:

cmd/id-jag-mcp/ Entry point: reads settings, assembles all components, starts HTTP server
internal/config/ Environment variable configuration reading
internal/athenz/ mTLS client + Athenz ZTS RFC 8693 token exchange
internal/tools/ Tool input types + shared upstream forwarding logic
internal/server/ MCP tool registration (official SDK) + REST shortcut routes + logging

Enter fullscreen mode Exit fullscreen mode

First, clone the project and build the binary:

git clone https://github.com/kkdai/id-jag-mcp.git
cd id-jag-mcp
go build -o id-jag-mcp ./cmd/id-jag-mcp

Enter fullscreen mode Exit fullscreen mode

To actually run it, you need to prepare mTLS certificates, a reachable Athenz ZTS, and an upstream API server. Configuration is done entirely through environment variables:

mkdir -p certs
cp /path/to/api-mcp.crt /path/to/api-mcp.key /path/to/ca.crt certs/

export UPSTREAM_BASE_URL=http://localhost:14443
export AUTHORIZATION_SERVER_URL=https://athenz-zts-server.athenz:4443/zts/v1

go run ./cmd/id-jag-mcp

Enter fullscreen mode Exit fullscreen mode

After startup, in addition to the /mcp endpoint for MCP client connections, it also provides REST shortcut routes corresponding to the three tools, making it easy to test directly with curl without an MCP client:

curl -H "Authorization: Bearer $AT" http://localhost:8101/api/docs

curl -X DELETE -H "Authorization: Bearer $AT" http://localhost:8101/api/docs/5

curl -X POST -H "Authorization: Bearer $AT" -H "Content-Type: application/json" \
  -d '{"name":"doc1","content":"hello"}' \
  http://localhost:8101/api/docs

Enter fullscreen mode Exit fullscreen mode

If you just want to confirm if the logic itself is written correctly, you don’t need to actually set up an Athenz/Keycloak environment—testing uses httptest to simulate ZTS and the upstream API throughout:

go build ./...
go vet ./...
go test ./...

Enter fullscreen mode Exit fullscreen mode

For example, the tests for internal/athenz will start a fake ZTS server to verify if the outgoing grant_type, subject_token, scope, and audience parameters are correct; the tests for internal/tools verify that when each tool forwards to the upstream, it carries the “downscoped token after exchange” rather than the original one received. This way, you can confirm the entire token exchange logic is correct without actually connecting to Athenz.

The README (available in both Chinese and English) contains a complete list of environment variables and more details.

Conclusion

ID-JAG does not solve “whether this client is who it says it is” (that is a PKCE problem), but rather “whether this non-human service identity is qualified to represent a specific person to do this specific thing right now.” What supports this architecture is not documentation constraints, but the chaining of RFC 8693 and RFC 7523 standards, ensuring every hop is forced to re-verify and re-downscope permissions.

If your AI Agents have started interacting with internal systems, this is an architecture worth taking the time to understand—and you don’t necessarily have to copy Athenz’s implementation. The key is to understand the core principle that “every hop must re-issue, and the scope must get narrower,” and apply it to your own authorization server.

Related Articles:

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다