DEV Community

drewmullen
drewmullen

Posted on

Easily setup Vault for Cursor Agents

One of the easiest and most helpful features of Cursor is Cloud Agents. With often 1 click you can have an agent in an ephemeral environment that you control. However, these agents often need access to external systems. There is an embedded secrets tab but that is the same-old foothold problem all over again: a long-lived Vault token sitting in an agent environment until you remember to rotate it.

Cloud agents can mint a short OIDC JWT from a local unix socket. Vault already knows how to trust that. Wire the two together so an agent can log in, read secrets from Vault and leave no static credential behind.

This write-up assumes a personal Cursor account (no Teams plan). It also assumes a network route to Vault.

TL;DR

  1. Cursor agents can mint JWTs
  2. Vault's JWT auth method can be bound to team_id, user_id, or automation_id
  3. Agent can dynamically get a Vault token and secrets during

Here is example code

Detailed Explanation

Set VAULT_ADDR (optional:VAULT_NAMESPACE, etc) as environment variables. The agent's custom start-up script mints a JWT, exchanges it for a Vault token for use the rest of the run.

sequenceDiagram
  participant Agent as Cloud agent VM
  participant Sock as api.sock
  participant Vault as Vault
  Agent->>Sock: POST /v1/tokens/oidc {"aud":"$VAULT_AUDIENCE"}
  Sock-->>Agent: RS256 JWT (5 min TTL)
  Agent->>Vault: vault write auth/cursor/login (role from claims)
  Vault->>Vault: fetch JWKS from api.cursor.com/keys
  Vault-->>Agent: client token (15m / 30m max)
  Agent->>Vault: vault kv get cursor-kv/cursor-agent/demo

Minting is local to the VM. There is no Cursor API call from Vault and no callback. The agent hits a unix socket that only exists on cloud-agent VMs:

curl -sS --fail --unix-socket "${CURSOR_AGENT_SOCKET:-/run/cursor/api.sock}" \
  -H 'Content-Type: application/json' \
  -d "{\"aud\":\"${VAULT_AUDIENCE:-vault}\"}" \
  http://cursor-agent/v1/tokens/oidc
Enter fullscreen mode Exit fullscreen mode

Issuer is https://api.cursor.com. JWKS is https://api.cursor.com/keys. Audience is a shared string (default vault), not a secret; it must match the Vault role. JWT TTL is 5 minutes with no refresh, so mint immediately before login.

This is the same JWT-auth pattern Vault already uses for HCP Terraform dynamic credentials: oidc_discovery_url + bound_issuer + a role. Vault pulls JWKS itself. The agent only needs egress to Vault.

Cursor's identity docs are the source of truth for the socket and claims. The token identifies the run, not a process. Any process in the VM can mint. Scope Vault policies to what the whole run may see.

Vault side (Terraform)

Against a live cluster this is additive: JWT backend auth/cursor, roles cursor-agent and cursor-automation, policy cursor-agent-<repo-slug>, kv-v2 mount cursor-kv, demo secret cursor-kv/cursor-agent/demo. Do not touch existing mounts.

The backend is JWT validation only — no OIDC client, no redirect. Vault fetches Cursor's discovery document and JWKS on its own:

resource "vault_jwt_auth_backend" "this" {
  description        = "Cursor cloud-agent JWT auth (OIDC discovery; JWT validation only)"
  path               = "cursor"
  type               = "jwt"
  oidc_discovery_url = "https://api.cursor.com"
  bound_issuer       = "https://api.cursor.com"
}
Enter fullscreen mode Exit fullscreen mode

Vault role for specific cursor user (includes integrations created by a specific user)

cursor-agent binds your sub and repo_url. Audience must match the mint body. Token TTL is 15 minutes, max 30:

resource "vault_jwt_auth_backend_role" "this" {
  backend           = vault_jwt_auth_backend.this.path
  role_name         = "cursor-agent"
  role_type         = "jwt"
  bound_audiences   = ["vault"]
  bound_subject     = "user:<cursor-user-id>"
  bound_claims_type = "string"
  bound_claims = {
    repo_url = "github.com/org/repo"
  }
  user_claim = "sub"

  # Only claims present on a personal-account repo agent.
  # Do not map team_id or automation_id here — missing mapped claims fail login.
  claim_mappings = {
    cloud_agent_id = "cloud_agent_id"
    repo_url       = "repo_url"
  }

  token_ttl      = 900
  token_max_ttl  = 1800
  token_policies = ["cursor-agent-repo"]
}
Enter fullscreen mode Exit fullscreen mode

cloud_agent_id is per-run metadata. Map it for audit. Never put it in bound_claims — you'd mint a one-shot role that dies with the VM.

Vault JWT role for trusting a Cursor Automation

