Insecure Inter-Agent Communication: When Agents Talk, Attackers Listen (ASI07)

This is post #7 of the OWASP Agentic AI Top 10: What Builders on AWS Need to Know series.

You’ve built a multi-agent system. I’ve built multi-agent systems. Agent A handles customer requests. Agent B processes payments. Agent C manages inventory. They talk to each other over internal APIs, passing context, delegating tasks, sharing results.

Now ask yourself: when Agent A tells Agent B “process a refund for $500 to account X,” how does Agent B know that instruction actually came from Agent A? And how does it know Agent A wasn’t compromised 30 seconds ago?

If your answer involves the words “we trust internal traffic,” you have a problem.

Welcome to Insecure Inter-Agent Communication.

What Is Insecure Inter-Agent Communication?

ASI07 covers threats where the messages flowing between agents lack proper authentication, integrity verification, or semantic validation. Attackers can intercept, spoof, tamper with, or replay these messages to manipulate agent behavior across an entire system.

Think about it like microservices. Ten years ago, people learned the hard way that internal service-to-service traffic needs the same rigor as external traffic. Zero trust. mTLS. Schema validation. The whole thing.

Multi-agent AI systems are making the same mistake all over again. But worse. Because agent-to-agent messages aren’t just API calls with typed schemas. They’re natural language. Untyped. Ambiguous. And the receiving agent can’t reliably distinguish a legitimate instruction from an injected one.

The Attack Surface Is Bigger Than You Think

OWASP identifies six vulnerability patterns for inter-agent communication:

  1. Unencrypted channels enabling semantic manipulation - MITM intercepts messages and injects hidden instructions that alter agent goals
  2. Message tampering causing cross-context contamination - Modified messages blur task boundaries, leaking data between agents
  3. Replay attacks on trust chains - Old delegation messages get replayed to trick agents into granting stale access
  4. Protocol downgrade and descriptor forgery - Agents coerced into weaker communication modes
  5. Discovery and routing attacks - Forged discovery traffic routes sensitive requests through attacker-controlled agents
  6. Metadata profiling - Traffic patterns reveal decision cycles, enabling prediction and manipulation

Real Attacks Happening Right Now

This isn’t theoretical. Researchers are actively exploiting these patterns.

Agent-in-the-Middle via A2A Agent Cards

In May 2026, Trustwave’s SpiderLabs demonstrated the “Agent-in-the-Middle” attack on Google’s A2A protocol. A malicious agent advertises exaggerated capabilities in its agent card (/.well-known/agent.json). The host agent picks it for tasks based on those fake capabilities. Sensitive requests get routed through the attacker.

The beauty (from the attacker’s perspective): the A2A protocol trusts agent cards by default. There’s no built-in verification that the capabilities advertised are real.

Agent Session Smuggling

Palo Alto’s Unit 42 discovered “agent session smuggling” where a malicious agent exploits an established cross-agent communication session to send covert instructions to a victim agent. Once a session is established, identity claims are taken at face value. A compromised peer can inject anything.

Agent Card Poisoning (Metadata Injection)

Researchers demonstrated Agent Card Poisoning where adversarial instructions are embedded directly within an agent card’s metadata. When a host agent reads the card to understand capabilities, the injected instructions influence its behavior. Prompt injection, but at the protocol discovery layer.

MCP Descriptor Poisoning

The pattern repeats with MCP. A malicious MCP endpoint advertises spoofed agent descriptors or false capabilities. When trusted, it routes sensitive data through attacker infrastructure. Multiple CVEs in 2025 documented this exact pattern.

Why Traditional Network Security Doesn’t Help

You might be thinking: “I’ll just put my agents in a VPC and encrypt the traffic.” That helps with transport security. But it doesn’t solve the core problem.

The messages themselves are the attack vector. Even over encrypted, authenticated channels, a compromised agent can send perfectly valid-looking messages that manipulate the receiving agent. The message is well-formed. The credentials are valid. The content is malicious.

