Skip to main content
AI Agent Secrets: Inject Credentials Without Leaking

AI Agent Secrets: Inject Credentials Without Leaking

AI Integration
••5 min read•By Daily Miranda Pardo

Your agent just decided to charge a customer €500. To execute it, it needs the Stripe API key.

Where exactly is that key in your architecture?

If the answer is "in the system prompt," "in the tool_result," or "as a parameter the LLM fills in," you have an active leak. Not a potential vulnerability: a leak that's happening right now in every conversation, every trace, every error log Stripe sends back when something fails.

Why Agents Leak Credentials Differently Than Classic Software

In a normal application, API keys live in environment variables. The code reads them at startup, uses them internally, and they never leave the system. The problem with agents is a new intermediary: the LLM and its conversation context.

Any string that passes through that context—system prompt, messages, tool_use blocks, tool_result blocks—can:

  • Be logged in your observability platform (Langfuse, Helicone, Langsmith)
  • Surface in your cloud provider's error logs
  • Be echoed by the model in a response if the prompt isn't tightly controlled
  • Leak to the client in a stream you don't filter correctly

Semantic failures don't throw exceptions. An API key in the LLM context doesn't raise an error—it's just there, exposed.

The Three Anti-Patterns We See in Production Audits

1. The Key in the System Prompt

// ❌ NEVER do this
const systemPrompt = `
  You are a payment assistant.
  To process charges use the Stripe API key: ${process.env.STRIPE_KEY}
  Call the charge_card tool with amount and currency.
`

The key enters the conversation history with the very first message. From that point it travels in every LLM request and every log you record.

2. The Key as a Tool Parameter

// ❌ Travels through LLM context as a tool_result
const tools = [{
  name: "charge_card",
  input_schema: {
    properties: {
      amount: { type: "number" },
      apiKey: { type: "string" } // ❌ the LLM decides what to put here
    }
  }
}]

The LLM decides parameter values. If you tell it an apiKey field exists, it will try to populate it. If you don't, it may hallucinate one. Either way the result lands as a tool_use block in the history.

3. The Key Inside tool_result

// ❌ The result goes back to LLM context
const result = await stripe.charges.create({
  amount,
  currency,
  source: token,
}, { apiKey: process.env.STRIPE_KEY })

// If you include response headers in tool_result, the key may be there
return JSON.stringify(result) // can contain auth metadata

The Right Pattern: Post-LLM Credential Injection

The rule is simple: the LLM decides what to do and with what business parameters, but never touches credentials. Credentials are injected in the tool execution layer, after the LLM has made its decision.

type ToolCall = {
  name: string
  input: Record<string, unknown>
}

async function executeToolCall(toolCall: ToolCall): Promise<unknown> {
  switch (toolCall.name) {
    case 'charge_card': {
      // Key loaded HERE, AFTER the LLM's decision
      const stripeKey = process.env.STRIPE_SECRET_KEY
      if (!stripeKey) throw new Error('Stripe not configured')

      const stripe = new Stripe(stripeKey)
      const result = await stripe.paymentIntents.create({
        amount: toolCall.input.amount as number,
        currency: toolCall.input.currency as string,
      })

      // Return only what the LLM needs to know — no auth metadata
      return { id: result.id, status: result.status, amount: result.amount }
    }
    // ... other tools
  }
}

The LLM never sees STRIPE_SECRET_KEY. It only sees the clean operation result.

Masking in Traces: The Layer Almost Nobody Implements

Even with the right pattern, external API error messages can contain authentication information. Stripe, for example, includes the request ID and partial context in its errors.

Before sending any message to your observability platform, apply a mask:

const SENSITIVE_PATTERNS = [
  /sk_live_[a-zA-Z0-9]{24,}/g,
  /sk_test_[a-zA-Z0-9]{24,}/g,
  /Bearer [a-zA-Z0-9+/=_-]{20,}/g,
  /(?:api[_-]?key|apikey|api[_-]?token)["\s]*[:=]["\s]*([a-zA-Z0-9_-]{16,})/gi,
]

function maskSecrets(text: string): string {
  return SENSITIVE_PATTERNS.reduce(
    (masked, pattern) => masked.replace(pattern, '[REDACTED]'),
    text
  )
}

// Apply before logging any message
function sanitizeMessages(messages: Message[]): Message[] {
  return messages.map((m) => ({
    ...m,
    content: typeof m.content === 'string'
      ? maskSecrets(m.content)
      : m.content,
  }))
}

In our secure database access implementation, we apply this same sanitization pattern before any structured log.

Per-Tenant Credentials in Multi-Client Systems

If you're building an agent that multiple clients use with their own integrations—each with their own Stripe key, their own CRM, their own Twilio account—you need a per-tenant credential resolution layer:

interface CredentialStore {
  get(tenantId: string, service: string): Promise<string>
}

class EnvCredentialStore implements CredentialStore {
  async get(tenantId: string, service: string): Promise<string> {
    // In production: AWS Secrets Manager, Doppler, Vault
    // In dev: env variables prefixed by tenant
    const key = `${tenantId.toUpperCase()}_${service.toUpperCase()}_KEY`
    const value = process.env[key]
    if (!value) throw new Error(`Credential not found: ${key}`)
    return value
  }
}

// Tool executor receives tenantId per request
async function createToolExecutor(tenantId: string, store: CredentialStore) {
  return {
    async execute(toolCall: ToolCall) {
      const credential = await store.get(tenantId, toolCall.name)
      return runWithCredential(toolCall, credential)
    }
  }
}

This pattern complements per-client context isolation directly: credentials and context are two separate attack surfaces, both need their own protection layer.

What We Find When We Audit Production Systems

80% of agents that reach DAILYMP for an audit have at least one of these three situations:

  1. Keys in system prompts because it was the fastest way to prototype and was never revisited
  2. No masking in traces, with Langfuse or Datadog recording the full context without filtering
  3. Unsanitized tool results returning the complete API response object (with headers and metadata) to the LLM context

None of these failures cause a production error. The agent works. Tests pass. The key is just sitting there, in hundreds of traces, waiting for someone to look.

Getting It Right From the Start

The pattern is clear: the LLM makes business decisions, the execution layer handles credentials. Never the other way around.

If you already have an agent in production and want to audit where your credentials are, message me on WhatsApp and we'll review the stack together.

Talk about securing my agent →

Share article

Repetitive processes in your business?

Download the free AI Automation Map — the 5 most time-consuming processes and how to fix them.

No spam. Just the PDF. Unsubscribe anytime.

Written by Daily Miranda Pardo

I help businesses automate processes, build AI agents and connect intelligent systems.