Security

Testing an Agent Fails Closed Without Its Group Claim

Antonio Evans10 min read

A stylized agent (robot with a headset) stands at a locked door labeled 'Resource'.
Key points

Key points

  1. 1An empty or absent groups claim is a normal production state, so the agent must treat it as "no permissions" rather than "unknown, allow."
  2. 2The test must assert four separate refusals: tool invocation, data reads, state changes, and any downstream or delegated request.
  3. 3A denial that leaves no trace is an untested denial, so assert the exact log fields, the reason code, and which missing-claim variant was seen.
  4. 4Test the empty array, the absent key, and the null value separately, and prove the agent never falls back to a default group, a cached permission set, or a permissive mode.

Why "no groups" is a real state, not an edge case

Teams usually test two things: a token with groups, and a token with an invalid signature. The empty-claim state sits between them and is the one that quietly breaks. It is a normal production condition with structural causes, not an anomaly.

The clearest cause is the token size limit. Microsoft Entra ID caps group claims at 150 entries in SAML assertions and 200 in JWTs, and when a user exceeds that limit the directory omits the group claims from the token entirely rather than truncating them Microsoft's group claims documentation. The Zero Trust developer guidance repeats the same limits and states that when the user is a member of too many groups, "there are no groups in the token," and the app must resolve membership some other way Microsoft's Zero Trust token guidance. An application that treats a missing claim as "allow" grants access to precisely the accounts whose entitlements are hardest to reason about.

The same gap exists in plain OIDC. The OpenID Connect Core specification defines a small set of standard profile claims and does not require groups or roles at all, so a provider is free to omit them the OpenID Connect Core specification. A login can be entirely valid while carrying zero authorization data. A field writeup on this problem makes the point directly: groups and roles are not core OIDC login claims, and whether an ID token, an access token, or the UserInfo response carries them depends on the provider's token profile the analysis of missing OIDC group claims. Practitioners hit this often enough that "no groups claim and no hasgroups either" is a recurring support question, with the fix being correct claim configuration on the API registration rather than the client the Stack Overflow thread on missing group claims.

Truncation is a second mechanism that produces the same result. SiteMinder enforces a default 1024-character limit on attribute length and trims oversized group attributes before the assertion reaches the service provider, which can break downstream authorization silently Broadcom's note on trimmed SAML attributes. At the other end, AWS rejects SAML responses larger than 128 KB outright, which is another way a large group set stops arriving in usable form Okta's article on SAML response size limits.

So the agent will meet absent or empty claims for structural reasons: overage, misconfiguration, attribute truncation, a directory lookup that never ran, or a user who genuinely belongs to no mapped group. The design question is not whether to handle it but what the safe handling is. Community reports show how painful the wrong answer is: one team described having to abandon directory groups entirely after discovering the limit the hard way the Esri community discussion on the 150-group limit.

Fail closed versus fail open, in one decision rule

Fail closed means the system defaults to a blocked state when a check cannot be confirmed. Fail open means it continues as if the check had passed. The pattern is easiest to see in code. In the fail-open form, the authorization check raises only on an explicit denial and execution otherwise continues; in the fail-closed form, execution proceeds only when authorization is explicitly returned true, and everything else raises Authzed's explanation of fail open and fail closed. The second shape is what you want around a group claim.

For an agent, the decision rule is short:

If the groups claim is absent, null, or an empty array, the agent is authorized for nothing beyond actions that require no authorization at all, such as returning a structured error to its caller. No default group, no cached permission set, no legacy or debug mode.

Write that rule into the test file as a comment above the first assertion. It is the specification the tests enforce, and it makes code review of new tools trivial: anything that can act must appear in the denial matrix.

The reason to be strict is that the risk compounds once a credential is abused. Non-human identities, including agents, outnumber human identities by more than 90 to 1 in many organizations, and 92 percent of surveyed organizations said their legacy IAM could not effectively manage AI and non-human identity risk the Cloud Security Alliance Agent Identity Governance Framework. An agent that quietly inherits a default group on a missing claim is exactly the overprivileged case those numbers describe. The same framework recommends replacing standing agent privileges with intent-declared, time-bound, scope-limited grants, and that is only enforceable if the empty case denies.

What the agent must refuse: four refusal classes

A single assertion ("the agent returned 403") is not enough. An agent can return an error and still have done work. Split the test surface into four classes and assert each one independently.

Refusal classExample actionWhat to assertWhat must not happen
Tool invocationCalling a CRM lookup, a ticketing tool, or a shell toolTool call count is zero; the mock records no invocationThe tool runs and its output is discarded later
Data accessReading a document, a database row, or a bucket objectZero read calls on the data client; no bytes returnedA read happens and the model's context filters it
State changeCreating a record, sending a message, or opening a pull requestZero write calls; the target system is unchangedA queued write executes after the error
Downstream or delegated requestCalling another agent, an MCP server, or a webhookNo outbound request; no child agent spawnedA retry loop eventually sends with cached groups