Agent-to-agent security needs to happen at the semantic layer, not just the transport layer. You need to validate what is being said, not just who is saying it.

Mitigating Inter-Agent Communication Risks on AWS

Here’s how to build secure agent-to-agent communication on AWS. The principle: validate structure, authenticate identity, verify intent, and contain blast radius.

1. EventBridge Schema Registry for Message Validation

Amazon EventBridge Schema Registry lets you define and document the contract for inter-agent messages. But here is the part people miss, and it is the whole ballgame: the registry documents the contract. Your code enforces it.

EventBridge does not validate events against your registered schemas. Let me say that again, because it trips up almost everyone. It will happily deliver a malformed, injection-laden event straight to your target without so much as a raised eyebrow. The registry gives you a shared definition and code bindings. The enforcement is on you, at both ends.

So let me show you how I wire this up.

Step 1: Define the contract in the Schema Registry.

import boto3
import json
from datetime import datetime, timezone
from jsonschema import validate, FormatChecker, ValidationError

eventbridge = boto3.client("events")
schemas = boto3.client("schemas")

# Define the contract for inter-agent messages
delegation_schema = {
    "openapi": "3.0.0",
    "info": {"title": "AgentDelegation", "version": "1.0.0"},
    "paths": {},
    "components": {
        "schemas": {
            "DelegationMessage": {
                "type": "object",
                "required": ["source_agent", "target_agent", "task_type", "parameters", "nonce", "timestamp"],
                "properties": {
                    "source_agent": {"type": "string", "pattern": "^agent-[a-z]+-[0-9]+$"},
                    "target_agent": {"type": "string", "pattern": "^agent-[a-z]+-[0-9]+$"},
                    "task_type": {"type": "string", "enum": ["query", "process", "notify"]},
                    "parameters": {
                        "type": "object",
                        "properties": {
                            "query": {"type": "string", "maxLength": 500},
                            "resource_id": {"type": "string", "pattern": "^[a-zA-Z0-9-]+$"}
                        },
                        "additionalProperties": False  # No free-form injection fields
                    },
                    "nonce": {"type": "string", "minLength": 32},
                    "timestamp": {"type": "string", "format": "date-time"}
                },
                "additionalProperties": False
            }
        }
    }
}

# Register the schema (documentation + code generation).
# Note: this is a one-time bootstrap. create_registry and create_schema both
# throw if the thing already exists, so do not run this on every invocation.
schemas.create_registry(RegistryName="agent-communications")
schemas.create_schema(
    RegistryName="agent-communications",
    SchemaName="AgentDelegation",
    Type="OpenApi3",
    Content=json.dumps(delegation_schema)
)

Step 2: Validate at the producer, before anything hits the bus.

# Extract the JSON Schema portion for validation
message_schema = delegation_schema["components"]["schemas"]["DelegationMessage"]

def publish_agent_message(message: dict):
    """Validate against the schema before putting anything on the bus."""
    try:
        validate(instance=message, schema=message_schema, format_checker=FormatChecker())
    except ValidationError as e:
        raise ValueError(f"Message rejected at producer: {e.message}")

    eventbridge.put_events(Entries=[{
        "Source": f"agent.{message['source_agent']}",
        "DetailType": "AgentDelegation",
        "Detail": json.dumps(message),
        "EventBusName": "agent-bus"
    }])

Step 3: Validate again at the consumer, before you act on a single field.

And here is where most examples fall over. The consumer is where the real discipline lives, because you have to assume the producer is buggy, compromised, or flat out lying to you.

