Skip to main content

Per-Developer Identity

By default a governed request is attributed to the client that made it — the org, tenant, and license key. That is enough to isolate one customer's data from another, but it does not tell you which developer ran a given tool call, or which AI-tool session it belonged to. Per-developer identity closes that gap: when a developer's email is supplied on a request, AxonFlow records it on the canonical audit record so the customer portal's User column, the audit User filter, and the per-user views on the Claude Code dashboard resolve to an individual instead of a shared synthetic id.

The two identity layers

Per-developer identity is two independent layers, carried by two different values, and it matters not to conflate them:

Layer 1 — AttributionLayer 2 — Authorization
Carried byX-User-Email header (from AXONFLOW_USER_EMAIL)X-User-Token header (from AXONFLOW_USER_TOKEN) — a per-user token
Trust modelAsserted — any caller can set it; honored only behind the trust gateValidated — signature, expiry, and revocation are checked on every request; non-forgeable
What it controlsWho a governed request is logged as — the portal User column, audit filters, per-user dashboardsWhich cross-user rows the caller may read — audit events, decisions, overrides — via the caller's validated role
Sinceplatform v9.3.0 (headers), v9.9.0 (trust gate)platform v9.10.0

The rest of this page up to Authorization covers layer 1. Layer 2 — per-user tokens and role-scoped reads — is covered in its own section below.

Enterprise feature

Per-developer identity is an Enterprise capability of the customer portal and audit surfaces. The attribution layer is asserted, not authenticated — supplying an email improves audit visibility, it is not a login, and on platform v9.10.0 and later an email alone never unlocks other users' data: reading beyond your own rows requires a validated per-user token with an admin role. Access to the platform is still governed by the license key; the org and tenant on every stored row come from that license, never from the supplied identity, so a developer cannot cross a tenant boundary by changing their email.

What gets recorded

Two fields land on the canonical audit_logs decision record when they are supplied:

  • user_email — the individual developer, taken from the X-User-Email request header.
  • session_id — the AI-tool session that produced the request, taken from the X-Session-Id request header. Platform migration core/129 adds a nullable session_id column to audit_logs; it is purely additive and backward-compatible.

Both are read from request headers only and are never trusted from a request body, so a payload cannot spoof another developer's identity into the audit trail. On platform v9.9.0 and later, both are recorded consistently on all four governance planes — /api/v1/decide, MCP check-input, MCP check-output, and the MCP-server tools/call path — when the deployment opts in via the trust gate below; the headers are attribution-only and never influence a verdict, an authorization decision, or the tenant/org a row is stored under.

The trust gate (platform v9.9.0+)

Because any governed caller can set X-User-Email, the platform does not trust the header by default — an unvalidated header would let one caller forge another principal's audit identity. Per-developer attribution therefore requires a one-time, deployment-level opt-in on the agent:

# Only the exact string "true" opts in; unset/false (the default) ignores the headers.
AXONFLOW_TRUST_IDENTITY_HEADERS=true

Set it only when every path to the agent's governance endpoints goes through a hop that controls the headers — a managed plugin fleet (AXONFLOW_USER_EMAIL via MDM), the Claude Desktop proxy (AXONFLOW_LEADER_EMAIL), or a gateway that re-sets them from validated token claims. This is the same knob the agentgateway adapters use — one contract, set it in both places.

With the gate off (the default), identity headers are ignored on every plane: attribution falls back to the validated client identity, and the agent logs a once-per-process warning (received identity headers (X-User-Email) but AXONFLOW_TRUST_IDENTITY_HEADERS is off …) so the degradation is never silent. Platforms with the gate advertise the identity_header_attribution capability in GET /health.

Upgrading from ≤ v9.8.x

Before v9.9.0 the MCP check-input and MCP-server planes honored X-User-Email unconditionally — which is exactly the forgeable-header exposure the gate closes. If your fleet relies on per-developer attribution (or per-user session overrides), set AXONFLOW_TRUST_IDENTITY_HEADERS=true when you upgrade, or the portal's User column will fall back to the client-scoped identity.

When no email is supplied, the row falls back to the client-scoped synthetic id mcp-client:<client-id> — never a blank User column. So attribution degrades gracefully: developers who have configured an email show up by name, and everyone else rolls up under the client identity.

Client version requirement

The platform side of per-developer identity — recording user_email and session_id from the request headers, migration core/129, and the synthetic fallback — ships in platform v9.3.0. The client side that emits those headers ships in the Claude Code plugin v1.8.0 and Claude Desktop proxy v0.3.0, both of which pair with platform ≥ v9.3.0. Older clients (Claude Code plugin ≤ v1.7.0, Desktop proxy ≤ v0.2.1) do not send the headers, so their activity attributes to the synthetic mcp-client:<client-id> id rather than a named developer until you upgrade.

