A hands-on follow-up to A Closer Look at How ThunderID Handles AI Agents. That piece argued, in principle, that delegation beats impersonation. This one tests it against a running server.
The gap between a claim and a server
It is easy to write that an identity system “treats AI agents as first-class identities with delegated, scoped authority.” It is harder to check.
So I built a small sandbox. One human, one AI agent, one fake API with two endpoints and a rule that should be simple to enforce that the agent may read the calendar and may not read messages , even when it is acting on the human’s behalf.
Alice, the human can do both. Her scheduling agent should not inherit that. That is the entire experiment.
Six phases later I had a working delegation flow, a real RFC 8693 token exchange and an audit trail. I also had a result that was the opposite of what I first thought I had found.
This post is really about that reversal. It turned out to be the most useful thing that happened.
Part 1: Reading the box before opening it
Before running anything, I spent twenty minutes just reading the files in the download. That was the best twenty minutes of the project.
The download is not what I expected. No Docker Compose, no JVM, no installer.
Two things jumped out of the config and the bootstrap files.
Agents are a real resource type. agent_type sits right next to user_type as an equal and it has a schema shaped like an agent rather than a person.
resource_type: agent_type
schema:
modelProvider: [openai, anthropic, gemini, mistral, custom]
model: string
function: [task-automation, rag-retrieval, code-gen,
data-analysis, orchestrator, sub-agent,
assistant, custom]
A user record has a family name and a phone number. An agent record has a model provider and a function. I had set aside a whole phase to fake an agent as a second user account. I could delete it.
The agent is its own OAuth client. Agent records carry their own inboundAuthConfig with a client secret. There is no separate application object to register and keep in sync.
And inside the binary itself all of RFC 8693 was there.
urn:ietf:params:oauth:grant-type:token-exchange
urn:ietf:params:oauth:token-type:id-jag
subject_token actor_token actor_token_type requested_token_type
My backup plan was to hand-build the token exchange myself. It was already looking unnecessary.
Then I ran it.
msg="In-process bootstrap completed" imported=26
msg="ThunderID Server started" startup_time=112.6857ms
112 milliseconds. For a full OAuth2 and OIDC server.
That number is not showing off. If every environment might want its own separate identity system, then starting one more has to be nearly free. A 57 MB binary that boots in a tenth of a second and keeps its data in SQLite can run per environment, per test, per customer. A traditional IAM stack cannot.
The request that settled the project
$ curl -k https://localhost:8090/.well-known/openid-configuration
grant_types_supported:
client_credentials
authorization_code
refresh_token
urn:ietf:params:oauth:grant-type:token-exchange <-- there it is
urn:openid:params:grant-type:ciba
urn:ietf:params:oauth:grant-type:jwt-bearer
Not just compiled in. Actually advertised. My fallback plan was dead, which was the best possible outcome.
Two more things in that document I had not gone looking for.
dpop_signing_alg_values_supported:
RS256, RS512, PS256, ES256, ES384, ES512, EdDSA,
ML-DSA-44, ML-DSA-65, ML-DSA-87
ML-DSA is the post-quantum signature algorithm NIST standardised. All three variants, offered by a running 1.0 server. I had read "post-quantum-safe by design" as marketing.
It is worth being careful here because this is easy to overstate. Those algorithms are offered for DPoP proofs and client authentication. ID tokens are still signed with RS256 or ES256 and the live key set holds exactly two keys, one RSA and one EC. So token signing is still classical. The quantum-resistant part is proof-of-possession. That is real but it is not "all your tokens are post-quantum now."
There was also id-jag, a draft standard for carrying agent authority between different identity systems advertised as a supported grant profile in a 1.0 release.
Part 2: Why two identities, not one login
I wrote the permission model down before creating anything. If you adjust permissions after seeing your results, the experiment is worthless.
The agent can do strictly less than the human. So any refusal later should come from that table, not from a bug in my code.
ThunderID takes its configuration as YAML applied to a running system.
resource_type: agent
type: default
name: agent-scheduler
attributes:
modelProvider: anthropic
model: claude-opus-5
function: assistant
inboundAuthConfig:
- type: oauth2
config:
clientId: agent-scheduler
clientSecret: <REDACTED>
grantTypes: [client_credentials,
urn:ietf:params:oauth:grant-type:token-exchange]
$ ./thunderid.exe bootstrap -defaults ./resources
msg="In-process bootstrap completed" imported=5
That turns “who gave this agent permission to read messages and when?” into a question you answer with git log. There is a change, an author and a date. You are not digging through a database trying to reconstruct history.
Here is the console before and after. Note that Agents is a top-level section under IDENTITIES , a sibling of Users, Groups and Roles not a checkbox buried in a user profile.
That turns “who gave this agent permission to read messages, and when?” into a question you answer with git log. There is a change, an author, and a date. You are not digging through a database trying to reconstruct history.
Here is the console before and after. Note that Agents is a top-level section under IDENTITIES , a sibling of Users, Groups and Roles — not a checkbox buried in a user profile:
The agent has its own ID, its own row and its own lifecycle. And the roles I declared in YAML show up as real objects.
The separation goes all the way down to storage. CATEGORY is agent, not user.
Why separation matters
Once the agent has its own record, four things become possible.
The kill switch is the one that convinces people. If the agent is just a credential saved on Alice’s account, switching it off disrupts Alice. If it is a separate identity, you disable it and she never notices.
There is a quieter point hiding in that schema too. modelProvider and function are facts you could write policy against. "This agent is an assistant running claude-opus-5" is exactly the sort of thing you might use to decide permissions on a per-request basis. The data model already knows more than the authorization model uses. That turns out to be the theme of this whole project.
Part 3: One door opens, one does not
The API is a small Flask service with two endpoints and one rule each. The only choice that really matters is that it verifies tokens instead of just decoding them.
signing_key = jwks_client.get_signing_key_from_jwt(token).key
claims = jwt.decode(
token, signing_key,
algorithms=["RS256", "ES256"],
audience=RESOURCE_ID, # minted for THIS api
issuer=discovery()["issuer"], # by THIS server
)
Decoding without verifying works and passes every test you write. It is also worthless as evidence. Anyone can open jwt.io, type {"scope": "calendar.read"} and walk straight in.
My first token request failed and the failure was more interesting than the success would have been.
{"error":"invalid_target",
"error_description":"No resource parameter supplied and
no default resource server is configured"}
ThunderID will not issue a token until it knows which API the token is for. Every token is stamped with its audience and is useless anywhere else. Remember this. It comes back later as half of a strange inconsistency.
Once I supplied resource the demo worked.
GET /calendar -> 200 OK
GET /messages -> 403 DENIED
{"error":"insufficient_scope",
"required_scope":"messages.read",
"granted_scopes":["calendar.read"]}
Same agent. Same script. Same credentials. One door opens and one does not and the only thing that decides it is a signed token.
The check that mattered more than the demo
What happens if the agent asks for more than it is allowed?
requested: calendar.read messages.read
granted : calendar.read
HTTP 200
No error. The extra scope just did not make it into the token. ThunderID compared what was asked for against what the agent is allowed and kept only the overlap. That is exactly the behaviour I wanted to see.
Then I tried the odd version of that. Ask only for something it is not allowed to have.
requested: messages.read
HTTP 200
{"access_token":"eyJ...","token_type":"Bearer","expires_in":3600}
Look carefully. There is no scope field at all. Not an error, not an empty string. It is simply missing. A valid, properly signed token that grants nothing. (I checked that it really is powerless, 403 at /calendar with granted_scopes: [].)
That is a sharp edge. If someone writes an API that treats a missing scope claim as "no restrictions," it will let everything through. Mine assumes the opposite.
def scopes_of(claims):
# A missing `scope` means zero scopes, never unrestricted.
return [s for s in claims.get("scope", "").split(" ") if s]
“200 OK” can mean you have been given nothing. The status code tells you the request was well-formed. Only the scope claim tells you what the token can actually do.
Watching ThunderID carefully trim an over-broad request here also set a trap for me. It made me assume it would do the same thing during delegation.
Part 4: One token, two parties
This is the phase the whole setup existed for.
The first obstacle was that there is no password grant, so there was no quick way to log Alice in from a script. I had to follow the real browser flow over HTTP. Authorize, flow engine, credentials, assertion, callback and token. One detail cost me twenty minutes. The flow step needs an action field saying where to go next. Leave it out and you get HTTP 200 with flowStatus: INCOMPLETE and the login form again, which looks exactly like a wrong password.
Then the exchange itself.
POST /oauth2/token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=<Alice's token>
actor_token=<the agent's token>
requested_token_type=urn:ietf:params:oauth:token-type:access_token
scope=calendar.read
resource=https://localhost:9000/api
HTTP 200 and the token that came back was the thing I came for.
{
"sub": "02900000-...-0002",
"act": { "iss": "https://localhost:8090",
"sub": "02900000-...-0003" },
"client_id": "agent-scheduler",
"scope": "calendar.read"
}
Two parties named in one token. The API sees both at once and neither is hidden behind the other. That is the difference between delegation and impersonation written into the data, rather than just described in a design document.
And it is not only a naming convention. Drop actor_token from the same request and act disappears completely. The token then just says Alice. ThunderID will do either one. Impersonation is available. It simply is not what you get when you ask for delegation.
Then:
Refused at /messages while working for a user who is allowed to read messages. That was exactly the sentence I wanted to be able to write.
Part 5: The check I almost didn’t run
Before writing it all up, one thought kept nagging at me. I had asked for calendar.read. I never checked whether I had to.
So I ran the same exchange again and asked for something the agent has no right to.
There it is.
Leave out scope completely and the agent inherits Alice's entire permission set. Asking for nothing gets you everything.
So the limit in my first run was not the server holding a line. It was my own script being polite. I had proved that a well-behaved agent stays where it belongs, which is not a security property at all. An agent that has been compromised or tricked the exact thing delegation is supposed to protect you from just asks for more and gets it.
What makes this a finding
ThunderID does enforce this limit elsewhere. Same agent, same server and same scope requested.
The logic exists. It runs when the agent acts as itself. It does not run when the agent acts for a user. That is the one case where the agent reaches past its own identity and so it is the one case where you would most want the check.
To be fair, RFC 8693 leaves the resulting scope up to the authorization server and does not require narrowing it to the actor’s rights. This is a permissive default. But it does mean the carefully separated agent identity its own record, its own credentials, its own roles is quietly stepped around at the exact moment authority gets handed over.
Token exchange was standardised before AI agents were the reason anyone cared about it. Its original job was moving trust between services, where the actor is usually a trusted gateway. Agents flip that around. The actor is now the least trusted thing in the exchange. Its permissions are exactly what should limit the result.
One thing that does bound it
To be accurate about what is and is not enforced: the subject token’s scopes are a real limit. Same agent, same request, and only Alice’s token changes.
So “what was requested, narrowed by the subject’s scopes” is enforced. It is specifically the agent’s own scopes that never enter the calculation.
That leaves you a workaround. Issue user tokens with narrow scopes and an agent cannot pull more out of an exchange. But it is a workaround, not the property the docs describe. Every part of your system that logs a user in has to scope its tokens down correctly and keep doing it forever. And it still never looks at the agent’s permissions. Two agents with completely different access rights, exchanging the same user token, get identical results.
The audit log said it first
I built the audit trail expecting it to be a box-ticking exercise. It described the problem more clearly than anything on my screen.
Same agent, same endpoint, opposite outcomes. The only visible difference is the FOR USER column. On its own the agent is contained. Working for a human, it is not.
That second row is only possible to write because the token named two parties. With impersonation every row would say “alice,” and “which agent did this and under what permission?” would be unanswerable. Not difficult but unanswerable because nobody ever recorded it.
That is the practical case for delegation over impersonation, and I find it more convincing than the theoretical one. It is not that impersonation is philosophically wrong. It is that it throws away the evidence.
Part 6: The dive in to code
I checked what ThunderID’s own documentation says is supposed to happen.
The token exchange reference (v1.0.1, guides/protocols/oauth-oidc/token-exchange.mdx):
“Final scopes must also be permissions on the target resource server and authorized for the issuing app or agent .”
It says plainly that the agent’s own permissions limit what it gets.
So one of us was wrong. ThunderID is open source, so I went and looked.
backend/internal/oauth/oauth2/granthandlers/token_exchange.go
func (h *tokenExchangeGrantHandler) getScopes(
tokenRequest *model.TokenRequest,
subjectScopes []string, // <-- only the SUBJECT's scopes arrive here
) ([]string, *model.ErrorResponse) {
if tokenRequest.Scope == "" {
return subjectScopes, nil // no scope requested -> ALL of the subject's scopes
}
// ...filter requested scopes to those present in the subject token
}
What actually happens is
requested ∩ subject-token scopes ∩ target resource server scopes
Three things. The fourth one the docs promise, the agent’s own permissions, is not there.
The product documents a security property and does not implement it. Someone building an API after reading either page would reasonably believe an agent limited to calendar.read cannot come back holding messages.read. They would be wrong.
So is it just ThunderID?
No. And this is where it gets interesting.
I had WSO2 Identity Server 7.3.0 installed, a mature IAM platform with fifteen years behind it. I rebuilt the same setup there. alice with three scopes, an agent with only calendar.read and token exchange switched on.
Two things were different and one was not.
IS limits the agent acting as itself , same as ThunderID. A tie.
IS refuses delegation by default. Where ThunderID hands a delegated token to any actor that can authenticate, IS said no.
Impersonation request rejected — impersonator: agentsched, subject: alice
Error: Authenticated user doesn't have impersonation permission for client
IS requires the actor to hold internal_user_impersonate and it creates the may_act binding itself after checking the actor is allowed. ThunderID accepts an actor_token the caller supplies and checks nothing. Deny by default versus allow by default. IS grants the delegation authority. ThunderID takes your word for it.
But once it is allowed, IS gives out exactly what ThunderID gives out.
An agent holding one scope got every scope its user holds. Same behaviour, different product.
So the difference is the gate , not the limit.
Two separate implementations of the same standard and neither one limits delegated scope by the actor’s own rights. One of them says in writing that it does.
That is a far better finding than “a product has a bug.” It says the limit is understood to be a good idea someone wrote it into ThunderID’s documentation, and is still missing from both products. The intention is there. The mechanism is not.
A debugging note worth sparing you
Getting IS to finish this took much longer than it should have, for two reasons.
Leaving out actor_token gives you this
{"error":"invalid_request",
"error_description":"Invalid Subject Token. Subject token is not ACTIVE."}
The subject token is fine. The actor token is the one that is missing. I spent a long time chasing token persistence settings, including a server restart, because the error pointed at the wrong parameter. The answer is in the docs, use response_type=id_token subject_token and the id_token you get back is the actor token you send in.
Second, internal_user_impersonate is a tenant-level permission. Add it to an application -level role and you get 200, it shows up on the role and it survives a restart but it never actually takes effect. It has to be an organization -level role. Silently accepting a permission that does nothing cost me four failed attempts.
What I would tell you to take away
Agent identity in ThunderID is real. I came in sceptical and it held up completely. Separate entity type, agent-shaped schema, its own credentials, its own roles, configuration as code and a server that starts in a tenth of a second. None of it is a service account with a new label.
Traceability and containment are different things and only one of them is delivered. The act claim tells you who acted. It does not stop them. An agent limited to calendar.read can reach everything its user can as soon as it acts for that user, and the audit log will faithfully record it doing so. Both products I tested behave this way and one of them documents the opposite.
Watch the asymmetry. The same server refuses to issue a token without a resource parameter. Narrowing by audience is required and fails safe. Narrowing by the actor's permissions does not happen at all and fails open. Two decisions about narrowing, opposite defaults, one product.
And the one about method, which is why this post exists. My first run succeeded. 403 at /messages, exactly the result I was hoping for, from a real server with a real transcript. It was only true because I had politely asked for less than I could have had.
A passing test and a broken security property produced identical output.
When a test passes because you asked nicely, you have tested your own politeness.
There is a second version of the same lesson. It cost me nothing and nearly cost me the finding. C heck the documentation against the behaviour, then check the source against both. The docs alone would have convinced me the limit was there. The measurement alone looked like a defensible default. Only putting all three side by side showed what was really going on.
The question is never “did the restriction hold?” It is “what happens when something tries to break it?” I nearly skipped that check. It is now the only thing in this project I would stake anything on.
Honest limitations
This is a local test sandbox, not a deployment. Self-signed certificates, verification switched off, two identities, a fake API with two endpoints and no attempt at load, multi-tenancy or federation.
ThunderID is a new project, and its docs may lag its code. Several things here I could only find by reading the binary and the shipped YAML. Both results are default behaviour. Whether some policy setting can add the missing limit in either product is a question I have not answered.
None of that makes the direction less interesting. Agent identity is going to matter and ThunderID is one of the earliest serious attempts to design for it directly instead of bolting it onto a system built for people.
Code and resources at:

















Top comments (0)