def handle_agent_message(event: dict):
    """Consumer validates independently. Never trust that the producer did its job."""
    # EventBridge delivers `detail` to a Lambda target already parsed into a dict.
    # Do NOT json.loads() it. (If you consume via SQS or Kinesis instead, parse the
    # record body, and pull `detail` out of that.)
    message = event["detail"]

    # Schema validation. Pass a FormatChecker so `date-time` is actually asserted.
    # That check needs the extras: pip install "jsonschema[format]"
    try:
        validate(instance=message, schema=message_schema, format_checker=FormatChecker())
    except ValidationError as e:
        print(f"[REJECTED] Invalid message from {message.get('source_agent', 'unknown')}: {e.message}")
        return  # Drop it. Don't act on it.

    # Anti-replay: check the nonce has not been used before
    if nonce_store.exists(message["nonce"]):
        print(f"[REJECTED] Replay detected: {message['nonce']}")
        return
    nonce_store.add(message["nonce"], ttl=300)

    # Freshness: parse defensively, and compare timezone-aware to timezone-aware
    try:
        msg_time = datetime.fromisoformat(message["timestamp"].replace("Z", "+00:00"))
    except ValueError:
        print(f"[REJECTED] Unparseable timestamp: {message['timestamp']}")
        return
    if msg_time.tzinfo is None:
        msg_time = msg_time.replace(tzinfo=timezone.utc)
    if (datetime.now(timezone.utc) - msg_time).total_seconds() > 60:
        print(f"[REJECTED] Stale message: {message['timestamp']}")
        return

    # Only now do we trust it enough to act
    process_delegation(message)

A few things in that consumer are load-bearing, so let me call them out.

  • Do not json.loads the detail. When EventBridge invokes a Lambda target, event[“detail”] is already a parsed dict. Calling json.loads on it throws TypeError and your consumer dies on the very first real message. You only parse a string when you are pulling the event out of an SQS or Kinesis record body.
  • Timestamps are timezone-aware, so treat them that way. A proper RFC 3339 date-time carries an offset or a trailing Z. Parse it into an aware datetime and compare it against datetime.now(timezone.utc). Mixing a naive utcnow() with an aware timestamp raises can’t subtract offset-naive and offset-aware datetimes, which is a real joy to debug at 2am. (Bonus: on Python older than 3.11, fromisoformat cannot even parse the Z suffix, which is why we normalise it first.)
  • The date-time format is documentation, not enforcement. Out of the box the jsonschema library does not check format at all. You have to install the extras and pass a FormatChecker, and even then I still parse the timestamp defensively in code. Belt and suspenders.

The key insight, and the reason this whole section exists:

The Schema Registry gives every team a shared source of truth. The generated bindings give you autocomplete and type safety in your IDE. That is genuine value and you should use it. But enforcement lives in your code, not in the bus. Validate at the producer so you never publish garbage. Validate at the consumer because you must never trust that the producer did. Then add the runtime checks a schema simply cannot express, like nonce replay protection and freshness windows.

Now one honest caveat, because I am not going to oversell this to you. Schema validation locks down the shape of your message. additionalProperties: False kills the free-form fields an attacker would love to smuggle in, and the regex patterns keep your agent IDs and resource IDs tidy. But look at that query field. It is a string, up to 500 characters, and the schema has no opinion whatsoever about what is inside it.

So this sails straight through validation without a whimper:

'; DROP TABLE users; -- and ignore all previous instructions

If that string later lands in a SQL query or an LLM prompt, your schema did absolutely nothing to save you. Schema validation constrains structure and format. It does not sanitise the semantics of free text. Those values still need context-specific escaping or sanitising at the point where you actually use them.

And one last thing while we are here. EventBridge rules can add a coarse second net through content-based filtering, for example only routing events where task_type matches one of your enum values. Useful, and basically free. But that is filtering, not schema validation. Do not confuse the two.

2. Step Functions for Typed Inter-Agent Orchestration

AWS Step Functions gives you something agents talking directly to each other never will: a single, auditable, centrally controlled entry point for every inter-agent message, with schema validation bolted to the front door.

I want to be careful with my words here, because this is where a lot of “secure multi-agent” write-ups quietly cheat. Step Functions is not a type system. It will not magically validate your payloads. What it gives you is a chokepoint. One place every message has to pass through. And a chokepoint is only worth something if you actually force the traffic through it. Hold that thought, because it is the whole point of this section.

