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.
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.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.
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.
If you suspect a compromise, rotate the keypair. POST to /v1/agents/<id>/enroll?force=true (admin role required). This:
npx @getstackrun/cli agent rotate agt_xxxEnrolling 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.