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
- Cursor agents can mint JWTs
- Vault's JWT auth method can be bound to
team_id,user_id, orautomation_id - Agent can dynamically get a Vault token and secrets during
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
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"
}
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"]
}
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
}
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 samerepo_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
- Copy cursor-env/ contents to the root of the repo you bound as
repo_url. - Set
VAULT_ADDRon the Cursor environment. - Instruct via agent prompt to pull specific secrets
- Launch a cloud agent on that repo (or an automation tied to the same repo).
- Confirm:
vault kv get cursor-kv/cursor-agent/demo
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)