Configuring the Claude Code plugin

On v1.8.0 and later, the Claude Code plugin resolves a developer email in this order and sends it as the X-User-Email header on every governed request (the MCP connection and both the pre-tool and post-tool hooks):

  1. the AXONFLOW_USER_EMAIL environment variable — the supported source;
  2. best-effort fallback to git config user.email;
  3. otherwise unset — the header is omitted and the row falls back to the synthetic client id.

Set it in the developer's shell profile:

export AXONFLOW_USER_EMAIL="[email protected]"

The session_id half needs no configuration: the plugin's PreToolUse and PostToolUse hooks forward Claude Code's own session_id (from the hook input) as X-Session-Id automatically, so every audit row carries the developer alongside the session they were working in. The MCP connection itself has no per-call session id and is unchanged.

The git fallback caveat

The git config user.email fallback is best-effort, and it only resolves at all when git is installed and a user.email is actually configured on the machine — on a fresh laptop, container image, or CI runner neither is a given, and the plugin then (correctly) sends no identity at all. When it does resolve, it is the git identity, not the Anthropic account: it may be a shared bot identity on CI, or a personal address that differs from the corporate directory. And because the fallback reads the merged git configuration, it is adversarially influenceable: a repository obtained as an archive can ship a .git/config whose user.email the fallback would then assert on that developer's audit rows (a normal git clone cannot inject one — config files are not cloned). Opening a repository can therefore influence the git-fallback identity, so it must not be relied on for trustworthy attribution. For reliable, uniform attribution across a fleet, setting AXONFLOW_USER_EMAIL explicitly via managed settings is required — do not rely on the git fallback.

On plugin releases after v1.8.0, the fallback is more resilient — when the read from the working directory fails (a corrupt repository config, a deleted working directory), the plugin explicitly retries the global user.email instead of silently giving up — and neither degraded state is silent: when no identity resolves, the plugin's hooks print a one-line notice to stderr (at most once per day) explaining why attribution is degrading to the client-scoped id and how to fix it, and when the identity came from the git fallback, they print a notice naming the exact address being asserted and that its source is unverified and repository-influenceable — at most once per day per asserted identity, so a same-day identity change re-fires it immediately (a second same-day notice naming a different address is the repo-influenced red flag to look for). Suppress the notices with AXONFLOW_IDENTITY_NOTICE=off if your fleet intentionally runs without per-developer identity. None of this changes the guidance: the fallback is a convenience for individual developers, not a fleet rollout mechanism.

Configuring the Claude Desktop proxy

On v0.3.0 and later, the Claude Desktop MCP governance proxy sends the developer identity as X-User-Email from its AXONFLOW_LEADER_EMAIL configuration value, and forwards the Desktop session as X-Session-Id. Set AXONFLOW_LEADER_EMAIL in the proxy's managed configuration so Cowork and MCP-tool activity is attributed to the leader.

Leader attribution requires a platform at v9.9.0 or later with the trust gate enabled. The proxy calls the two planes (/decide and MCP check-output) that ignored the identity headers on earlier platforms, so against ≤ v9.8.x its rows attribute to the authenticated client (<client-id>@axonflow.local) even when AXONFLOW_LEADER_EMAIL is set. See Why do I see mcp-client:… instead of an email?.

Setting the email across a fleet

For a managed fleet, distribute AXONFLOW_USER_EMAIL per developer through your MDM or fleet-configuration tooling so each machine carries its own developer's address. Because the value is per-person, it must be owned by the per-login mechanism your MDM provides (a run-on-every-login command or a per-user managed setting), not a static, machine-wide policy that would apply the same address to every user of a shared host. Your fleet-deployment runbook covers the platform-specific recipe (macOS configuration profile / command, Windows registry / command, Linux command) for injecting a per-developer value.

OTEL-ingest attribution (Cowork & Claude Code telemetry)

There are two independent identity paths, and they use different sources:

  • The plugin / proxy path (above) attributes a request from the X-User-Email header, which the Claude Code plugin and Desktop proxy populate from AXONFLOW_USER_EMAIL (or the git fallback). You control the value.
  • The OTEL-ingest path (Cowork & Claude Code OTEL Ingest) attributes a stored record from the user.email attribute the client puts on its own telemetry stream. You do not set this value — Claude Code / Cowork emit it, and only when the acting user is signed in with their Anthropic account (OAuth / managed login). On the AxonFlow side, reading that per-record user.email (and the session.id and Anthropic account identifiers alongside it) is available on platform v9.5.0+.

Because the OTEL user.email comes from the client's sign-in, whether per-member attribution is possible on that plane depends on how the client authenticates:

