Per-User Token Provisioning
Fleet deployments historically authenticated every developer's plugin with a single shared tenant credential (org_id:license). That gives attribution, not authorization: anyone holding the shared credential can assert any identity. Per-user tokens give every developer a validated, non-forgeable {identity, role} that AxonFlow's fleet plane uses for role-scoped access — so a developer reads only their own audit, decision, and override rows while an admin reads the whole tenant trail.
This page is the provisioning runbook. For the read-scoping model those tokens unlock — the role→scope rule, the enforcement layers, and the v9.10.0 behavior change — see Per-Developer Identity → Role-scoped reads.
The two paths
Two provisioning paths converge on the same validated identity. Pick by whether you run an identity provider — you do not need one to start.
| Path A — AxonFlow-managed | Path B — IdP-issued (OIDC) | |
|---|---|---|
| Token issuer | AxonFlow admin API (HS256) | Your IdP: JumpCloud, Okta, Azure AD… (RS256/JWKS) |
| Role source | Admin-assigned at mint | SCIM group→role mappings (never the token's own role claim) |
| Revocation | Server-side deny-list, immediate | IdP token lifetime + IdP-side deactivation |
| Best for | Teams without an IdP; evals; break-glass | IdP-managed fleets |
Path A is not a stopgap — it is the permanent no-IdP tier, and the fastest way to get role-scoped reads for a fleet today.
Path A is available on platform v9.10.0 and later. Path B (OIDC) requires platform v9.11.0 or later — it depends on the fleet-mappable system roles and the self-hosted (in-vpc) OIDC key fix described in its prerequisites below. On v9.10.0, use Path A.
Path A — Admin-minted per-user tokens
Prerequisites
- The customer-portal carries
JWT_SECRET(the same value as the agent — minted tokens are validated agent-side) andADMIN_API_KEY. - Platform v9.10.0 or later (the revocation deny-list and role-scoped reads).
The role travels as a JWT claim, so whoever can mint sets the role. Mint, rotate, and revoke therefore require a valid X-Admin-API-Key — even in deployment modes where admin auth is otherwise optional (in-vpc). If ADMIN_API_KEY is not configured, the endpoints refuse with 401/503; they never fall open.
ADMIN_API_KEY is the same credential that gates org and license management, and it works across every org — it is now also a "mint an admin-role token for any org" credential. Rotate it on a schedule, restrict it to operators, and never embed it in a fleet-distributed config.
Mint
curl -X POST "$PORTAL_URL/api/v1/admin/organizations/{org_id}/user-tokens" \
-H "X-Admin-API-Key: $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"user_email": "[email protected]", "role": "developer", "ttl_hours": 720}'
Response (201) — the token is returned exactly once and is never stored or logged:
{
"success": true,
"user_token": {
"token": "eyJ...",
"jti": "f3b0…",
"user_email": "[email protected]",
"role": "developer",
"org_id": "yourco",
"expires_at": "2026-08-15T00:00:00Z"
}
}
rolemust be one ofadmin,owner,policy_admin,developer,member,viewer.ttl_hoursdefaults to 720 (30 days), capped at 8760 (1 year). Every token carriesexp— unbounded tokens cannot be minted.- Emails are canonicalized (lower-cased, trimmed) at mint, validation, and revocation, so audit attribution and read-scoping key on the same value.
Rotate
curl -X POST "$PORTAL_URL/api/v1/admin/organizations/{org_id}/user-tokens/rotate" \
-H "X-Admin-API-Key: $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"user_email": "[email protected]", "role": "developer"}'
Rotation revokes every existing token for the user (issued before the rotation instant), then mints the replacement. Revocation granularity is one second — a token minted in the same second as the rotation survives it.
Revoke
# One token (by jti — from the mint response or the MINT_USER_TOKEN audit row):
curl -X DELETE "$PORTAL_URL/api/v1/admin/organizations/{org_id}/user-tokens" \
-H "X-Admin-API-Key: $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"jti": "f3b0…", "reason": "laptop stolen"}'
# All of a user's tokens (offboarding):
curl -X DELETE "$PORTAL_URL/api/v1/admin/organizations/{org_id}/user-tokens" \
-H "X-Admin-API-Key: $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"user_email": "[email protected]", "reason": "offboarded"}'
Revocation is enforced server-side on every validation: the deny-list is consulted before a token is accepted, and if the deny-list is unreachable the token is rejected — fail closed. Every mint, rotate, and revoke writes an admin audit row (actor, target user, role, ttl, jti); the token value itself never lands in any log or audit row.
Fleet-scale delivery
Deliver each developer's token through your MDM's managed-settings file so it never transits chat or email. For the Claude Code plugin:
// /Library/Application Support/ClaudeCode/managed-settings.json (per device)
{
"env": {
"AXONFLOW_USER_TOKEN": "<that developer's token>"
}
}
Scope the payload per device/user in your MDM — a fleet-wide static file would hand every developer the same identity (and one developer's token to all others). The same AXONFLOW_USER_TOKEN value drives every integration's X-User-Token header; see Configuring your client for the per-client field and minimum version. Rotate on your credential cadence with the rotate endpoint above.
Path B — IdP-issued OIDC tokens (JumpCloud example)
Your IdP issues each developer a standard OIDC token; AxonFlow validates it against the IdP's JWKS (issuer, audience, expiry, RS256 signature) and resolves the developer's role from the SCIM-synced directory — deliberately not from any role claim inside the token, so role assignment stays on AxonFlow's audited SCIM/admin surface and an IdP misconfiguration cannot mint an admin.
The examples use JumpCloud; Okta, Azure AD (Entra ID), and OneLogin work the same way — substitute that IdP's issuer, JWKS URI, and SCIM steps (Okta · Azure AD · OneLogin).
Prerequisites
- Platform v9.11.0 or later. Path B depends on the fleet-mappable system roles being seeded per org and, for self-hosted (in-vpc) deployments, on the OIDC config being keyed to your licensed org — both of which land in v9.11.0. On v9.10.0, use Path A.
- Enterprise deployment. Per-user token resolution runs only for enterprise-authenticated fleet callers. Community and community-SaaS callers keep their existing bypass and never resolve a per-user identity, so a token presented there is ignored.
- The agent has a database connection. The OIDC validator and the SCIM role resolver register at startup only if one is available. Without it, no validator registers and a presented token is ignored (least-privilege attribution) rather than honored — the agent logs a rate-limited warning saying exactly this.
sso:configurepermission for the operator doing the setup (steps 3–4).
On a self-hosted (in-vpc) deployment the SSO config is stored under a platform-wide tenant key while the fleet verifier looks it up under your real licensed org. Platform v9.11.0 reconciles the two automatically on upgrade, provided the agent can identify the deployment org: set ORG_ID to your licensed org before upgrading. If the upgrade cannot verify the org it leaves the config untouched and logs a WARNING rather than guessing — in which case Path B stays inactive. After upgrading, confirm with:
SELECT tenant_id, org_id FROM sso_configurations;
-- in-vpc: expect tenant_id = '__platform__' and org_id = <your licensed org>
Path A is unaffected by any of this.
1. Configure SCIM provisioning first
Path B resolves roles from the SCIM-synced directory, so the directory must be populated before any token will resolve to more than least privilege. Group→role mappings only take effect for users SCIM has actually provisioned (mapping a group syncs its current members' roles). Do this first, or every developer authenticates successfully and then reads nothing.
- Create a SCIM bearer token in the portal (Settings → SCIM → Create Token; the page is titled Platform SCIM Provisioning on in-vpc) — copy it once; it is not shown again.
- Point JumpCloud's SCIM / directory-sync at
$PORTAL_URL/scim/v2using that token, and assign your developer group(s) so users and groups sync.
Full walkthrough: SCIM Getting Started · Group to Role Mapping.
2. Create the OIDC application in JumpCloud
- JumpCloud Admin Console → SSO Applications → Add New Application → Custom OIDC App.
- Grant types: Authorization Code (plus Refresh Token as desired). Redirect URI: your token-delivery tooling (device-flow / CLI helper).
- Note the values:
- Issuer:
https://oauth.id.jumpcloud.com/ - JWKS URI:
https://oauth.id.jumpcloud.com/.well-known/jwks.json - Audience: your app's client ID (or the custom audience you configure).
- Issuer:
- Ensure the
emailclaim is included (standard scopes:openid email). - Assign the app to your developer group(s).
Read your IdP's OIDC discovery document — for JumpCloud, https://oauth.id.jumpcloud.com/.well-known/openid-configuration — rather than copying the values above. IdPs vary the issuer (trailing slash included), the JWKS URI, and the scopes needed to emit email. A mismatched iss or aud is rejected fail-closed, which looks identical to "tokens don't work".
3. Configure AxonFlow (per tenant, requires sso:configure)
curl -X POST "$PORTAL_URL/api/v1/sso/config" \
-H "Cookie: <portal session>" \
-H "Content-Type: application/json" \
-d '{
"provider": "oidc",
"enabled": true,
"oidc_issuer": "https://oauth.id.jumpcloud.com/",
"oidc_audience": "<your client id>",
"oidc_jwks_uri": "https://oauth.id.jumpcloud.com/.well-known/jwks.json",
"oidc_claim_mapping": {"email": "email"}
}'
POSTcreates; it returns409if a config already exists. To change an existing one — including switching a tenant from SAML to OIDC — usePUT /api/v1/sso/configwith the same body. (PATCHtogglesenabled;DELETEremoves the config.)- Issuer and JWKS URI must be https (plaintext HTTP is refused except loopback, because a swapped JWKS would turn validation into a forgery oracle). URLs targeting an internal or cloud-metadata endpoint (
169.254.169.254, RFC-1918 hosts,*.internal) are rejected — the platform fetches these server-side. - Use a per-tenant issuer, not a shared multi-tenant "common" issuer. The verifier trusts the identity in a token signed by the configured issuer + audience; if two AxonFlow tenants configure the same issuer/audience, a token minted for one is structurally valid for the other. Give each tenant a distinct OIDC app (distinct issuer or audience) so a token cannot cross a tenant boundary.
oidc_claim_mapping.emailnames the claim carrying the developer identity (defaultemail; e.g.preferred_usernamefor some IdPs).- The SSO-config mutation endpoints require the
sso:configurepermission.
4. Map SCIM groups to roles
Path B roles come from the SCIM directory (step 1), never from the token. Map each synced JumpCloud group to an AxonFlow role. Both calls are session-authenticated and gated on sso:configure — deliberately not reachable with a SCIM directory-sync token, so a sync token cannot grant its own group roles.
First discover the role UUIDs:
curl "$PORTAL_URL/api/v1/scim/roles" -H "Cookie: <portal session>"
# => {"roles":[{"id":"3f2b…","name":"developer","display_name":"Developer",…}, …],
# "count":6}
Every org has the six fleet-mappable system roles seeded automatically: admin, owner, policy_admin, developer, member, viewer. New orgs get them on creation; existing orgs are backfilled on upgrade. These are the only role names a SCIM group may map to — the fleet resolver keys on the role name, so mapping a group to a differently-named custom role is rejected (400) rather than silently resolving every member to least privilege. Map each group to whichever of the six fits: admin or owner for a group that should read the whole tenant, developer / member / viewer for own-rows access.
Then map a group. The <group-id> is the SCIM group id — list them from the SCIM router with the SCIM bearer token (a different router and credential to the session-authenticated calls here): curl "$PORTAL_URL/scim/v2/Groups" -H "Authorization: Bearer $SCIM_TOKEN".
curl -X PUT "$PORTAL_URL/api/v1/scim/groups/<group-id>/role-mapping" \
-H "Cookie: <portal session>" \
-H "Content-Type: application/json" \
-d '{"role_id": "3f2b…"}'
# => {"group_id":"…","role_id":"3f2b…","role_name":"Developer","users_updated":12}
users_updated counts the group members whose role assignments were re-synced. A 0 means the mapping applied to nobody — an empty group, members SCIM has not provisioned yet (revisit step 1), or members that failed to resolve. It is not by itself an error, but on a group you expect to have developers in, treat it as one. Send {"role_id": null} to clear a mapping, and review current mappings with GET /api/v1/scim/groups/role-mappings.
A developer in no mapped group resolves to least privilege (own-rows read scope), never admin.
5. Deliver the token to the fleet
The IdP token travels on the fleet request as X-User-Token:
X-User-Token: <the developer's OIDC token>
X-User-Token everywhereAuthorization: Bearer <token> is accepted only on the MCP-server plane, as a concession to deployments that authenticate the tenant out-of-band. On the agent-proxied REST plane (the audit / decision / override read surfaces), Authorization is only ever the tenant credential — a Bearer per-user token there is parsed as a tenant credential and rejected. X-User-Token is the one header both planes read, so it is the only correct answer for a fleet.
The plugins send this header for you when AXONFLOW_USER_TOKEN is set; the fleet-scale delivery note under Path A applies unchanged to Path B tokens.
AxonFlow requires both an email claim and an aud matching the configured audience. Those are reliably present on ID tokens; many IdPs issue access tokens that are opaque, carry a resource-server aud, or omit email entirely — any of which is rejected fail-closed. Configure your token-delivery tooling to hand AxonFlow a token type that carries both, and confirm against a decoded sample before rolling out to the fleet.
What AxonFlow enforces on every OIDC token
- RS256 only —
alg: noneand HS256 algorithm-confusion tokens are rejected before any key material is consulted. issexact match;audmust contain the configured audience;exprequired and enforced;nbfhonored (60s clock-skew leeway).email_verified, when present, must betrue— an IdP that emits a self-asserted / unverified email (e.g. multi-tenant Azure AD) cannot be used to impersonate another directory user's identity, and thus their role.- JWKS cached 15 minutes; an unknown
kidtriggers a rate-limited refetch, so IdP signing-key rotation is picked up automatically. - Every failure mode is fail-closed: unknown key, expired token, wrong audience, or an unreachable role directory means the request is not authenticated.
What the token unlocks: role-scoped reads
Both paths produce the same validated {identity, role}, and the fleet plane's validator accepts either. Once resolved, 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 or a direct API call:
| Resolved role | Audit / decision / override reads |
|---|---|
admin / owner | Full tenant — every user's rows |
developer, member, viewer, policy_admin | Own rows only — rows attributed to their own identity |
| No per-user identity (shared credential alone) | Zero rows — fail-closed |
Two properties worth restating alongside provisioning:
- Emails are canonicalized (lower-cased, trimmed) at mint, validation, and revocation, so audit attribution and read-scoping key on the same value — a developer always sees the full set of their own attributed history.
- Path B roles come from the SCIM directory, never the token. A developer with no mapped role resolves to least privilege (own-rows), so an IdP misconfiguration can never mint an admin.
The complete model — the enforcement layers, the covered surfaces, admin-gated compliance exports, and the v9.10.0 behavior change for token-less callers — is documented in Per-Developer Identity → Role-scoped reads.
Related
- Per-Developer Identity — the two identity layers (attribution and authorization) and the full read-scoping model.
- SCIM Getting Started · Group to Role Mapping — the directory-sync setup Path B builds on.
- SSO Configuration — the SAML/OIDC config surface
POST /api/v1/sso/configwrites. - v9.10.0 release notes — the release that introduced per-user tokens and role-scoped reads.