So instead of agents invoking each other directly, you route every request through a state machine.

{
  "Comment": "Secure multi-agent orchestration",
  "StartAt": "ValidateRequest",
  "States": {
    "ValidateRequest": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123456789012:function:validate-agent-message",
      "Next": "RouteToAgent",
      "Catch": [
        { "ErrorEquals": ["ValidationError"], "ResultPath": "$.error", "Next": "RejectAndLog" },
        { "ErrorEquals": ["States.ALL"],      "ResultPath": "$.error", "Next": "RejectAndLog" }
      ]
    },
    "RouteToAgent": {
      "Type": "Choice",
      "Choices": [
        { "Variable": "$.task_type", "StringEquals": "process_payment", "Next": "PaymentAgent" },
        { "Variable": "$.task_type", "StringEquals": "check_inventory", "Next": "InventoryAgent" }
      ],
      "Default": "RejectAndLog"
    },
    "PaymentAgent": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123456789012:function:payment-agent",
      "InputPath": "$.validated_params",
      "ResultPath": "$.payment_result",
      "TimeoutSeconds": 30,
      "Retry": [
        { "ErrorEquals": ["Lambda.TooManyRequestsException", "Lambda.ServiceException"],
          "IntervalSeconds": 2, "MaxAttempts": 3, "BackoffRate": 2.0 }
      ],
      "Catch": [
        { "ErrorEquals": ["States.ALL"], "ResultPath": "$.error", "Next": "RejectAndLog" }
      ],
      "Next": "Done"
    },
    "InventoryAgent": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123456789012:function:inventory-agent",
      "InputPath": "$.validated_params",
      "ResultPath": "$.inventory_result",
      "TimeoutSeconds": 30,
      "Retry": [
        { "ErrorEquals": ["Lambda.TooManyRequestsException", "Lambda.ServiceException"],
          "IntervalSeconds": 2, "MaxAttempts": 3, "BackoffRate": 2.0 }
      ],
      "Catch": [
        { "ErrorEquals": ["States.ALL"], "ResultPath": "$.error", "Next": "RejectAndLog" }
      ],
      "Next": "Done"
    },
    "RejectAndLog": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123456789012:function:log-rejected-message",
      "ResultPath": "$.log_result",
      "Next": "Rejected"
    },
    "Rejected": {
      "Type": "Fail",
      "Error": "MessageNotProcessed",
      "Cause": "Message failed validation, routing, or agent execution and was never forwarded."
    },
    "Done": {"Type": "Succeed"}
  }
}

Let me walk through what that buys you.

- One front door. Every message enters at ValidateRequest. Nothing reaches an agent until it has been validated and explicitly routed. There is no side entrance.
- Least-privilege payloads. InputPath: $.validated_params means the payment agent receives only the parameters it needs. It never sees the full envelope, the routing metadata, or the
nonce. If that Lambda gets popped, it has less to work with.
- Fail closed, and fail loud. Validation errors, unknown task_type values, and dead agents all funnel to RejectAndLog and then to a Fail state. This matters. In the naive version a
rejected message ends in Succeed, which means a malicious payload produces a green, successful execution and your CloudWatch alarm on failed executions never fires. Do not do that. A
rejection is a failure. Make it look like one.
- Retries for the honest failures. Agents throw transient errors. Lambda.TooManyRequestsException is not an attack, it is a Tuesday. The Retry block backs off and tries again before
giving up.
- A full audit trail. Every transition, with input and output, lands in the execution history. When something goes sideways at 3am you have a complete, replayable record of exactly what
happened. (Execution history is kept for 90 days, so if you need audit that outlives that, turn on CloudWatch Logs or ship it to S3. And remember the message contents sit in that
history, so mind what you log.)

Now about those timeouts, because the word "timeout" lies to you a little.