Client authenticationuser.email on the streamStored attribution
Anthropic account login (OAuth / managed sign-in)present on every log record and metric datapointthe real member's email
API key (ANTHROPIC_API_KEY)absent — along with organization.id and the Anthropic account identifiers; only session.id and an anonymous device user.id remainfalls back to [email protected], still keyed by session_id

So a fleet whose Cowork / Claude Code users authenticate with a shared API key cannot be told apart on the OTEL-ingest plane — every row attributes to [email protected]. This is a Claude Code / Cowork client behavior (an API key is not tied to a person), not an AxonFlow ingest gap: the ingest already reads user.email when the client sends it, and no ingest change can recover an email the client never emits. To get per-member attribution on this plane, have the fleet sign in to Cowork / Claude Code with their Anthropic account rather than a shared API key. (For the plugin / proxy plane, AXONFLOW_USER_EMAIL is the independent lever and works regardless of how the client authenticates to Anthropic.)

Reserved fallback address

[email protected] is the OTEL-ingest plane's client-agnostic fallback, analogous to the mcp-client:<client-id> fallback the plugin / proxy plane uses — the row is still stored, redacted, signed, and session-keyed; it just isn't attributed to a named person.

Authorization: per-user tokens and role-scoped reads

Everything above is attribution — who a governed request is logged as. On platform v9.10.0 and later, Enterprise deployments get the second layer: authorization. A per-user token is a validated, non-forgeable credential that resolves to a {identity, role} pair on every request, and that role decides which cross-user governance rows the caller may read — audit events, decision history, and policy overrides, whether they arrive through the MCP read tools (search_audit_events, list_recent_decisions, list_overrides, get_policy_stats, explain_decision) or a direct API call to the same endpoints.

