Skip to main content

Agent Authentication

These endpoints handle agent onboarding and token exchange using the private_key_jwt pattern (RFC 7523). Both endpoints are unauthenticated (no Bearer token or API key required) and have per-IP rate limits.

Overview

The agent authentication flow replaces static API keys with asymmetric key-based auth:

  1. Dashboard creates an agent → returns a one-time bootstrap secret (ah5b_ prefix)
  2. Agent generates an ES256 key pair locally and calls /v1/agents/bootstrap with the secret + public key
  3. Agent signs a short-lived JWT (client assertion) with its private key and exchanges it at /v1/agents/token for a short-lived opaque access token (ah5t_ prefix)
  4. Agent uses the access token as Authorization: Bearer ah5t_... for all Vault API calls

The private key never leaves the agent machine. Access tokens default to 2 hours and can be refreshed by signing a new assertion.

Endpoint Summary

MethodEndpointRate LimitDescription
POST/v1/agents/bootstrap5/min/IPRegister or rotate an agent's public key using a bootstrap secret
POST/v1/agents/token30/min/IPExchange a signed client assertion for an access token

Bootstrap

POST /v1/agents/bootstrap

Registers or rotates the agent's ES256 public key using a one-time bootstrap secret. Works for both first enrollment (createdactive) and key rotation (active → replace key + invalidate tokens).

Request:

{
"bootstrapSecret": "ah5b_aBcDeFgHiJkLmNoPqRsTuVwXyZ...",
"publicKey": {
"kty": "EC",
"crv": "P-256",
"x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
"y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"
}
}
FieldRequiredDescription
bootstrapSecretYesOne-time secret from agent creation or bootstrap-secret endpoint (ah5b_ prefix)
publicKeyYesES256 (P-256) public key in JWK format. Must include kty, crv, x, y.

Behavior by agent status:

Agent StatusAction
createdActivates the agent, registers the public key, sets enrolledAt
activeReplaces the public key, invalidates all existing access tokens
disabledRejected with 409

Response (200):

{
"agentId": "uuid",
"name": "Email Assistant",
"status": "active",
"workspaceId": "uuid"
}

Errors:

StatusReason
400Invalid public key (not ES256/P-256), missing fields
401Invalid, expired, or already-consumed bootstrap secret
409Agent is disabled

Token Exchange

POST /v1/agents/token

Exchanges a signed ES256 client assertion JWT for a short-lived opaque access token. Supports both JSON and application/x-www-form-urlencoded request bodies.

Request (JSON):

{
"grant_type": "client_assertion",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": "eyJhbGciOiJFUzI1NiIs..."
}

Request (form-urlencoded):

grant_type=client_assertion&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer&client_assertion=eyJhbGciOiJFUzI1NiIs...
FieldRequiredDescription
grant_typeYesMust be "client_assertion"
client_assertion_typeYesMust be "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
client_assertionYesSigned ES256 JWT (see below)

Client Assertion JWT

The client assertion is a JWT signed with the agent's ES256 private key:

Header:

{
"alg": "ES256",
"typ": "JWT"
}

Payload:

{
"iss": "<agent-id>",
"sub": "<agent-id>",
"aud": "<token-audience>",
"iat": 1700000000,
"exp": 1700000060,
"jti": "unique-random-id"
}
ClaimRequiredDescription
issYesAgent ID (must match sub)
subYesAgent ID
audYesToken audience. Defaults to the API base URL. Configurable via AGENT_TOKEN_AUDIENCE env var.
iatYesIssued-at timestamp (epoch seconds)
expYesExpiration timestamp. Must be at most 60 seconds after iat.
jtiYesUnique identifier. Each jti can only be used once (replay protection).

Response (200):

{
"access_token": "ah5t_aBcDeFgHiJkLmNoPqRsTuVwXyZ...",
"token_type": "Bearer",
"expires_in": 7200
}

The access token is opaque (not a JWT). Use it as Authorization: Bearer ah5t_... for all authenticated API calls (Vault, connections, etc.).

Errors:

StatusReason
401Invalid signature, expired assertion, wrong algorithm, wrong audience, replayed jti, agent not active

Complete Example

Here is the full flow using the jose library in Node.js:

import { generateKeyPair, exportJWK, SignJWT } from "jose";
import { randomUUID } from "node:crypto";

// 1. Generate an ES256 key pair (do this once, persist the private key)
const { publicKey, privateKey } = await generateKeyPair("ES256");
const publicJwk = await exportJWK(publicKey);

// 2. Bootstrap the agent
const bootstrapRes = await fetch("http://localhost:4000/v1/agents/bootstrap", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bootstrapSecret: "ah5b_...",
publicKey: publicJwk,
}),
});
const { agentId } = await bootstrapRes.json();

// 3. Sign a client assertion
const assertion = await new SignJWT({ jti: randomUUID() })
.setProtectedHeader({ alg: "ES256" })
.setIssuer(agentId)
.setSubject(agentId)
.setAudience("http://localhost:4000")
.setIssuedAt()
.setExpirationTime("30s")
.sign(privateKey);

// 4. Exchange for an access token
const tokenRes = await fetch("http://localhost:4000/v1/agents/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "client_assertion",
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
client_assertion: assertion,
}),
});
const { access_token } = await tokenRes.json();

// 5. Use the access token to call the Vault
const vaultRes = await fetch("http://localhost:4000/v1/vault/execute", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${access_token}`,
},
body: JSON.stringify({
model: "B",
connectionId: "your-connection-id",
method: "GET",
url: "https://gmail.googleapis.com/gmail/v1/users/me/messages",
}),
});

Environment Variables

VariableDefaultDescription
AGENT_TOKEN_TTL_SECONDS7200 (2 hours)Access token lifetime
AGENT_TOKEN_AUDIENCEAPI base URLExpected aud claim in client assertions
BOOTSTRAP_SECRET_TTL_HOURS1Bootstrap secret expiry

Security Properties

  • No shared secrets: The private key never leaves the agent machine. Only the public key is stored server-side.
  • Short-lived tokens: Access tokens expire in 2 hours by default. Compromise window is minimal.
  • Replay protection: Each client assertion jti can only be used once. The jti is stored in PostgreSQL (l_jti_replay_cache table) with a primary key constraint for atomic cross-replica detection.
  • Instant revocation: Disabling an agent immediately deletes all access tokens from the database.
  • Machine binding: Only the machine holding the private key can sign valid assertions.