Skip to content

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.

PropertyValue (code)
Token endpointPOST {issuer}/oauth/token (https://api.sectigoaxiom.com/oauth/token)
client_idThe stable agent ID, agt_…. Required in the form.
AssertionCompact JWS signed by an active enrolled key, iss = sub = agent ID
AlgorithmsES256 (P-256) and EdDSA (Ed25519)
Token typeDPoP, bound to the proof key's thumbprint
Token lifetime300 seconds (AGENT_TOKEN_TTL_SECONDS)
Scopesactions: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):

sh
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:

json
{
  "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):

  • aud is the token endpoint (the issuer URL is also accepted).
  • exp is in the future and at most 10 minutes ahead; the SDK uses 60 seconds.
  • sub equals iss, and jti is 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 a kid, every active or rotating key of the agent is tried.
  • The agent must be active. A suspended agent gets 403 forbidden with reason_code: agent_suspended.

The request itself carries a DPoP proof for POST on the token endpoint (no ath, because there is no token yet):

http
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
json
{ "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:

ClaimRule
htmThe HTTP method, upper case.
htuThe exact request URL without query or fragment, as the server sees it (scheme, host, path).
iatWithin ±120 seconds of the server clock.
jtiFresh per request. A second use is rejected (DPoP proof replayed).
athbase64url 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):

ts
// 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}"`);
sh
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 ​

ts
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.

  1. Generate a new key and bind it to the same agent (same proof-of-possession flow). Every currently active jwk or x509 credential of that agent enters rotating, with its expiry shortened to the end of the overlap window (rotation_overlap_seconds, default 1 day, at most 7 days).
  2. 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.
  3. 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_id is the agent ID (required on this path).
  • The token's iss must be a trusted issuer for the agent's tenant, it is verified against that issuer's jwks_uri and audience, and its sub must equal the bound subject.
  • An untrusted issuer is 403 forbidden with reason_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 ​

CodeStatusMeaning
invalid_client401Assertion not signed by an active enrolled key, wrong aud, exp too long, missing jti.
invalid_dpop_proof401Proof missing, malformed, stale, replayed, wrong htm/htu/ath or key.
invalid_token401Token expired, revoked, or the credential behind it was revoked or expired.
forbidden403Agent suspended or retired (agent_suspended, agent_retired), untrusted issuer.

The full list is in errors and reason codes.

Developer preview. Contract version 2026-11-14.