The distinction from attribution is the point of the design: X-User-Email is asserted, so it can label rows (and, behind the trust gate, key the caller's own-rows view) but can never widen visibility; X-User-Token is validated (signature, expiry, and server-side revocation are checked on every request), and only its role can grant cross-user reads. An email alone never grants access to anyone else's rows.

Role → read scope

Resolved identityAudit / decision / override reads
Token with admin, owner, or policy_admin roleFull tenant — every user's rows
Token with developer or viewer roleOwn rows only — rows attributed to their own identity
No token; asserted email behind the trust gateOwn rows only — capped at that email's attributed rows
No per-user identity at all (shared credential alone)Zero rows — fail-closed

These five roles also carry a separate set of portal permissions — which is why viewer reads own-rows here but tenant-wide in the portal console. Both planes are defined side by side in Roles & Permissions.

Three properties worth knowing:

  • Enforced server-side, in the query. The scope is applied where the rows are selected, not filtered client-side afterward — so a direct API call with the shared tenant credential is covered exactly like the MCP tools, and caller-supplied filters can narrow a non-admin's view but never widen it to a colleague's rows.
  • Invalid tokens are rejected, never downgraded. A per-user token that is present but tampered, expired, revoked, or minted for another org fails the request with 401 — it is never silently treated as "no token".
  • Own-rows completeness follows attribution. A developer's own-rows view contains the rows that were attributed to them when written. Rows written before your fleet rolled out per-user identity carry the client-scoped synthetic id, so they belong to no individual and a non-admin will not see them; admins see everything either way.

Whole-tenant compliance exports (evidence packs, regulator audit exports, and generating or downloading a compliance report) are admin-gated rather than scoped - a per-user "own rows" export is not a meaningful compliance artifact, so a non-admin caller is denied rather than served a subset. Note this gates the export, not the read: a caller with audit:read (including a viewer) can still view and poll compliance report status; only generating and downloading the artifact requires administrator authority. See Roles & Permissions for the view-versus-export split.

Behavior change in v9.10.0 — token-less fleet callers read zero rows

Before v9.10.0, any holder of the shared org:license credential could read the entire tenant's audit trail, decisions, and overrides. From v9.10.0, a fleet caller with no per-user identity reads zero rows from those surfaces, and an asserted email (behind the trust gate) is capped at its own rows — never the tenant pool. To restore tenant-wide reads, provision per-user tokens for your fleet and give reviewers an admin-role token. Enforcement — allow / deny / redact on tool calls — is unaffected; this changes read visibility only.

This is fleet identity, not portal login

The OIDC on this page (Path B) verifies a per-user token carried on governed API calls; it does not sign a human into the portal UI in a browser. Browser OIDC portal login is a separate plane, configured separately with client credentials. See the three identity planes and Portal Login (SSO).

Provisioning per-user tokens

Two provisioning paths converge on the same validated {identity, role}:

  • Path A — AxonFlow-managed (no IdP required). Your AxonFlow admin mints, rotates, and revokes per-user tokens from the admin API (POST /api/v1/admin/organizations/{org_id}/user-tokens), assigning the role at mint. Tokens always carry an expiry (default 30 days, capped at 1 year), and revocation is a server-side deny-list consulted on every validation.
  • Path B — IdP-issued (OIDC). Your identity provider (JumpCloud, Okta, Azure AD, …) issues each developer a standard OIDC token; AxonFlow validates it against the IdP's JWKS, and the role comes from the SCIM-synced directory, never from a claim inside the token — so an IdP misconfiguration cannot escalate anyone to admin. A developer with no mapped role resolves to least privilege.

The full provisioning runbook — mint/rotate/revoke examples, the JumpCloud OIDC walkthrough, and SCIM group→role mappings — lives in the Per-User Token Provisioning guide. The complete read-scoping model is covered above.

Configuring your client to send the token

Most integrations send the token as the X-User-Token header on every governed request; set it per developer, alongside the fleet-wide tenant credential:

ClientHow to configureMinimum version
Claude Code pluginAXONFLOW_USER_TOKEN env var, or a 0600 token file1.10.0
Cursor pluginAXONFLOW_USER_TOKEN env var (expanded by the plugin's mcp.json header)1.6.0
Codex pluginAXONFLOW_USER_TOKEN env var + X-User-Token entry in config.toml1.6.0
OpenClaw pluginuserToken plugin config, AXONFLOW_USER_TOKEN env var, or a 0600 token file2.7.0
Claude Desktop proxyUser token extension field (AXONFLOW_USER_TOKEN) — sent as the body user_token fieldany release
LiteLLMuser_token per call / default_user_token in configany release (fail-closed posture: 1.0.4)

One token-type caveat: the planes the Claude Desktop proxy and LiteLLM call validate the admin-minted (Path A) token only — an IdP-issued OIDC token is accepted on the X-User-Token header surfaces the coding-tool plugins use, not on the body user_token planes.

In the coding-tool plugins and the Desktop proxy, leaving the token unconfigured leaves the wire behavior unchanged — governance (allow/deny/redact) works exactly as before; only the read scope above applies. The exception is LiteLLM, which sends a placeholder token by default: Enterprise deployments validate and reject it, so LiteLLM against an Enterprise platform needs a real minted token — see the LiteLLM page.

Like AXONFLOW_USER_EMAIL, the token is per-person: deliver it through a per-device or per-login mechanism (MDM managed settings, a provisioning script writing the 0600 token file), never as one fleet-wide value — a shared token would give every developer the same identity and role.

Where the identity shows up

Once AXONFLOW_USER_EMAIL is set, per-developer attribution flows through to:

  • Audit logs — the portal User column resolves to the developer, and the User filter matches on their email. See Reading the audit logs in the portal.
  • Usage & Analytics — governed Claude Code traffic is counted per organization; per-developer identity surfaces in the audit logs and the Claude Code dashboard, not in the usage totals (usage events are org-scoped and do not carry the developer email). See Portal Operations.
  • The Claude Code dashboard — the per-user Grafana panel breaks decisions down by developer and verdict. See the Grafana Dashboard reference.

Why do I see mcp-client:… instead of an email?

A User column value of mcp-client:<client-id> — or, on other enforcement planes, a reserved-domain address like <client-id>@axonflow.local — is the platform's client-scoped fallback: the row was attributed to the client rather than to a person, either because the request carried no X-User-Email header or because that enforcement plane does not record the supplied email yet. The portal marks these values with a client badge, and shows a note above the Log Explorer whenever the current results contain one. It usually means one of three things:

  1. The client predates per-developer identity. The identity headers are emitted by Claude Code plugin ≥ 1.8.0 and Claude Desktop proxy ≥ 0.3.0 (paired with platform ≥ v9.3.0). Anything older — plugin ≤ v1.7.0, proxy ≤ v0.2.1 — never sends them, regardless of how the environment is configured. Upgrade the client first.
  2. No email is configured on an up-to-date client. Set AXONFLOW_USER_EMAIL in the developer's environment (the plugin's git config user.email fallback also resolves an email, but see the git fallback caveat).
  3. The platform's trust gate is off (platform ≥ v9.9.0). Identity headers are ignored by default; set AXONFLOW_TRUST_IDENTITY_HEADERS=true on the agent — the agent log's detection warning confirms this is the cause.
  4. The row came from a plane that did not record the email on your platform version. On platforms ≤ v9.8.x, the Decision-Mode request plane (/decide) and MCP check-output attribute to the authenticated client even when the header is supplied — the two planes the Claude Desktop proxy uses, so proxy traffic shows the client identity on those versions. Fixed in v9.9.0 (with the trust gate on).

Rows written before an upgrade keep their synthetic identity: attribution applies from the moment the client starts sending the header, and historical audit rows are never rewritten.