The tool-invocation class matters because agents plan before they act, and a plausible-looking failure at the top level can hide a successfully completed tool call underneath. Mock the tool layer and assert on the mock's call log, not on the model's text. Guidance on detecting non-human identity abuse flags unusual tool calls and behavior outside the agent's expected pattern as signals, which only works if a baseline of "zero tool calls under denial" exists Sweet Security on non-human identity for AI agents.

The delegation class matters for multi-agent designs. If an orchestrator spawns sub-agents that carry their own tokens, a missing claim on the parent must not produce children that authenticate with a broader credential. The CSA framework notes that orchestrating agents spawn ephemeral sub-agents that each need scope definitions and tracked access, which makes the parent's denial the natural control point the CSA Agent Identity Governance Framework.

Worth testing explicitly: the "deny but retry" path. Some agents catch an authorization failure and retry with a different scope or a service account. Assert the retry budget is not spent re-authorizing with a broader credential. The failure mode is not a bad error message; it is a successful request under an identity nobody intended.

The log fields that prove a denial occurred

The refusal and the log are two halves of one test. A denial without evidence is indistinguishable from a crash, and during an incident review you need to show that the agent knew why it stopped and what it tried to reach.

Every denial entry should carry these fields:

FieldPurposeExample value
eventSeparates denials from generic errorsauthorization.decision
decisionThe outcomedeny
reason_codeMachine-readable causemissing_groups_claim
claim_stateWhich variant was seenabsent, null, or empty_array
agent_idThe non-human identity actingagent-support-triage-01
principalThe subject from the tokensub value, not the email
actionWhat was attemptedtool.crm.lookup
resourceThe targetcrm:accounts:read
trace_idCorrelates to the request and child spansW3C trace context value
policy_versionWhich policy build evaluated itauthz-2026.03

Two design choices are worth calling out. First, log the claim state as a distinct enum. empty_array and absent fail for different reasons and lead to different fixes: the first usually means the user genuinely has no mapped groups, the second usually means an overage or a broken claim mapping. Microsoft's remedy for the latter is to restrict emitted groups to those assigned to the application or to apply a group filter Microsoft's group claims documentation. Second, never log the raw token or the full group list at this level. Log the claim state and the decision and keep the assertion out of logs.

There is a governance reason to invest here, not only a debugging one. Every agent action should be logged alongside its identity context, which is the same argument made for SCIM-managed agent identities: the record of who acted and under what entitlement is the thing that makes audit possible LoginRadius on SCIM for AI agents. If your stack emits OpenTelemetry spans, model the denial as a span event with these attributes so it joins the rest of the trace instead of living in a separate log silo.

A step-by-step test procedure

Work in a fixture-driven harness so each case is a one-line parameter rather than a new test file. The procedure assumes the agent takes a token, extracts claims, and calls tools.

Step 1: Build four token fixtures. Same issuer, same audience, same subject, same expiry. Vary only the group claim:

A. groups = ["eng-readonly"]   (control, expect allow)
B. groups = []                 (empty array)
C. no groups key at all        (absent)
D. groups = null               (explicit null)

Add a fifth for overage if your provider models it: a token carrying only the overage indicator and no groups array. Microsoft documents the overage case as the token carrying an indication instead of the claim Microsoft's Zero Trust token guidance.

Step 2: Prove the harness can pass. Run case A first and assert the tool was called. If the control does not succeed, a later denial proves nothing. This is the most common mistake in fail-closed suites: the empty-state tests go green because every test is failing.

Step 3: Run cases B, C, and D against every refusal class. For each combination, assert the refusal, the absence of side effects, and the log entry:

def test_missing_groups_denies_tool_call(agent, fixtures, tool_mock, log_capture):
    result = agent.run(token=fixtures.groups_empty, task="look up account 4471")

    # 1. the refusal
    assert result.status == "denied"
    assert result.error.code == "authorization_failed"

    # 2. no side effects
    assert tool_mock.call_count == 0
    assert tool_mock.last_call is None

    # 3. the evidence
    entry = log_capture.single(event="authorization.decision")
    assert entry.decision == "deny"
    assert entry.reason_code == "missing_groups_claim"
    assert entry.claim_state == "empty_array"
    assert entry.action == "tool.crm.lookup"
    assert entry.trace_id == result.trace_id

    # 4. no secret material in the log
    assert fixtures.groups_empty.raw not in log_capture.raw_text

Parametrize claim_state across empty_array, absent, and null and run the same body. Keep the assertion on reason_code strict: a generic authorization_failed reason hides whether the claim was missing or the scope was wrong.

Step 4: Assert the negative on fallbacks. Configure the agent with a default group in its settings as a hostile fixture, run case C, and assert the default group was not applied. Then warm any permission cache by running case A, run case C, and assert the cached permissions were not reused. A cache keyed on subject rather than on claim content is a real bug class, and this test catches it.

Step 5: Assert the downstream boundary. Point the agent at a mock MCP server or a second mock agent and assert zero outbound requests for cases B, C, and D. If the agent retries, assert the retry budget is not spent re-authorizing with a different credential.