A second role, cursor-automation, binds automation_id + repo_url instead of sub. Same policy for now. Details in the claims section below.

Policy is read-only on the whole KV mount. The mount is the isolation boundary for now; later you can narrow paths per repo:

resource "vault_mount" "this" {
  path = "cursor-kv"
  type = "kv"
  options = { version = "2" }
}

resource "vault_policy" "this" {
  name = "cursor-agent-repo"
  policy = <<-EOT
    path "cursor-kv/data/*" {
      capabilities = ["read"]
    }
    path "cursor-kv/metadata/*" {
      capabilities = ["read", "list"]
    }
  EOT
}
Enter fullscreen mode Exit fullscreen mode

Demo secret is a non-sensitive proof value (proof = cursor-vault-jit-ok). Do not put real secrets there while this is still being proven out. Terraform state will contain whatever you write to vault_kv_secret_v2.

VAULT_ADDR and VAULT_TOKEN stay in the operator environment. On HCP Vault Dedicated, set VAULT_NAMESPACE=admin for Terraform and the agent; forgetting it looks like a 404 on auth/cursor.

Scoping Vault with JWT claims

Vault JWT roles AND every bound claim. A missing claim fails closed, including claim_mappings. That is why you do not pile every Cursor field onto one role.

Cursor fills claims from the run. The VM cannot spoof sub, repo_url, or automation_id. Pick the claim that matches the trust you actually want, and split roles when the claim is only sometimes present.

Claim On the JWT Bind it to mean
sub Always. user:<id> or service_account:<id> This Cursor user (or service account) owns the run. Personal-account allowlist. Prefer this over email.
owner_user_id When known. Same id, no user: prefix Same idea as sub, but not always present. Bind sub.
owner_email When known Don't. Email can change.
team_id When known (Teams) Anyone on that Cursor team. Absent on personal accounts — binding it 403s every login. Minting with sub_claim: team_id also fails if that claim has no value.
repo_url When known. host/path, not a URL This GitHub repo, not a fork. Combine with sub or team_id. Alone, any Cursor user who can start an agent on that repo gets in.
automation_id Only when source is AUTOMATIONS That one automation. Interactive website/API/Slack JWTs omit it, so this must be its own Vault role. Tie the automation to the same repository so repo_url is present.
source When known (WEBSITE, API, SLACK, AUTOMATIONS) Extra pin if you want "website only." Same missing-claim trap if you require it on every role.
cloud_agent_id Always. Per-run (bcId) Audit metadata. Never a bound claim.
aud Always. From the mint body Shared string (vault by default), not a secret. Must match bound_audiences.

Private automations on a personal account still have sub=user:<id>. Binding only sub lets that automation in and lets you kick off interactive agents. Binding only automation_id lets that automation in and 403s a website agent.

Use both, as two roles with the same KV policy:

  • cursor-agent: bound_subject = "user:<cursor-user-id>" + repo_url=github.com/org/repo
  • cursor-automation: automation_id=<automation-uuid> + the same repo_url

The login script reads unsigned workspace/automation-id metadata only to choose the role name. Vault still verifies the JWT. A process in the VM can skip the script and try either role; the bound claims are the control.

If the automation should see a narrower path than your interactive agents, split the policies. Same pattern: different role, different token_policies.

Trust model, short

Piece What it actually means
JWT Identifies the run. Any process on the VM can mint.
aud Shared string (default vault), not a secret. Must match the role.
bound_issuer Cursor (https://api.cursor.com).
bound_subject Your Cursor user (user:<id>) on cursor-agent.
bound_claims.repo_url This GitHub repo, not a fork. Pair with sub or team_id.
bound_claims.automation_id That one automation, on cursor-automation only.
cloud_agent_id Audit metadata. Never a bound claim.
Vault token 15m/30m True JIT. Re-login if the job outlives it.
Policy What the whole run may read, not "the login script."

If an agent can cat the socket response, it can log in. Don't try to pretend otherwise with a tighter role; tighten the KV policy instead. The login script is convenience, not a security boundary.

Also don't mint during environment.json install. Install runs on Builds, ahead of the agent. The JWT would be expired by the time you vault write auth/cursor/login. Keep mint+login in start, immediately adjacent.

Implement yourself

  1. Copy cursor-env/ contents to the root of the repo you bound as repo_url.
  2. Set VAULT_ADDR on the Cursor environment.
  3. Instruct via agent prompt to pull specific secrets
  4. Launch a cloud agent on that repo (or an automation tied to the same repo).
  5. Confirm:
vault kv get cursor-kv/cursor-agent/demo
Enter fullscreen mode Exit fullscreen mode

Conclusion

With this setup you can now guilt free setup any of your automations to pull secrets directly from Vault! An example can be found here

Top comments (0)