Authenticating STACK across CLI, SDK, and AI clients

STACK never asks you to paste a long-lived API key into a config file. We use OAuth 2.1 with PKCE -- the same standard you would recognise from gh auth login, aws sso login, and any modern dev tool. The legacy sk_live_* keys still work for CI environments where there is no browser, but they are no longer the recommended path for human-driven workflows.

From your terminal (CLI)

bash
stack-cli auth login

Opens a browser, asks you to approve the device, and stores a refresh token at ~/.stack/credentials.json with mode 0600. On Linux + macOS the file is gated by the OS user account; on Windows the default ACL on %USERPROFILE%\.stack is user-scoped. OS Keychain integration (Touch ID / Windows Hello / libsecret) is on the roadmap (Phase 3).

Subsequent runs read the refresh token, exchange it for a 5-minute access token, and rotate the refresh on use. stack-cli auth status shows the current sign-in state. stack-cli auth logout revokes the refresh and clears the file.

From your AI client (Claude Code, Claude Desktop, ChatGPT, Cursor)

Add STACK as a remote MCP server. Your AI client handles the OAuth dance for you -- you click approve in your browser once, and the token never touches a config file you maintain.

bash
# Claude Code
claude mcp add stack --transport http https://mcp.getstack.run/mcp

# Claude Desktop -- one-click via Anthropic MCP Registry
# ChatGPT Plus/Pro/Business -- Settings -> Connectors -> Developer Mode
# Cursor -- ~/.cursor/config.json + the same URL

On the first call, the MCP server returns 401 Unauthorized with a WWW-Authenticate: Bearer resource_metadata=... header pointing at /.well-known/oauth-protected-resource. The MCP client picks that up, drives the user through the OAuth dance, and stores the resulting tokens in its own config. You do not see, copy, or paste a key.

From your application code (SDK)

For interactive development:

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

const stack = new Stack(); // reads ~/.stack/credentials.json
const agents = await stack.agents.list();

For agent runtimes (Phase 2 -- recommended for production):

typescript
const stack = new Stack({ agent_id: 'agt_xxx' });
// First run: generates an Ed25519 keypair locally, runs proof-of-possession
// enrollment via /v1/agents/<id>/enroll. Private key is persisted to
// ~/.stack/agents/<agent_id>.json (mode 0600).
// Subsequent runs sign every API call with a fresh 60-second JWT.

See the security/agent-keys doc for the full agent-runtime story -- why the agent should not hold your operator key, and what the per-agent keypair protects against.

For CI environments where there is no browser

Long-lived API keys are still supported via the STACK_API_KEY environment variable. We recommend rotating them at least monthly and pairing them with an IP allowlist on the API key's settings page when your CI provider supports it.

bash
STACK_API_KEY=sk_live_... node my-agent.js

Token model

  • Access tokens: 5-minute TTL. Bearer for API + MCP calls. EdDSA-signed JWT, audience-bound to stack:api or stack:mcp depending on resource indicator.
  • Refresh tokens: 30-day TTL, rotated on every use. Stored as sha256 hash on the server -- the raw value lives only on your machine.
  • Reuse detection: presenting an already-replaced refresh token revokes the entire token family AND records a critical security event in your audit log. The OAuth 2.1 BCP behavior, no opt-out.

Why this matters

An API key in your .env file or your CI secrets store is a credential anyone with access can copy. A refresh token in your OS keychain is gated by your physical device. The blast radius is much smaller, and the audit trail of who used what is much richer.

What this prevents

  • Keys committed to git (refresh tokens are never in source).
  • Keys leaked via CI build logs (CI keeps a different, narrower path).
  • Session hijack via stolen MCP config files (the OAuth tokens never touch the MCP config you maintain).
  • Long-lived blast radius (5-minute access tokens limit how long a leaked Authorization header is useful).

OAuth is the recommended path; legacy sk_live_* keys remain valid indefinitely. There is no migration deadline. We will keep both alive until at least Phase 3 (passkey-rooted bootstrap) ships.

stack | docs