TimeoutSeconds: 30 does not kill your Lambda. Let me say that again, because I got this wrong myself the first time. When the timer fires, Step Functions stops waiting and marks the
task failed. The Lambda underneath keeps right on running, burning money and holding connections, until it finishes or hits its own function timeout. So set the Lambda's timeout too.
The state machine timeout protects the orchestrator. The function timeout protects your wallet and your downstream.

And here it comes. The part that actually makes this secure.

A state machine that routes through an orchestrator does not stop an agent from picking up the phone and calling another agent directly.

Read that again. The JSON above is necessary. It is not sufficient. If your payment agent's execution role has lambda:InvokeFunction on the inventory agent, then the moment someone
compromises the payment agent your beautiful orchestrator becomes optional. The attacker just calls the next agent directly and skips validation, routing, and logging. All of it.

The chokepoint is only a chokepoint if IAM makes it the only road.

So two rules, and they are not optional.

Rule 1: Only the orchestrator may invoke the agents. The state machine's execution role gets lambda:InvokeFunction, scoped to exactly these functions and nothing else.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "InvokeOnlyTheseFunctions",
      "Effect": "Allow",
      "Action": "lambda:InvokeFunction",
      "Resource": [
        "arn:aws:lambda:us-east-1:123456789012:function:validate-agent-message",
        "arn:aws:lambda:us-east-1:123456789012:function:payment-agent",
        "arn:aws:lambda:us-east-1:123456789012:function:inventory-agent",
        "arn:aws:lambda:us-east-1:123456789012:function:log-rejected-message"
      ]
    }
  ]
}

Rule 2: The agents may not invoke each other. At all. Look closely at a typical agent role. Notice what is not there.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PaymentAgentDataAccessOnly",
      "Effect": "Allow",
      "Action": ["dynamodb:GetItem", "dynamodb:PutItem"],
      "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/payments"
    }
  ]
}

No lambda:InvokeFunction. Anywhere. The payment agent can touch its payments table and that is the entire list. It has no ability, none, to call the inventory agent or any other
function. If it needs something done elsewhere, it returns a result to the orchestrator and the orchestrator decides what happens next. That is what "no direct agent-to-agent
communication" actually means, and you will notice it lives in IAM, not in the state machine.

Want to go one step further? Put a resource-based policy on each agent function that grants invoke permission only to the orchestrator role, then use an SCP to deny
lambda:InvokeFunction on those ARNs for every other principal in the account. Now even an over-privileged admin role cannot accidentally wire two agents together. Belt, suspenders, and
a second belt.

The state machine gives you the shape. IAM gives you the guarantee. Most people ship only the first one and call it secure.

### 3. SQS Message Signing for Integrity

