JWT Authentication
Since v0.13.0, flux7-mesh validates JWT tokens from external identity providers. The IdP issues the tokens, and mesh7 enforces them.
How it works
External IdP (Auth0, Cloudflare Access, Keycloak)
│
└── issues JWT with agent identity in claims
│
▼
Agent ── Authorization: Bearer <jwt> ──► mesh7
│
1. Fetch JWKS (cached, refresh 5 min)
2. Validate signature (RS256 / ES256)
3. Check exp, iss, aud
4. Extract agent ID from claim
│
▼ policy engine (same YAML rules)
mesh7 does not issue tokens. It validates them against the IdP's public keys (JWKS endpoint) and extracts the agent identity from a configurable claim.
Configuration
auth:
jwt:
jwks_url: https://auth.example.com/.well-known/jwks.json
issuer: https://auth.example.com
audience: mesh7
agent_claim: sub
| Field | Required | Default | Description |
|---|---|---|---|
jwks_url |
Yes | — | JWKS endpoint URL (public keys) |
issuer |
No | — | Validate iss claim if set |
audience |
No | — | Validate aud claim if set |
agent_claim |
No | sub |
Which claim is used as agent ID for policies |
user_claim |
No | (off) | Which claim names the human the agent acts for. Empty means tokens carry no user and traces record none |
allow_legacy |
No | false |
Accept the plaintext Bearer agent:<name> form alongside JWTs (migration only) |
No auth.jwt block = no validation. The legacy Bearer agent:<name> format still works, for backward compatibility. With auth.jwt set, it is rejected with 401 unless allow_legacy: true.
Delegation: the agent and the user
A token can carry two identities at once: the agent making the call, and the
human it acts for. With user_claim set, every trace entry records user_id
and the OTel export carries enduser.id, so a call answers on whose behalf
it happened, not only by which agent.
The user is optional by design: a client-credentials token (an agent acting for no one) has no user, and that absence is a fact the policy may act on, never a validation error.
The session on the MCP Streamable HTTP transport is bound to the full identity (agent and user) on every request: a session ID is not a credential, and cannot be reused by another agent or across users.
Data plane only
JWT governs agent identity on the data plane. Operator endpoints (traces, grants, approvals, policies) are guarded separately (see Control Plane Auth).
Auth flow
Authorization header present?
│
├─ No → anonymous
│
├─ "Bearer agent:<name>" → allow_legacy on: name used as agent ID (no validation)
│ allow_legacy off: HTTP 401
│
└─ "Bearer <jwt>" → validate against JWKS
├─ Valid → extract agent_claim → agent ID for policies
└─ Invalid → HTTP 401 Unauthorized
Key distinction: 401 (not authenticated, bad token) vs 403 (authenticated but policy denied).
JWKS caching
mesh7 fetches the JWKS at startup and caches it in memory. A background goroutine refreshes the cache every 5 minutes. No per-request fetch.
If the initial JWKS fetch fails, mesh7 does not start (fail-closed).
Supported algorithms
- RSA: RS256, RS384, RS512
- ECDSA: ES256 (P-256), ES384 (P-384), ES512 (P-521)
Tokens must include a kid header matching a key in the JWKS response.
Example: per-agent policies with JWT
auth:
jwt:
jwks_url: https://auth.example.com/.well-known/jwks.json
issuer: https://auth.example.com
agent_claim: sub
policies:
- name: admin-agents
agent: "admin-*"
rules:
- tools: ["*"]
action: allow
- name: worker-agents
agent: "worker-*"
rules:
- tools: ["filesystem.read_*"]
action: allow
- tools: ["filesystem.write_*"]
action: human_approval
- tools: ["*"]
action: deny
An agent with JWT claim sub: admin-bot matches the first policy. An agent with sub: worker-3 matches the second.
IdP examples
Cloudflare Access
auth:
jwt:
jwks_url: https://<team>.cloudflareaccess.com/cdn-cgi/access/certs
issuer: https://<team>.cloudflareaccess.com
audience: <application-aud-tag>
agent_claim: email
Auth0
auth:
jwt:
jwks_url: https://<tenant>.auth0.com/.well-known/jwks.json
issuer: https://<tenant>.auth0.com/
audience: https://mesh.example.com
Keycloak
auth:
jwt:
jwks_url: https://<host>/realms/<realm>/protocol/openid-connect/certs
issuer: https://<host>/realms/<realm>
audience: mesh7
agent_claim: azp # the client — the agent
user_claim: sub # the subject — the human it acts for
Keycloak puts the client id in azp and the user in sub, which is the
delegation pair. Keycloak also documents the OAuth identity-chaining draft and
its use as an MCP authorization server, so the same realm can issue the token
an API gateway (Kong, ...) validates before handing the call to mesh7.
Local development
For testing without a real IdP, use the generate-token.py script in examples/jwt-auth/:
pip install cryptography PyJWT
# Start a local JWKS server
python generate-token.py serve &
# Generate a token
TOKEN=$(python generate-token.py token --sub my-agent)
# Test
curl -H "Authorization: Bearer $TOKEN" http://localhost:9090/health
See examples/jwt-auth/ for the full setup.
MCP Streamable HTTP
JWT validation also applies to MCP Streamable HTTP connections (POST /mcp). Managed Agents or remote MCP clients presenting a valid JWT are identified by their claim value, and the same policies apply.
Managed Agent ── POST /mcp ── Authorization: Bearer <jwt> ──► mesh7
│
validate JWT
extract agent ID
apply policies
Stdio clients under auto-proxy
When a daemon is running, mesh7 --mcp does not serve tools itself: it becomes
a stdio→HTTP shuttle to the daemon's POST /mcp. By default the shuttle
identifies itself with the legacy Bearer agent:<--mcp-agent> header, which a
JWT-strict daemon rejects. Give the client a token instead:
{
"mcpServers": {
"mesh7": {
"command": "mesh7",
"args": ["--mcp", "--config", "config.yaml"],
"env": { "MESH_AGENT_TOKEN": "<jwt issued for this agent>" }
}
}
}
With MESH_AGENT_TOKEN set, the shuttle sends Authorization: Bearer <token>
and the daemon derives the agent (and user) from its claims; --mcp-agent is
then ignored for identity. The token is read once at start, so a short-lived
token needs the client restarted when it expires.