Two agents, one limit, one refused.
All posts
6 min read
by

How to let an AI agent call the Stripe API without giving it your key

Let your AI agent issue Stripe refunds without holding the secret key. The agent names an action. Anlyon resolves the credential and makes the call.

agentscredentialssecurityactionstutorial

Short answer: do not put the key anywhere the agent can reach. Define the Stripe call once, outside the agent, with the key held in a vault and referenced by name. The agent asks for the call by name and passes input. Something that is not the agent resolves the key and sends the request. That is what Anlyon does: your agent declares what it wants to do, and Anlyon executes it against production.

The rest of this post is why that shape matters, and the code to build it.

The default setup, and why it fails

Most agents that "use Stripe" look like this:

agent -> tool function in your process -> STRIPE_SECRET_KEY -> api.stripe.com

The tool function holds sk_live_... in an environment variable. The model decides when to call the tool and with what arguments. Everything between a model's decision and a real refund is code you wrote, running with a credential that can do anything that key can do.

Three things go wrong with that, and none of them need a clever attacker:

  1. Prompt injection reaches the key's full authority. The model reads a support email, a web page or a PDF. Somewhere in it is a sentence telling it to refund a different charge, or to call a tool it was not meant to. The model cannot reliably tell instructions from data, so the only safe assumption is that anything it reads can steer what it does. A system prompt that says "never refund more than $100" is a request, not a control.
  2. The key is findable. If the agent can read files or run shell commands, the key is one env or cat .env away. That is how the PocketOS agent found a token with more authority than its task needed, and used it.
  3. Nothing outside your process can refuse the call. By the time anything could look at the request, it has left.

OWASP names this failure directly. The OWASP Top 10 for Agentic Applications, published in December 2025, lists ASI03, Identity and Privilege Abuse: an agent using credentials, tokens or inherited permissions beyond what it was meant to have. A secret key in the agent's process is the textbook case.

The alternative: the agent asks, something else acts

agent -> "refund-order" + input -> Anlyon -> api.stripe.com

The agent's half of the boundary contains an action name and some JSON. No URL, no header, no key. The key lives in a vault the agent cannot read, and it is put on the wire by the system making the request.

Here is the whole thing with Anlyon.

1. Put the key in the vault (operator key, once)

import { Client } from '@anlyonhq/sdk';

const ops = new Client({ apiKey: process.env.ANLYON_OPERATOR_KEY! });

await ops.secrets.put('STRIPE_KEY', { value: process.env.STRIPE_KEY! });

The vault is write-only. There is no endpoint that returns a secret's value, on any surface, for any key. secrets.get() returns metadata and when it was last used, never the value.

2. Define the call once

await ops.actions.create({
  name: 'refund-order',
  description: 'Refund a Stripe charge, in full or in part.',
  method: 'POST',
  urlTemplate: 'https://api.stripe.com/v1/refunds',
  headers: { Authorization: 'Bearer {{secret:STRIPE_KEY}}', 'Content-Type': 'application/x-www-form-urlencoded' },
  inputSchema: {
    type: 'object',
    properties: {
      charge: { type: 'string', pattern: '^ch_' },
      amount: { type: 'integer' },
    },
    required: ['charge', 'amount'],
  },
  requiresApproval: true,
});

Three properties do the work:

  • {{secret:STRIPE_KEY}} is a reference. Anlyon decrypts it at dispatch, inside the request it is about to send. No API returns its value, and it is not in the model's context. That covers a credential in the vault, not a key your own process still holds.
  • The destination is fixed. An action that references a secret must have a literal scheme and host. The agent cannot interpolate input into the host, so it cannot choose who receives your key.
  • The input is validated. A charge id that does not start with ch_, or an amount that is not an integer, is refused before anything is sent.

3. Give the agent a key that can only ask

Create the agent's key with actions:invoke, nothing else. It can invoke refund-order. It cannot edit the action, so it cannot move where the credential goes, and it cannot read the credential.

const anlyon = new Client({ apiKey: process.env.ANLYON_AGENT_KEY! });

const { data } = await anlyon.actions.invoke('refund-order', {
  charge: 'ch_3P9x',
  amount: 12000,
});

If you expose this to a model as a tool, the tool's implementation is that one line. Or skip the wrapper: the Anlyon MCP server gives Claude, Cursor and other MCP clients an invoke_action tool directly.

4. Decide which refunds need a person

The action above has requiresApproval: true, so every invocation parks and returns 202 until someone approves it in the console. On approval, Anlyon sends the request snapshot the approver reviewed, using the credential bound to that action. The snapshot does not preserve a credential that has since been revoked, and destination bindings are checked again at dispatch.

In practice you want most refunds to go straight through and only the big ones to wait. That is a policy, created from your deploy code with an operator credential:

await ops.approvalPolicies.create({
  name: 'small-refunds-auto',
  matchKind: 'action',
  matchActionName: 'refund-order',
  effect: 'auto_approve',
  priority: 20,
});

await ops.approvalPolicies.create({
  name: 'large-refunds-need-two',
  matchKind: 'action',
  matchActionName: 'refund-order',
  minAmount: 50000, // matches when the input's amount is 50000 or more
  effect: 'require_approval',
  requiredApprovals: 2,
  priority: 10,
});

Policies are evaluated on every invocation, lowest priority number first, and the first match wins. A refund of 50,000 or more (in cents, $500) needs two approvers. Anything smaller is approved automatically and recorded as a policy decision. N counts distinct credentials for API-key deciders and distinct users for dashboard deciders, so require dashboard decisions when you mean two people.

What an injected agent can and cannot do now

A prompt-injected agent can still ask for a refund. That request is now schema-checked, policy-evaluated, held for a human when the rules say so, and recorded against the agent that made it. What the agent cannot do is read the key, send it anywhere else, or call an endpoint the action does not define.

Two things make that hold in practice:

  • Take the key out of the agent's process. Once the Stripe key lives only in the vault, there is no second path to Stripe for the agent to find. Remove STRIPE_SECRET_KEY from the agent's environment as part of the move.
  • Set the approval threshold deliberately. Schema and policy define what an acceptable request looks like. The approval threshold decides which of those a person looks at before money moves.

What to do this week

  1. List every secret your agent's process can read. Not the ones it uses: the ones it can read.
  2. For each one that performs a write (refunds, emails, deploys, deletes), define the write as an action and move the secret into the vault.
  3. Remove the secret from the agent's environment. This is the step that makes the rest true.
  4. Put a human in front of the writes you cannot undo.

Free during Early Beta Access, no credit card. A Stripe test key lets you try it without moving money.

Start building → · Actions in the docs → · Secrets in the docs →

Free tier, no credit card. One command if you use Claude or Cursor.

$ claude mcp add anlyon -- npx -y @anlyonhq/mcp-server