Step 6: Assert the error shape. The caller should receive a stable error code and a message that names the missing claim without leaking policy detail. Assert the status code (401 or 403, by your own convention) and that the body contains no group names or internal policy identifiers.

Step 7: Add the regression guard. Keep the fixtures in a shared module and fail the build when a protected action is registered without a corresponding denial test. A registry-driven check is worth more than any single test, because it stops the suite losing coverage as tools are added.

Testing the identity lifecycle around the claim

The empty-claim test is stronger when it sits inside a lifecycle test. SCIM provisioning creates, updates, and deprovisions agent identities with attributes that include authorization roles and scopes, and deprovisioning removes them from connected systems LoginRadius on SCIM provisioning. Two additions follow naturally:

  • After a simulated deprovisioning, issue a token with an empty groups array and assert the agent denies. This proves revocation reaches the authorization path, not only the directory.
  • After a permission change, assert the agent uses the new groups and not a cached set.

Aembit's framing is useful context: agents are non-deterministic and may take different actions on each attempt, so identity systems must enforce posture in an environment where the actor shifts context Aembit on AI agent identity. A deterministic suite around the one claim that gates everything is the cheapest way to bound that non-determinism.

One more guardrail: emergency or break-glass access should be its own explicit, logged path, not an accidental fallback for missing claims. Microsoft's emergency access guidance is instructive precisely because it keeps break-glass accounts narrow, cloud-only, and excluded from policies that would otherwise block them Microsoft's emergency access account guidance. If your agent needs an override, make it a named and audited mechanism that the test can distinguish from a bug.

Where this fits in a wider agent test plan

Treat the empty-claim suite as the first of three authorization suites. The second covers claim manipulation: tampered signature, wrong audience, wrong issuer, expired token. The third covers scope narrowing, where groups are present but the requested action falls outside them. All three should share the fixture builder and the log assertions, so a denial looks identical to the harness no matter which check fired.

There is a timing dimension too. An assertion is valid only inside its time window, and clock skew between issuer and validator can push a valid token outside it, producing failures that look intermittent and are often reported as vague "assertion expired" errors the guide to SAML clock skew in Keycloak. That belongs in the token-manipulation suite, and it is worth keeping separate from the empty-claim suite so a clock problem never masquerades as a missing-claims result.

When the suites are ready to run against real models rather than mocks, the practical constraint becomes access to many model endpoints and consistent logging across them. Quanta Inference puts text, image, video, audio and embedding models behind one OpenAI-compatible API key with prepaid US dollar pricing, which lets the denial tests run against every model your agent may use without a separate integration per provider.

FAQ

FAQ

Should the empty groups claim return 401 or 403?

Either is defensible if it is consistent. A 401 says the caller is not authenticated for this resource; a 403 says the caller is authenticated but not permitted. Because the token itself is valid and only its authorization content is empty, 403 with a stable error code reads more accurately and is easier to distinguish from signature failures on a dashboard.

How do we test the overage case if our test directory cannot hold hundreds of groups?

Do not build the overage in the directory. Hand-craft the token fixture to match the shape your provider produces in overage, which is typically a token with the overage indicator and no groups array, and treat it as a variant of the absent-claim case. Check the provider's documented limits and indicator name, then pin the fixture in source control.

The agent works when we test by hand but denies in CI. What is the usual cause?

Almost always the control case. The CI fixture is missing a group the local token had, or the harness mocks the identity provider but not the directory lookup that normally fills in groups. Run the allow control first and assert the tool was called; if that fails, every denial result below it is meaningless.

Should the denial log include the group names that were present?

If the claim is absent, there are none. For partial cases where groups are present but insufficient, log the group identifiers only if your retention and access policy allows it, and never log the raw assertion. The safer default is to log counts plus the policy identifier that rejected the request, which is enough to reproduce the decision.

How often should this suite run?

On every pull request that touches the authorization path, and at least nightly against a real identity provider rather than only mocked tokens. Mocked claims prove your code handles the shape; a live token proves your provider still emits the shape you expect, which is where truncation and overage changes first surface.

Sources

Sources

  1. Configure group claims for applications by using Microsoft ...
  2. Configure group claims and app roles in tokens
  3. Final: OpenID Connect Core 1.0
  4. Why OIDC Group or Role Claims Are Missing and Where to Find ...
  5. oauth 2.0 - Azure OAuth2 Code Flow - no groups claim in access token ...
  6. "Response attribute will be trimmed" warning for SAML ...
  7. AWS Account Federation Fails Due to SAML Response ...
  8. Add support for retrieving SAML groups, when Azure AD is ...
  9. Understanding "Failed Open" and "Fail Closed" in Software ...
  10. Agent Identity Governance Framework, Lab Space
  11. Understanding Non-Human Identity for AI Agents
  12. Using SCIM to Provision and Govern AI Agents
  13. What Kind of Identity Should Your AI Agent Have?
  14. Manage emergency access accounts in Microsoft Entra ID
  15. SAML Clock Skew in Keycloak
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles