STACK
MENU
DOCS / SECURITY / AGENT KEYS

Agent Keys

An agent can use a STACK-managed key or a customer-managed key. STACK-managed is the default: STACK holds the signing key and an operator or member session issues Passports for the agent. Customer-managed mode puts an Ed25519 private key in the agent's runtime so it can authenticate without carrying the operator API key.

For a customer-managed agent, the private key lives under ~/.stack/agents/<agent_id>.json. Unix systems set mode 0600; Windows relies on the profile directory ACL. The public key is enrolled with STACK, and each API request carries a fresh 60-second JWT signed by the agent. STACK still applies that agent's service and skill restrictions, so a stolen agent key does not become an operator credential.

Set up a customer-managed agent

typescript
import { Stack } from '@getstackrun/sdk';

const operator = new Stack();
const agent = await operator.agents.register({
  name: 'runtime-agent',
  key_mode: 'customer_managed',
});

// Run this in the agent's runtime. On first use, the SDK opens the
// browser approval flow and enrolls a new local Ed25519 keypair.
const stack = new Stack({ agent_id: agent.id });

// 1. SDK reads ~/.stack/agents/agt_xxx.json -- not found.
// 2. Opens the dashboard enrollment approval flow, or uses the configured
//    operator bearer in a non-interactive environment, to call:
//      POST /v1/agents/agt_xxx/enrollment-challenge
//      ~> { challenge_id, challenge }
// 3. Generates an Ed25519 keypair locally.
// 4. Signs the challenge bytes with the new private key.
// 5. POST /v1/agents/agt_xxx/enroll
//      { public_key (JWK), challenge_id, signed_challenge }
// 6. Server verifies the signature against the submitted public_key
//    (proof-of-possession), stores the public_key on the agents row.
// 7. SDK persists the private key to disk.

await stack.passports.issue({ agent_id: 'agt_xxx', services: ['github'] });
// Every subsequent call signs a fresh 60-second JWT with the private key.

What each request looks like over the wire

text
Authorization: Bearer <jwt>

JWT header:  { alg: 'EdDSA' }
JWT claims:  {
  iss:   'stack-sdk',
  sub:   'agt_xxx',
  aud:   'stack:agent',
  iat:   1714850000,
  nbf:   1714850000,
  exp:   1714850060,
  jti:   'aj_1714850000_a3f9c2'
}
JWT signed by the agent's local private key.

One JWT = one request. Every agent JWT is single-use: the server burns its jti on first presentation, and any second presentation returns 401 with code AGENT_JWT_REPLAYED. Sign a fresh JWT for every call — including retries. Never cache a signed JWT, and never let an HTTP retry layer resend the same Authorization header. Both official SDKs do this automatically; hand-rolled clients must too. Give each request a distinct jti.

Each request is independently signed, time-bound, and audience-bound. A captured JWT can succeed only before expiry and only if it reaches STACK before the legitimate request. After STACK accepts a jti, every later presentation fails.

Replay protection

The API caches every jti it accepts in Redis with a TTL of twice the JWT max. The second presentation of the same jti returns 401 Unauthorized. There is no opt-out.

Rotating the keypair

If you suspect a compromise, rotate the keypair. POST to /v1/agents/<id>/enroll?force=true (admin role required). This:

  • Revokes every live Passport for the agent; the next STACK-verified call rejects them.
  • Overwrites the stored pubkey with the new one.
  • Audits an agent.enroll.rotate row in your chain.
bash
npx @getstackrun/cli agent rotate agt_xxx

Member-role gate

Enrolling an agent requires write access to that agent. Readonly team members cannot enroll. Standard and admin members can. Re-enrollment with ?force=true requires admin role specifically -- without that gate, an attacker who phished a standard member could overwrite the stored pubkey with their own and silently take over the agent.

Threat model

  • Process memory dump on the agent runtime: leaks one agent’s privkey only. Operator key not present.
  • Stolen, decrypted workstation: can expose local agent keys and any saved OAuth refresh token. Revoke both credentials after a compromise.
  • Cross-agent contagion: keypair is per-agent. Compromising one does not compromise siblings.
  • Token replay: first presentation wins. Redis rejects later use of the same jti.
  • Stolen enrollment challenge: bound to one agent + one operator + single-use. Cross-operator replay refused + recorded as security event.
stack | Docs