If your agents communicate via queues, [SQS](https://aws.amazon.com/sqs/?trk=d76afd77-bb62-46ac-b0a3-9dbf5ecde253) with message attributes and HMAC signing prevents tampering:

```python
import hashlib
import hmac
import json
import time
import uuid
import boto3

sqs = boto3.client("sqs")
secrets = boto3.client("secretsmanager")

# Pull the shared signing key from Secrets Manager, never from source control.
SECRET_KEY = secrets.get_secret_value(SecretId="agent-hmac-key")["SecretString"].encode()

MAX_AGE_SECONDS = 60  # freshness window; bounds the replay surface


class IntegrityError(Exception):
    """Raised when a message fails signature, freshness, or replay checks."""


class InMemoryNonceStore:
    """Example only. In production use a SHARED store (DynamoDB with a TTL
    attribute, or Redis). An in-memory dict does NOT stop replays across
    concurrent Lambda instances, because each instance has its own copy."""
    def __init__(self):
        self._seen = {}

    def exists(self, nonce: str) -> bool:
        now = time.time()
        self._seen = {n: exp for n, exp in self._seen.items() if exp > now}
        return nonce in self._seen

    def add(self, nonce: str, ttl: int):
        self._seen[nonce] = time.time() + ttl


def _signing_bytes(source_agent: str, nonce: str, timestamp: str, body: str) -> bytes:
    """Canonical, unambiguous representation of everything we sign. This covers
    the body AND the security-relevant attributes, so none of them can be
    tampered with independently of the signature."""
    return json.dumps(
        {"source_agent": source_agent, "nonce": nonce, "timestamp": timestamp, "body": body},
        sort_keys=True,
        separators=(",", ":"),
    ).encode()


def send_signed_message(queue_url: str, message: dict, source_agent: str):
    """Sign the body together with the attributes, then send."""
    body = json.dumps(message, sort_keys=True, separators=(",", ":"))
    nonce = str(uuid.uuid4())
    timestamp = str(int(time.time()))
    signature = hmac.new(
        SECRET_KEY, _signing_bytes(source_agent, nonce, timestamp, body), hashlib.sha256
    ).hexdigest()

    sqs.send_message(
        QueueUrl=queue_url,
        MessageBody=body,
        MessageAttributes={
            "source_agent": {"DataType": "String", "StringValue": source_agent},
            "signature":    {"DataType": "String", "StringValue": signature},
            "nonce":        {"DataType": "String", "StringValue": nonce},
            "timestamp":    {"DataType": "String", "StringValue": timestamp},
        },
    )


def _attr(attrs: dict, name: str) -> str:
    """Fetch a required string attribute, or fail closed."""
    try:
        return attrs[name]["StringValue"]
    except (KeyError, TypeError):
        raise IntegrityError(f"missing required attribute: {name}")


def verify_message(msg: dict, nonce_store) -> dict:
    """Verify a single message. Returns the parsed body or raises IntegrityError.
    `nonce_store` must expose .exists(nonce) and .add(nonce, ttl)."""
    body = msg["Body"]
    attrs = msg.get("MessageAttributes") or {}

    source_agent = _attr(attrs, "source_agent")
    signature    = _attr(attrs, "signature")
    nonce        = _attr(attrs, "nonce")
    timestamp    = _attr(attrs, "timestamp")

    # 1) Integrity + binding: recompute over body AND attributes, constant-time compare.
    expected = hmac.new(
        SECRET_KEY, _signing_bytes(source_agent, nonce, timestamp, body), hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(signature, expected):
        raise IntegrityError(f"signature check failed (claimed source: {source_agent})")

    # 2) Freshness: reject stale or future-dated messages (allow a little clock skew).
    try:
        age = int(time.time()) - int(timestamp)
    except ValueError:
        raise IntegrityError("unparseable timestamp")
    if age > MAX_AGE_SECONDS or age < -MAX_AGE_SECONDS:
        raise IntegrityError(f"stale/future-dated message (age {age}s)")

    # 3) Anti-replay: each nonce is accepted exactly once.
    if nonce_store.exists(nonce):
        raise IntegrityError(f"replay detected (nonce {nonce})")
    nonce_store.add(nonce, ttl=MAX_AGE_SECONDS * 2)

    return json.loads(body)


def receive_and_process(queue_url: str, nonce_store, handler):
    """Long-poll, verify each message, process + delete on success. On failure
    we alert and leave the message for SQS to redrive to a dead-letter queue
    (configure a redrive policy with maxReceiveCount on the source queue)."""
    response = sqs.receive_message(
        QueueUrl=queue_url,
        MessageAttributeNames=["All"],
        MaxNumberOfMessages=10,
        WaitTimeSeconds=20,
    )
    for msg in response.get("Messages", []):
        try:
            payload = verify_message(msg, nonce_store)
        except IntegrityError as e:
            print(f"[REJECTED] {e}")   # emit a CloudWatch metric / alarm here
            continue                    # do NOT delete -> redrive policy sends it to the DLQ
        handler(payload)
        sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=msg["ReceiptHandle"])

HMAC uses a shared symmetric key, so this authenticates the group, not the individual agent. Signing source_agent stops an attacker who can write to the queue but does not hold the key. It does not stop a compromised agent that does hold the shared key from forging a message as any other agent. If your threat model includes a compromised agent (and this whole post says it does), you need per-agent keys or asymmetric signatures — each agent signs with its own private key, verifiers hold the public keys, and now source_agent is genuinely provable. Don’t let “prevents tampering” imply “proves who sent it,” because with one shared key it doesn’t.

4. API Gateway with Mutual TLS for Agent-to-Agent Calls

For agents that communicate via HTTP APIs, API Gateway with mutual TLS ensures both sides prove their identity:

  • Each agent gets its own client certificate
  • API Gateway validates the cert before forwarding the request
  • You know exactly which agent sent which message
  • No certificate = no communication. Period.

Combined with request validation (OpenAPI schema enforcement at the gateway), you get both identity verification AND structural validation before the message reaches the target agent.

5. VPC Security Groups for Network-Level Isolation

Defense in depth means layering. VPC security groups restrict which agents can even attempt to communicate:

  • Payment agent only accepts inbound from the orchestrator
  • Inventory agent only accepts inbound from the orchestrator
  • No agent can talk directly to any other agent
  • All traffic must flow through the validated path

If an agent gets compromised, it can’t laterally move to other agents because the network itself prevents it.

If your agents communicate across VPCs or accounts, AWS PrivateLink ensures that traffic never traverses the public internet. Each agent exposes its API as a VPC endpoint service. Other agents connect via interface endpoints in their own VPC.

Why this matters for inter-agent security:

  • Traffic stays on the AWS backbone, no internet exposure
  • You control exactly which VPCs (and therefore which agents) can connect
  • Combined with security groups, you get network-level allowlisting of agent-to-agent communication paths
  • An attacker who compromises one agent can’t reach other agents unless the PrivateLink connection explicitly exists

Think of it as the network equivalent of “deny by default” - agents can’t even see each other unless you’ve explicitly wired up the endpoint.

7. Amazon Verified Permissions: Semantic Authorization Between Agents

Network-level controls tell you which agents can connect. Amazon Verified Permissions (Cedar) tells you what they’re allowed to ask for once connected.

// Agent A (customer-service) can ask Agent B (payments) to look up orders
// but NOT to issue refunds
permit(
    principal == Agent::"customer-service",
    action == Action::"invoke",
    resource == Tool::"payments/lookup-order"
);

// Only the refund-approver agent can invoke the refund tool
permit(
    principal == Agent::"refund-approver",
    action == Action::"invoke",
    resource == Tool::"payments/process-refund"
);

This stops the confused deputy problem cold. Even if Agent A gets compromised and tries to tell Agent B “process a refund,” the Cedar policy rejects it because Agent A doesn’t have invoke permission on payments/process-refund. The authorization check happens independently of whatever the message says.

The Architecture: Secure Multi-Agent Communication

Layer these together:

  1. Network - VPC security groups + PrivateLink restrict who can talk to whom
  2. Transport - Mutual TLS proves identity on every connection
  3. Structure - EventBridge schema registry / API Gateway request validation rejects malformed messages
  4. Integrity - HMAC signing detects tampering
  5. Authorization - Verified Permissions (Cedar) controls what agents can ask each other to do
  6. Orchestration - Step Functions controls the flow, filters inputs, enforces timeouts
  7. Audit - Every message logged in CloudWatch/CloudTrail

An attacker needs to bypass all seven layers to successfully inject a malicious inter-agent message. That’s the point.

Key Takeaway

Don’t let your agents talk to each other unsupervised. Route inter-agent communication through a validated, schema-enforced, auditable orchestration layer. Validate structure, verify identity, sign for integrity, and restrict the network paths. The same zero-trust principles that saved microservices will save multi-agent systems.

Up Next

Post 8: Cascading Failures (ASI08) - One hallucination, ten downstream agents acting on it. How Step Functions error handling, circuit breakers, and CloudWatch composite alarms keep a single agent failure from taking down your entire system.

I would be very interested to hear your thoughts or comments, so please feel free to ping me on LinkedIn or Twitter. If you’re building multi-agent systems and have solved the communication security problem differently, I genuinely want to hear about it.

Onward!!