Authentication
Agents authenticate with OAuth 2.1 client_credentials using a private_key_jwt client assertion (RFC 7523), and every request carries a DPoP proof (RFC 9449). There is no shared secret: the agent proves possession of its enrolled private key when it gets a token and again on every call.
| Property | Value (code) |
|---|---|
| Token endpoint | POST {issuer}/oauth/token (https://api.sectigoaxiom.com/oauth/token) |
client_id | The stable agent ID, agt_…. Required in the form. |
| Assertion | Compact JWS signed by an active enrolled key, iss = sub = agent ID |
| Algorithms | ES256 (P-256) and EdDSA (Ed25519) |
| Token type | DPoP, bound to the proof key's thumbprint |
| Token lifetime | 300 seconds (AGENT_TOKEN_TTL_SECONDS) |
| Scopes | actions:submit and actions:read (both granted by default) |
People (for example an MCP client acting for a developer) use the authorization-code flow with PKCE instead; see MCP.
Before you start: an enrolled key
An agent is a stable ID (agt_…) registered in a customer tenant. A tenant admin binds a public key to it with proof of possession: Axiom issues a single-use challenge, the key signs it, and only then is the key accepted. The private key never leaves the agent. See registering and admitting agents for the admin steps and agents and credentials for the model.
With the CLI (from the repository root):
node packages/sdk/bin/axiom.mjs keygen --agent agt_01kd0m6n3b4v5c6x7z8a9s0d1f
# Private key saved to ~/.config/axiom/keys/<kid>.jwk (kid …); prints the public JWK to enroll
export AXIOM_SESSION_COOKIE='…' # axiom_session cookie of a signed-in tenant admin
node packages/sdk/bin/axiom.mjs agent bind agt_01kd0m6n3b4v5c6x7z8a9s0d1f
# Bound jwk credential crd_… (thumbprint …) to agt_…, valid until …axiom agent bind fetches the challenge, signs it with the local key and submits the proof. To bind through the console instead, sign the challenge it shows with axiom agent sign-challenge and paste the proof back. See the CLI reference.
The token request
The client assertion is a JWT signed by the enrolled key:
{
"iss": "agt_01kd0m6n3b4v5c6x7z8a9s0d1f",
"sub": "agt_01kd0m6n3b4v5c6x7z8a9s0d1f",
"aud": "https://api.sectigoaxiom.com/oauth/token",
"jti": "kq4bU1w0Qh6m0tVqfB2mSg",
"iat": 1763143200,
"exp": 1763143260
}Rules the server enforces (apps/api/src/auth/oauth.ts):
audis the token endpoint (the issuer URL is also accepted).expis in the future and at most 10 minutes ahead; the SDK uses 60 seconds.subequalsiss, andjtiis present and single-use (replays are rejected for 10 minutes).- If the JWS header has a
kid, it must equal the enrolled key's thumbprint. Without akid, every active or rotating key of the agent is tried. - The agent must be
active. A suspended agent gets403 forbiddenwithreason_code: agent_suspended.
The request itself carries a DPoP proof for POST on the token endpoint (no ath, because there is no token yet):
POST /oauth/token HTTP/1.1
Host: api.sectigoaxiom.com
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7…
grant_type=client_credentials
&client_id=agt_01kd0m6n3b4v5c6x7z8a9s0d1f
&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer
&client_assertion=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9…
&scope=actions%3Asubmit%20actions%3Aread{ "access_token": "axa_Jd0…", "token_type": "DPoP", "expires_in": 300, "scope": "actions:submit actions:read" }A missing DPoP proof is 401 invalid_dpop_proof; a bad assertion is 401 invalid_client. The token endpoint answers OAuth errors ({ "error", "error_description" }), not problem documents. The SDK maps both to AxiomProblem with one code.
You may pass resource (RFC 8707). A token issued for a resource is refused by the Axiom MCP server if that resource is neither the MCP server nor the API origin.
DPoP proofs on every call
Every API call sends Authorization: DPoP axa_… and a new DPoP header. The proof is a compact JWS with header typ: dpop+jwt and the public jwk, and these claims:
| Claim | Rule |
|---|---|
htm | The HTTP method, upper case. |
htu | The exact request URL without query or fragment, as the server sees it (scheme, host, path). |
iat | Within ±120 seconds of the server clock. |
jti | Fresh per request. A second use is rejected (DPoP proof replayed). |
ath | base64url SHA-256 of the access token. |
The proof key's thumbprint must match the token's binding (proof key does not match token binding). Because htu includes the host, a proof made for the hosted gateway (gw.) cannot be replayed against the API (api.), and a proof made for an embedded-SDK provider's own URL cannot be replayed against Axiom. All DPoP failures are 401 invalid_dpop_proof; fix the clock, the URL or the jti rather than retrying in a loop.
The curl helper used in this guide
Proofs are single-use and bound to one URL, so every curl example in these docs gets its two headers from a small script. Save it as axiom-headers.ts in the repository root (the SDK is a workspace package of TypeScript sources, so run it with tsx, which the monorepo already has):
// axiom-headers.ts: print Authorization and DPoP headers for one request, in curl config format.
// Usage: npx tsx axiom-headers.ts POST https://api.sectigoaxiom.com/v1/actions
import { readFile } from "node:fs/promises";
import { AxiomAgent, signerFromJwk } from "@sectigo/axiom-sdk";
const [method, url] = process.argv.slice(2);
if (!method || !url) throw new Error("usage: axiom-headers.ts METHOD URL");
const agent = new AxiomAgent({
baseUrl: process.env.AXIOM_API ?? "https://api.sectigoaxiom.com",
agentId: process.env.AXIOM_AGENT_ID!,
signer: await signerFromJwk(JSON.parse(await readFile(process.env.AXIOM_KEY_FILE!, "utf8"))),
});
const h = await agent.authHeaders(method, url);
console.log(`header = "Authorization: ${h.authorization}"`);
console.log(`header = "DPoP: ${h.dpop}"`);export AXIOM_API=https://api.sectigoaxiom.com # or http://localhost:8787
export AXIOM_AGENT_ID=agt_01kd0m6n3b4v5c6x7z8a9s0d1f
export AXIOM_KEY_FILE=~/.config/axiom/keys/<kid>.jwk
curl -sS -K <(npx tsx axiom-headers.ts GET "$AXIOM_API/v1/actions/act_01kd0m7s2v8x9yz3abcdefghjk") \
"$AXIOM_API/v1/actions/act_01kd0m7s2v8x9yz3abcdefghjk"Each invocation fetches a fresh token, which is fine for trying things out. A real client caches the token (the SDK's TokenManager refreshes 60 seconds before expiry) and makes only a new proof per call. The URL you pass to the helper must be exactly the URL curl requests (without the query string, which the proof ignores).
With the SDK
import { readFile } from "node:fs/promises";
import { AxiomAgent, signerFromJwk } from "@sectigo/axiom-sdk";
const agent = new AxiomAgent({
baseUrl: "https://api.sectigoaxiom.com",
agentId: "agt_01kd0m6n3b4v5c6x7z8a9s0d1f",
signer: await signerFromJwk(JSON.parse(await readFile(process.env.AXIOM_KEY_FILE!, "utf8"))),
});
// Every call gets a DPoP proof bound to the cached token and the exact URL.
const caps = await agent.capabilities();signer is any Signer ({ kid, alg, sign(bytes), publicJwk() }), so a KMS or HSM can hold the key instead of a local JWK. A 401 invalid_token (expired or revoked token) gets one automatic retry with a fresh token; that is safe because authentication fails before any handler runs.
Key rotation
Rotation keeps the agent ID, and with it the authority to resume the agent's pending actions.
- Generate a new key and bind it to the same agent (same proof-of-possession flow). Every currently active
jwkorx509credential of that agent entersrotating, with its expiry shortened to the end of the overlap window (rotation_overlap_seconds, default 1 day, at most 7 days). - Switch the running agent to the new key. With the SDK:
agent.rotateSigner(newSigner). The cached token is dropped because it is bound to the old thumbprint. - Old tokens stay valid until they expire (at most 5 minutes) unless the old credential is revoked.
At resume Axiom rechecks the credential recorded on the action. A legitimately rotated credential is fine; only a revoked credential turns the action into revoked (credential_revoked). See lost or compromised keys.
Workload identity instead of a private key
A tenant can trust a workload identity issuer (a SPIFFE JWT-SVID issuer or an OIDC issuer) in its trust profile (workload_issuers: [{ issuer, jwks_uri, audience }]), and the admin binds a spiffe or oidc credential (issuer + subject) to the agent. The agent then sends the workload token itself as client_assertion:
client_idis the agent ID (required on this path).- The token's
issmust be a trusted issuer for the agent's tenant, it is verified against that issuer'sjwks_uriandaudience, and itssubmust equal the bound subject. - An untrusted issuer is
403 forbiddenwithreason_code: issuer_not_trusted. - A DPoP proof is still mandatory, so the issued token is bound to a key the workload holds.
Errors you will see here
| Code | Status | Meaning |
|---|---|---|
invalid_client | 401 | Assertion not signed by an active enrolled key, wrong aud, exp too long, missing jti. |
invalid_dpop_proof | 401 | Proof missing, malformed, stale, replayed, wrong htm/htu/ath or key. |
invalid_token | 401 | Token expired, revoked, or the credential behind it was revoked or expired. |
forbidden | 403 | Agent suspended or retired (agent_suspended, agent_retired), untrusted issuer. |
The full list is in errors and reason codes.