Identity broker API
Every endpoint of the MAQPNA identity broker - token mint, verify, DPoP bind, proof nonces, RFC 8693 exchange and A2A delegation, JWKS, OIDC discovery, warm-pool bootstrap and probes - plus the session-token claims.
The identity broker (maqpna-identity) issues each session a short-lived, signed identity: a session token. This page is the reference for its HTTP API and for the claims inside the tokens it issues.
You rarely call the broker yourself. The operator mints tokens when it starts a session, the gateway fetches the broker's public keys (JWKS), and SDKs read the token from the file the operator mounts. You need this page when you debug a token, mint one by hand for a test, exchange a token for a cloud credential, implement A2A delegation without an SDK, or write a verifier. For the concepts, read Identity and delegation; for the design, Identity broker.
Source: cmd/maqpna-identity (broker.go, delegation.go, dpop.go, checks.go, bootstrap.go) and pkg/identity.
Endpoints at a glance#
| Method | Path | Port | Auth | Purpose |
|---|---|---|---|---|
POST |
/v1/token |
API (:8081) |
Broker token | Mint a session token |
POST |
/v1/token/verify |
API | Broker token | Verify a token and return its claims |
POST |
/v1/token/bind |
API | The session token + DPoP proof | Bind a token to a DPoP key |
POST |
/v1/proof/nonce |
API | Broker token | Single-use nonce for an identity-check proof |
POST |
/v1/exchange |
API | The subject token | RFC 8693 token exchange; with actor_token, A2A delegation |
GET |
/.well-known/jwks.json |
API | None | Public keys (JWKS) |
GET |
/.well-known/openid-configuration |
API | None | OIDC discovery, only with cloud federation (-federation-key) |
GET |
/.well-known/federation/jwks.json |
API | None | Federation-only keys, only with cloud federation |
GET |
/healthz |
API and bootstrap | None | Liveness |
GET |
/readyz |
API | None | Readiness |
GET |
/version |
API | None | Build information |
GET |
/metrics |
API | None | maqpna_build_info gauge |
POST |
/v1/bootstrap |
Bootstrap (-bootstrap-listen, Helm default :8083) |
Projected ServiceAccount token | Warm-pool late binding |
sequenceDiagram
autonumber
participant OP as Operator
participant IB as Identity broker
participant AG as Agent A
participant GW as Gateway
participant B as Agent B
OP->>IB: POST /v1/token (Bearer MAQPNA_BROKER_TOKEN)
IB-->>OP: {token, expiresAt, spiffeId, jti, kid}
OP->>AG: token file, re-minted at 80% of the lifetime
GW->>IB: GET /.well-known/jwks.json
AG->>GW: POST /a2a/team-a/reviewer (scope agents:reviewer)
GW->>B: call + Txn-Token
B->>IB: POST /v1/exchange<br/>subject_token = Txn-Token, actor_token = B's token
IB-->>B: delegated token (act: A, then the user)
Authentication#
| Caller | How it authenticates |
|---|---|
| Operator, attestation service, tests | Authorization: Bearer <broker token>: the value of MAQPNA_BROKER_TOKEN (or the file named by MAQPNA_BROKER_TOKEN_FILE). The broker refuses to start without it |
| Agents exchanging or delegating | The tokens in the request body authenticate the request |
| Agents binding a DPoP key | Authorization: Bearer <session token> plus a DPoP proof |
| Warm-pool pods | A projected ServiceAccount token with audience maqpna-bootstrap, checked with a Kubernetes TokenReview |
A wrong broker token returns 401 {"error":"unauthorized"} with WWW-Authenticate: Bearer realm="maqpna-identity". All JSON responses carry Cache-Control: no-store, except the key documents. Request bodies are limited to 64 KiB.
POST /v1/token#
Mint a session token. Unknown body fields are rejected.
| Field | Type | Meaning |
|---|---|---|
namespace, agent, session |
string | The session identity; they form the SPIFFE ID |
user |
string | The user the agent acts for; becomes act.sub = "user:<user>" |
scopes |
[]string | Granted scopes (tools:github, models:team-a.coder, agents:reviewer) |
tier |
string | Trust tier (tier-0, tier-1, tier-2) |
ttlSeconds |
int | Lifetime; 0 = -default-ttl (15 min); capped at -max-ttl (1 h); negative is refused |
audience |
string | Optional; default -audience (maqpna-gateway) |
groups |
[]string | The user's verified groups (at most 32) |
principalVerified |
string | How the user was verified: requester, trustedCreator or idToken |
parentSession, forkRoot, inheritedTaints |
string, string, []string | For forked sessions |
proof |
object | Proof of where the session runs, checked by a configured identity check (see below) |
dpopJkt |
string | RFC 7638 thumbprint of the agent's DPoP key; binds the token (cnf.jkt) |
curl -sS -X POST http://localhost:8081/v1/token \
-H "Authorization: Bearer $MAQPNA_BROKER_TOKEN" -H "Content-Type: application/json" \
-d '{"namespace":"team-a","agent":"coder","session":"fix-4821","user":"[email protected]","scopes":["tools:github","models:team-a.coder"],"tier":"tier-1","ttlSeconds":900}'
{
"token": "eyJhbGciOiJFZERTQSIsImtpZCI6Ik56YkxzWGg4dURDY2QtNk1Od1hGNFdfN25vV1hGWkFmSGt4WnNSR0M5WHMifQ.…",
"expiresAt": "2026-10-18T14:20:00Z",
"spiffeId": "spiffe://maqpna.local/ns/team-a/agent/coder/session/fix-4821",
"jti": "b3f1c2…",
"kid": "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs",
"tokenType": "Bearer"
}
tokenType is DPoP for a bound token. substrate is added when a proof was verified.
| Status | error |
|---|---|
400 |
invalid JSON: …, ttlSeconds must be >= 0, invalid scope, invalid principalVerified, bad dpopJkt, unknown proof check "…" |
401 |
unauthorized |
403 |
The proof failed its check, or -require-proof is set and no proof was sent |
503 |
Tenant signing key missing or mismatching (fail closed), or an identity check cannot reach its trust anchors |
CLI: maqpna token mint.
Proofs and POST /v1/proof/nonce#
With identity checks configured (-identity-checks, -sandbox-check), a mint can carry a proof that the broker verifies against the substrate itself and records in the maqpna_substrate claim:
proof field |
Meaning |
|---|---|
check |
Registered check name (for example kubernetes, spiffe, aws-iid, gcp-identity, azure-imds, tee, or a sandbox driver) |
sandbox |
Sandbox ID or name |
nonce |
A nonce from /v1/proof/nonce, for checks that bind one |
token |
A signed JWT (projected ServiceAccount token, JWT-SVID, GCP identity token) |
document, signature, format |
AWS instance identity document and its signature |
x5c |
X.509-SVID chain, leaf first, base64 DER |
evidence |
TEE evidence |
POST /v1/proof/nonce with body {"check": "<name>"} returns {"check", "nonce", "expiresAt"} for a check that issues nonces; 400 for one that does not. See Identity checks.
POST /v1/token/verify#
Verify a token's signature, issuer, expiry, audience and tenant key binding.
| Field | Meaning |
|---|---|
token |
Required |
audience |
Audience to check; empty = the broker's default |
curl -sS -X POST http://localhost:8081/v1/token/verify \
-H "Authorization: Bearer $MAQPNA_BROKER_TOKEN" -H "Content-Type: application/json" \
-d "{\"token\":\"$(cat token.jwt)\"}"
A valid token returns 200 {"valid": true, "claims": {...}}; an invalid one returns 200 {"valid": false, "error": "<why>"}. A body without token is 400. CLI: maqpna token verify (which checks against the JWKS) and maqpna token inspect (which decodes).
POST /v1/token/bind#
Exchange an unbound session token for the same token bound to a DPoP key (RFC 9449). Present the token as Authorization: Bearer <token> and a DPoP header with a proof over it (ath). A token's jti binds to one key only: the first bind wins.
{"token":"eyJ…","tokenType":"DPoP","expiresAt":"2026-10-18T14:20:00Z","jti":"b3f1c2…","jkt":"0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"}
| Status | error |
|---|---|
400 |
invalid_request (token already bound), invalid_dpop_proof, invalid_grant |
401 |
invalid_token |
409 |
invalid_grant: the token is already bound to another key |
503 |
Tenant key unavailable |
Errors carry WWW-Authenticate: DPoP algs="EdDSA ES256", error="<code>". The bind record lives in the replica's memory, so with several broker replicas a token can be bound once per replica. See DPoP.
POST /v1/exchange#
RFC 8693 token exchange. The body is application/x-www-form-urlencoded (RFC 8693) or JSON with the same parameter names. Errors follow RFC 6749: {"error", "error_description"}.
| Parameter | Meaning |
|---|---|
grant_type |
urn:ietf:params:oauth:grant-type:token-exchange (optional; any other value is refused) |
subject_token |
The token to exchange |
subject_token_type |
urn:ietf:params:oauth:token-type:jwt or …:access_token; for delegation also urn:ietf:params:oauth:token-type:txn_token |
audience |
Target audience |
scope |
Space-separated requested scopes; empty = every scope of the subject token |
requested_ttl_seconds |
Shorter lifetime than the audience's maximum |
actor_token, actor_token_type |
Present: this is an A2A delegation (below) |
requested_token_type |
Accepted, not used |
Exchange (no actor token)#
The subject token must be a MAQPNA session token (not itself an exchanged token). The audience must be on the -exchange-config allow-list, and the subject's namespace must match the audience's allowedNamespaces. The issued token holds only the requested scopes that are both allowed for the audience (allowedScopes) and granted by the subject; it lives at most maxTTLSeconds (default 300) and never longer than the subject; it carries maqpna_parent_jti. A provider on the audience turns the MAQPNA token into a cloud credential (aws-sts, gcp-sts, azure-entra); without one, the result is a down-scoped MAQPNA token.
-exchange-config file:
{
"audiences": {
"github-mcp": {"maxTTLSeconds": 300, "allowedScopes": ["tools:github"], "allowedNamespaces": ["team-a"]},
"aws-prod": {"provider": "aws-sts", "params": {"roleArn": "arn:aws:iam::123456789012:role/agent"}, "allowedNamespaces": ["team-a"]}
}
}
The params keys depend on the provider; see Cloud identity and keys.
curl -sS -X POST http://localhost:8081/v1/exchange \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d "subject_token=$(cat "$MAQPNA_TOKEN_FILE")" \
-d subject_token_type=urn:ietf:params:oauth:token-type:jwt \
-d audience=github-mcp \
-d scope=tools:github
{"access_token":"eyJ…","issued_token_type":"urn:ietf:params:oauth:token-type:jwt","token_type":"Bearer","expires_in":300,"scope":"tools:github"}
A DPoP-bound subject token needs a proof by its key when the audience has a provider (cloud credentials are bearer); down-scoping it to a MAQPNA token needs none and keeps the binding.
| Status | error |
Cause |
|---|---|---|
400 |
invalid_request |
Missing subject_token or audience, bad form, bad requested_ttl_seconds, unsupported token type |
400 |
unsupported_grant_type |
Wrong grant_type |
400 |
invalid_target |
Audience not allow-listed, or not for this namespace |
400 |
invalid_grant |
Subject token invalid, or itself exchanged |
400 |
invalid_scope |
No requested scope is both allowed and granted |
400 |
invalid_dpop_proof |
Bound subject without a valid proof |
502 |
server_error |
The cloud credential provider failed |
503 |
temporarily_unavailable |
Tenant key unavailable |
A2A delegation (with an actor token)#
Agent B, handling an A2A call from agent A, exchanges for a token that records the chain. actor_token is B's own session token (not exchanged, not delegated). subject_token is either:
- the call's transaction token from the gateway (
subject_token_type=urn:ietf:params:oauth:token-type:txn_token), which must have been issued for a call to B; or - A's session token (
jwt/access_token/ empty type), which must grantagents:<B>(agents:<ns>/<B>across namespaces) and must not be DPoP-bound.
audience, when set, must be the broker's default audience. The result is a token for B whose act chain is A and then the user, with maqpna_delegator_jti. It lives at most 15 minutes (or requested_ttl_seconds), the chain is capped at -max-delegation-depth (5), and a delegation never crosses a tenant. A DPoP-bound actor token needs a proof by its key.
curl -sS -X POST http://localhost:8081/v1/exchange \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d "subject_token=$TXN_TOKEN" \
-d subject_token_type=urn:ietf:params:oauth:token-type:txn_token \
-d "actor_token=$(cat "$MAQPNA_TOKEN_FILE")"
The response has the same shape as an exchange (issued_token_type urn:ietf:params:oauth:token-type:jwt). Errors: invalid_request, invalid_target, invalid_grant (bad actor or subject, wrong call target, different tenants, bound subject token), invalid_scope (subject_token does not grant agents:<name>), invalid_dpop_proof, temporarily_unavailable. See Agent-to-agent calls.
GET /.well-known/jwks.json#
The broker's public keys: the current signing key, retired keys still trusted (-previous-keys), every tenant key, and, when configured, the ML-DSA key and the federation key. Content-Type: application/jwk-set+json, Cache-Control: public, max-age=300.
curl -sS http://localhost:8081/.well-known/jwks.json
{"keys":[{"kty":"OKP","crv":"Ed25519","x":"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo","kid":"NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs","use":"sig","alg":"EdDSA"}]}
MAQPNA verifiers use the OKP keys (and ML-DSA keys when identity.acceptMLDSA is set) and ignore others. The gateway refreshes this document every 300 s and at once on an unknown kid (at most every 10 s).
OIDC discovery for cloud federation#
With -federation-key (an ES256 or RS256 key) the broker signs federation JWTs that AWS STS, Google STS and Microsoft Entra accept, and serves two documents for the federation issuer (-federation-issuer, default -issuer):
GET /.well-known/openid-configuration:
{
"issuer": "https://identity.example.eu",
"jwks_uri": "https://identity.example.eu/.well-known/federation/jwks.json",
"response_types_supported": ["id_token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["ES256"],
"claims_supported": ["sub", "aud", "iss", "iat", "exp", "nbf", "jti", "maqpna_ns", "maqpna_agent", "maqpna_session", "maqpna_tier", "scope"]
}
GET /.well-known/federation/jwks.json holds only the federation key. Both are cached for 300 s. The issuer URL must route to the broker, or you host these two documents statically. See Hyperscaler interop.
POST /v1/bootstrap#
Warm-pool late binding: a pre-started pod learns which session it serves. Served only on -bootstrap-listen, the one broker port agent namespaces may reach.
| Input | Meaning |
|---|---|
Authorization: Bearer <projected ServiceAccount token> |
Audience -bootstrap-audience (maqpna-bootstrap), mounted at /var/run/maqpna/bootstrap/sa-token |
?wait=<seconds> |
Long-poll until the pod is adopted and its session prepared; capped at 25 s. Clients use 20 |
The broker runs a TokenReview, checks the bound pod, follows pod → Sandbox → SandboxClaim → AgentSession with matching UIDs, binds the first pod, and returns the session:
{
"namespace": "team-a",
"session": "fix-4821",
"agent": "coder",
"attested": false,
"token": "eyJ…",
"expiresAt": "2026-10-18T14:20:00Z",
"spiffeId": "spiffe://maqpna.local/ns/team-a/agent/coder/session/fix-4821",
"jti": "b3f1c2…",
"env": {"MAQPNA_GATEWAY_URL": "<gateway URL>", "MAQPNA_SESSION": "fix-4821", "…": "…"}
}
Attested sessions (tier-2) get no token: they redeem their release ID with attestation evidence. Call again with the rotated ServiceAccount token to refresh before expiresAt.
| Status | error |
Retry? |
|---|---|---|
400 |
invalid_request (bad wait) |
No |
401 |
invalid_token |
No |
403 |
access_denied (pod, Sandbox, claim or session do not match), session_revoked |
No |
409 |
already_bootstrapped (bound to another live pod), session_suspended |
No |
410 |
session_gone (deleted, finished or expired) |
No |
425 |
not_adopted, not_prepared |
Yes |
503 |
temporarily_unavailable (too many concurrent bootstraps, Retry-After: 1; or a backend error) |
Yes |
Probes, version and metrics#
| Endpoint | Answer |
|---|---|
GET /healthz |
200 {"status":"ok"} |
GET /readyz |
200 {"status":"ready"}; 503 {"status":"shutting down"} while draining after SIGTERM (-shutdown-delay) |
GET /version |
{"component":"identity","version","commit","buildDate","goVersion","platform","tags"} |
GET /metrics |
The maqpna_build_info gauge only |
Token claims#
Session tokens are compact JWS. The header is {"alg":"EdDSA","kid":"<RFC 7638 thumbprint>"} (ML-DSA-* when -sign-alg selects it). Verifiers reject other algorithms, refuse tokens with typ: txntoken+jwt (a transaction token is never an identity token), and accept a token without kid only when exactly one key is trusted.
| Claim | Type | Example | Meaning |
|---|---|---|---|
iss |
string | maqpna-identity |
-issuer |
sub |
string | spiffe://maqpna.local/ns/team-a/agent/coder/session/fix-4821 |
SPIFFE-style session ID; the verifier requires it to match the maqpna_* claims |
aud |
string or array | maqpna-gateway |
-audience, or the exchange audience |
iat, nbf, exp |
number | 1792332000 |
Unix seconds |
jti |
string | b3f1c2… |
Token ID; revocations can target it |
act |
object | {"sub":"user:[email protected]"} |
The user the agent acts for. Delegation nests it: {"sub":"<caller SPIFFE ID>","act":{"sub":"user:[email protected]"}} |
scope |
string | tools:github models:team-a.coder |
Space-separated scopes |
maqpna_ns |
string | team-a |
Namespace |
maqpna_agent |
string | coder |
Agent |
maqpna_session |
string | fix-4821 |
Session |
maqpna_tier |
string | tier-1 |
Trust tier |
maqpna_groups |
string | platform support |
The user's verified groups, space-separated, at most 32 |
maqpna_principal_verified |
string | requester |
How the user was bound: requester, trustedCreator or idToken |
maqpna_parent_jti |
string | Set on exchanged tokens: the subject token's jti |
|
maqpna_delegator_jti |
string | Set on delegated tokens: the delegating agent's jti, so revoking it revokes the chain |
|
maqpna_parent_session, maqpna_fork_root |
string | Set on forked sessions | |
maqpna_inherited_taints |
string | web |
The parent's taint labels at fork time, space-separated |
maqpna_substrate |
object | {"check":"kosa8","sandbox":"sb-1","isolation":"microvm","attestation":"measured"} |
What a verified proof established (also egress, digest, platform, workload, jurisdiction) |
cnf |
object | {"jkt":"0ZcOCORZNY…"} |
DPoP key binding (RFC 7800) |
Decode one locally:
maqpna token inspect "$(cat "$MAQPNA_TOKEN_FILE")"
Broker flags that shape the API#
| Flag | Default | Effect |
|---|---|---|
-listen |
:8081 |
API port |
-bootstrap-listen |
off | Bootstrap port |
-trust-domain, -issuer, -audience |
maqpna.local, maqpna-identity, maqpna-gateway |
sub, iss, aud |
-default-ttl, -max-ttl |
15m, 1h |
Mint lifetimes |
-exchange-config |
— | Exchange allow-list |
-max-delegation-depth |
5 | Delegation chain cap |
-txn-token-keys, -txn-token-audience |
—, the trust domain | Transaction tokens accepted as delegation subjects |
-tenants-file, -tenant-keys-dir, -tenant-reload |
—, /var/run/maqpna/tenant-keys, 10s |
Per-tenant signing keys |
-identity-checks, -sandbox-check, -require-proof |
— | Mint proofs |
-federation-key, -federation-issuer |
— | Cloud federation and OIDC discovery |
-public-url |
each request's scheme and host | The htu of DPoP proofs |
-tls-cert, -tls-key |
— | Serve the API over TLS; clients may present an X.509-SVID |
The complete list is maqpna-identity -h; environment variables are in Environment variables.
Next steps#
- Gateway data-plane API: where session tokens are used
- Admin API: revocations and the rest of
/v1/* - Key management: rotate the signing key without breaking tokens
- Identity broker flow: the sequences behind mint, bootstrap and delegation
- Connect to the gateway: how SDKs read and refresh the token