Security, Authentication and Enterprise-Grade A2A (Part 6 of 8)

Security, Authentication and Enterprise-Grade A2A (Part 6 of 8)

Parts 3 through 5 built a fully functional multi-agent system. The implementations used a simple static API token for auth, which is fine for local development but nowhere near ready for enterprise production. This part hardens everything: JWT verification with proper claim validation, OAuth2 client credentials for machine-to-machine auth, mutual TLS between agents, Agent Card signing for tamper-proof discovery, and RBAC at the skill level.

Every implementation in this post is in Node.js and drops directly into the server and orchestrator from Parts 3 and 5. The same patterns apply to the Python and C# implementations from Part 4 using their respective JWT and TLS libraries.

The Security Model for A2A Systems

Before writing any code it helps to have a clear mental model of what needs securing and why. An A2A system has three distinct security surfaces:

flowchart TD
    subgraph Surface1["Surface 1 - Discovery"]
        AC[Agent Card\n/.well-known/agent.json]
        AS[Agent Card Signing\nTamper detection]
        AC --> AS
    end

    subgraph Surface2["Surface 2 - Transport"]
        TLS[TLS 1.3\nEncryption in transit]
        mTLS[Mutual TLS\nBidirectional identity]
        TLS --> mTLS
    end

    subgraph Surface3["Surface 3 - Authorization"]
        JWT[JWT Verification\nIdentity + claims]
        RBAC[RBAC\nSkill-level access control]
        OA[OAuth2 Client Credentials\nMachine-to-machine tokens]
        JWT --> RBAC
        OA --> JWT
    end

    Surface1 --> Surface2
    Surface2 --> Surface3

The A2A specification is explicit that security is handled at the HTTP transport layer, not inside the JSON-RPC message body. This is the right design: it means standard enterprise tooling like API gateways, service meshes, and identity providers all work with A2A without protocol-level changes.

JWT Verification Middleware

Replace the static token check from Part 3 with a proper JWT middleware. Install the dependencies first:

npm install jsonwebtoken jwks-rsa

The middleware fetches the public key from your identity provider’s JWKS endpoint, verifies the token signature, and validates the standard claims (expiry, issuer, audience). It also extracts the agent’s identity and granted scopes and attaches them to the request for downstream RBAC checks:

// src/middleware/jwtAuth.js

import jwt from "jsonwebtoken";
import jwksClient from "jwks-rsa";

// Initialize the JWKS client once at startup
// For Azure AD: https://login.microsoftonline.com/{tenantId}/discovery/v2.0/keys
// For Auth0:    https://{domain}/.well-known/jwks.json
// For Keycloak: https://{host}/realms/{realm}/protocol/openid-connect/certs
const jwks = jwksClient({
  jwksUri: process.env.JWKS_URI || "https://your-idp.example.com/.well-known/jwks.json",
  cache: true,
  cacheMaxAge: 10 * 60 * 1000, // 10 minutes
  rateLimit: true,
});

function getSigningKey(header, callback) {
  jwks.getSigningKey(header.kid, (err, key) => {
    if (err) return callback(err);
    callback(null, key.getPublicKey());
  });
}

export function jwtAuth(req, res, next) {
  // Agent Card is always public - skip auth
  if (req.path === "/.well-known/agent.json" || req.path === "/health") {
    return next();
  }

  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith("Bearer ")) {
    return res.status(401).json(rpcError("Unauthorized: Bearer token required"));
  }

  const token = authHeader.slice(7);

  jwt.verify(
    token,
    getSigningKey,
    {
      algorithms: ["RS256", "ES256"],
      issuer: process.env.JWT_ISSUER,       // e.g. "https://your-idp.example.com"
      audience: process.env.JWT_AUDIENCE,   // e.g. "https://inventory-agent.example.com"
    },
    (err, decoded) => {
      if (err) {
        const message =
          err.name === "TokenExpiredError"
            ? "Token expired"
            : err.name === "JsonWebTokenError"
            ? "Invalid token"
            : "Token verification failed";

        return res.status(401).json(rpcError(message));
      }

      // Attach verified identity to the request for RBAC
      req.agentIdentity = {
        sub: decoded.sub,           // agent or service identity
        clientId: decoded.client_id || decoded.azp,
        scopes: parseScopes(decoded.scope || decoded.scp),
        roles: decoded.roles || [],
        issuer: decoded.iss,
      };

      next();
    }
  );
}

function parseScopes(scope) {
  if (!scope) return [];
  if (Array.isArray(scope)) return scope;
  return scope.split(" ");
}

function rpcError(message) {
  return { jsonrpc: "2.0", id: null, error: { code: -32001, message } };
}

RBAC at the Skill Level

Not every client agent should be able to invoke every skill on a remote agent. A read-only monitoring agent should be able to check stock levels but not trigger purchase orders. RBAC enforces this by mapping required scopes to each skill and checking the verified token claims before the task handler runs:

// src/middleware/rbac.js

// Defines which OAuth2 scope is required to invoke each skill
const SKILL_SCOPES = {
  "check-stock":      "inventory:read",
  "trigger-reorder":  "inventory:write",
  "approve-po":       "procurement:approve",
  "view-suppliers":   "suppliers:read",
};

// Maps skill IDs to roles that can bypass scope checks (for internal agents)
const SKILL_ROLES = {
  "trigger-reorder":  ["procurement-agent", "admin"],
  "approve-po":       ["admin"],
};

export function skillRbac(req, res, next) {
  // RBAC only applies to task submission methods
  const body = req.body;
  if (!body?.method?.startsWith("tasks/send")) return next();

  // Determine which skill is being invoked from the message text
  const message = body.params?.message;
  const skillId = detectSkill(message);

  if (!skillId) return next(); // Cannot determine skill - let the handler decide

  const identity = req.agentIdentity;
  if (!identity) {
    return res.status(401).json(rpcError(null, -32001, "No identity on request"));
  }

  const requiredScope = SKILL_SCOPES[skillId];
  const allowedRoles  = SKILL_ROLES[skillId] || [];

  // Pass if the agent has the required scope OR an allowed role
  const hasScope = requiredScope && identity.scopes.includes(requiredScope);
  const hasRole  = allowedRoles.some(r => identity.roles.includes(r));

  if (!hasScope && !hasRole) {
    console.warn(
      `[RBAC] Access denied: ${identity.sub} tried skill "${skillId}" ` +
      `(needs scope "${requiredScope}", has [${identity.scopes.join(", ")}])`
    );
    return res.status(403).json(
      rpcError(body.id, -32001,
        `Forbidden: scope "${requiredScope}" required for skill "${skillId}"`)
    );
  }

  console.log(`[RBAC] Access granted: ${identity.sub} -> skill "${skillId}"`);
  req.detectedSkill = skillId;
  next();
}

function detectSkill(message) {
  if (!message?.parts) return null;
  const text = message.parts
    .filter(p => p.type === "text")
    .map(p => p.text?.toLowerCase() || "")
    .join(" ");

  if (text.includes("reorder") || text.includes("purchase order")) return "trigger-reorder";
  if (text.includes("approve") && text.includes("po"))              return "approve-po";
  if (text.includes("supplier"))                                    return "view-suppliers";
  if (text.includes("stock") || text.includes("sku"))               return "check-stock";
  return null;
}

function rpcError(id, code, message) {
  return { jsonrpc: "2.0", id, error: { code, message } };
}

OAuth2 Client Credentials in the Orchestrator

The orchestrator needs to obtain tokens programmatically using the OAuth2 client credentials flow. This is the standard machine-to-machine pattern: the orchestrator authenticates as a service principal and receives a short-lived access token it presents to each remote agent.

// src/tokenManager.js

import fetch from "node-fetch";

export class TokenManager {
  constructor(config) {
    this._config = config;
    // Map of scope string -> { token, expiresAt }
    this._cache = new Map();
  }

  /**
   * Get a valid token for the given scope, refreshing if needed.
   * Uses the OAuth2 client credentials flow.
   */
  async getToken(scope) {
    const cached = this._cache.get(scope);
    // Refresh 60 seconds before expiry to avoid edge cases
    if (cached && Date.now() < cached.expiresAt - 60_000) {
      return cached.token;
    }

    const token = await this._fetchToken(scope);
    return token;
  }

  async _fetchToken(scope) {
    const { tokenUrl, clientId, clientSecret, audience } = this._config;

    const body = new URLSearchParams({
      grant_type:    "client_credentials",
      client_id:     clientId,
      client_secret: clientSecret,
      scope,
      ...(audience ? { audience } : {}),
    });

    const response = await fetch(tokenUrl, {
      method:  "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body,
    });

    if (!response.ok) {
      const text = await response.text();
      throw new Error(`Token fetch failed (${response.status}): ${text}`);
    }

    const data = await response.json();

    const expiresAt = Date.now() + (data.expires_in || 3600) * 1000;
    this._cache.set(scope, { token: data.access_token, expiresAt });

    console.log(`[TokenManager] Fetched token for scope "${scope}", expires in ${data.expires_in}s`);
    return data.access_token;
  }
}

// Factory for common identity providers
export function createTokenManager(provider = "entra") {
  const configs = {
    // Azure Entra ID (formerly Azure AD)
    entra: {
      tokenUrl:     `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}/oauth2/v2.0/token`,
      clientId:     process.env.AZURE_CLIENT_ID,
      clientSecret: process.env.AZURE_CLIENT_SECRET,
      audience:     process.env.AZURE_AUDIENCE,
    },
    // Auth0
    auth0: {
      tokenUrl:     `https://${process.env.AUTH0_DOMAIN}/oauth/token`,
      clientId:     process.env.AUTH0_CLIENT_ID,
      clientSecret: process.env.AUTH0_CLIENT_SECRET,
      audience:     process.env.AUTH0_AUDIENCE,
    },
    // Keycloak
    keycloak: {
      tokenUrl:     `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/token`,
      clientId:     process.env.KEYCLOAK_CLIENT_ID,
      clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
    },
  };

  const config = configs[provider];
  if (!config?.clientId) {
    throw new Error(`TokenManager: missing config for provider "${provider}"`);
  }

  return new TokenManager(config);
}

Wire the TokenManager into the A2A client so every outgoing request carries a valid token:

// Update src/a2aClient.js constructor

export class A2AClient {
  constructor(tokenManager, scopeMap = {}) {
    // tokenManager: TokenManager instance
    // scopeMap: maps agentUrl -> required OAuth2 scope
    // e.g. { "http://inventory:3000": "inventory:read inventory:write" }
    this._tokenManager = tokenManager;
    this._scopeMap = scopeMap;
  }

  async _headers(agentUrl) {
    const scope = this._scopeMap[agentUrl] || process.env.DEFAULT_SCOPE || "";
    const token = await this._tokenManager.getToken(scope);
    return {
      "Content-Type":  "application/json",
      "Authorization": `Bearer ${token}`,
    };
  }

  async sendTask(agentUrl, taskId, message, pushNotification = null) {
    const params = { id: taskId, message };
    if (pushNotification) params.pushNotification = pushNotification;

    const response = await fetch(agentUrl, {
      method:  "POST",
      headers: await this._headers(agentUrl),
      body:    this._rpcBody("tasks/send", params),
    });

    const data = await response.json();
    if (data.error) throw new Error(`A2A error ${data.error.code}: ${data.error.message}`);
    return data.result;
  }

  // ... rest of methods use await this._headers(agentUrl) in the same way
}

Agent Card Signing

Agent Cards are served over HTTPS, but in a zero-trust environment you also want to verify that the card has not been tampered with by a man-in-the-middle or a compromised CDN. A2A v0.3 introduced signed Agent Cards for exactly this purpose.

The signing approach uses a detached JWS (JSON Web Signature). The agent signs a canonical JSON representation of its own card and includes the signature in the card itself. The client verifies the signature using the agent's public key, which it obtains from a trusted key registry:

// src/agentCardSigner.js

import crypto from "crypto";
import { createSign, createVerify } from "crypto";

/**
 * Sign an Agent Card using RS256.
 * In production, load the private key from Azure Key Vault, AWS KMS, or HashiCorp Vault.
 */
export function signAgentCard(card, privateKeyPem, keyId) {
  // Canonical JSON: sorted keys, no whitespace
  const canonicalCard = JSON.stringify(sortObjectKeys(card));
  const payload = Buffer.from(canonicalCard).toString("base64url");

  const header = Buffer.from(
    JSON.stringify({ alg: "RS256", kid: keyId, typ: "JWT" })
  ).toString("base64url");

  const signingInput = `${header}.${payload}`;
  const signer = createSign("RSA-SHA256");
  signer.update(signingInput);
  const signature = signer.sign(privateKeyPem, "base64url");

  return {
    ...card,
    signature: {
      alg:       "RS256",
      kid:       keyId,
      protected: header,
      signature,
    },
  };
}

/**
 * Verify a signed Agent Card.
 * keyResolver: async (kid) => publicKeyPem
 */
export async function verifyAgentCard(signedCard, keyResolver) {
  const { signature: sig, ...card } = signedCard;
  if (!sig) throw new Error("Agent Card is not signed");

  const publicKeyPem = await keyResolver(sig.kid);
  if (!publicKeyPem) throw new Error(`No public key found for kid: ${sig.kid}`);

  const canonicalCard = JSON.stringify(sortObjectKeys(card));
  const payload = Buffer.from(canonicalCard).toString("base64url");
  const signingInput = `${sig.protected}.${payload}`;

  const verifier = createVerify("RSA-SHA256");
  verifier.update(signingInput);
  const valid = verifier.verify(publicKeyPem, sig.signature, "base64url");

  if (!valid) throw new Error("Agent Card signature verification failed");
  return card; // Return the verified card without the signature block
}

function sortObjectKeys(obj) {
  if (typeof obj !== "object" || obj === null) return obj;
  if (Array.isArray(obj)) return obj.map(sortObjectKeys);
  return Object.keys(obj)
    .sort()
    .reduce((acc, k) => ({ ...acc, [k]: sortObjectKeys(obj[k]) }), {});
}

Add card verification to the AgentRegistry in the orchestrator:

// Update src/agentRegistry.js fetchCard method

async fetchCard(baseUrl, verify = true) {
  const url = baseUrl.replace(/\/$/, "");
  // ... existing cache check ...

  const response = await fetch(`${url}/.well-known/agent.json`);
  const card = await response.json();

  if (verify && card.signature) {
    try {
      await verifyAgentCard(card, this._keyResolver);
      console.log(`[Registry] Card signature verified for: ${card.name}`);
    } catch (err) {
      throw new Error(`Agent Card signature invalid for ${url}: ${err.message}`);
    }
  } else if (verify && !card.signature) {
    console.warn(`[Registry] WARNING: Agent Card from ${url} is unsigned`);
  }

  this._cache.set(url, { card, fetchedAt: Date.now() });
  return card;
}

Mutual TLS Between Agents

In high-security enterprise environments, TLS alone authenticates the server to the client but not the client to the server. Mutual TLS (mTLS) requires both sides to present certificates, giving you cryptographic identity verification at the transport layer before any HTTP request is processed.

Here is how to configure an A2A server in Node.js to require client certificates:

// server-mtls.js

import https from "https";
import fs from "fs";
import express from "express";
import wellKnownRouter from "./src/routes/wellKnown.js";
import tasksRouter from "./src/routes/tasks.js";
import { jwtAuth } from "./src/middleware/jwtAuth.js";
import { skillRbac } from "./src/middleware/rbac.js";

const app = express();
app.use(express.json());

// Agent Card - no client cert required (public endpoint)
app.use(wellKnownRouter);

// Task endpoints - mTLS + JWT + RBAC
app.use("/a2a", jwtAuth, skillRbac, tasksRouter);

const tlsOptions = {
  // Server certificate and key
  cert: fs.readFileSync(process.env.TLS_CERT_PATH || "./certs/server.crt"),
  key:  fs.readFileSync(process.env.TLS_KEY_PATH  || "./certs/server.key"),

  // CA that signed the client certificates
  ca: fs.readFileSync(process.env.TLS_CA_PATH || "./certs/ca.crt"),

  // require: reject connections without a valid client cert
  requestCert: true,
  rejectUnauthorized: true,
};

const server = https.createServer(tlsOptions, app);

server.on("secureConnection", (tlsSocket) => {
  const cert = tlsSocket.getPeerCertificate();
  if (cert?.subject) {
    console.log(`[mTLS] Client connected: CN=${cert.subject.CN}`);
  }
});

server.listen(3443, () => {
  console.log("A2A Inventory Agent (mTLS) running on port 3443");
});

The orchestrator side needs to present its own certificate when connecting:

// src/a2aClientMtls.js - extend A2AClient for mTLS

import https from "https";
import fs from "fs";
import fetch from "node-fetch";

export function createMtlsAgent() {
  return new https.Agent({
    // Orchestrator's client certificate
    cert: fs.readFileSync(process.env.CLIENT_CERT_PATH || "./certs/orchestrator.crt"),
    key:  fs.readFileSync(process.env.CLIENT_KEY_PATH  || "./certs/orchestrator.key"),
    // CA that signed the server certificates
    ca:   fs.readFileSync(process.env.TLS_CA_PATH      || "./certs/ca.crt"),
    rejectUnauthorized: true,
  });
}

// Pass the agent into fetch calls
const mtlsAgent = createMtlsAgent();

// In A2AClient._sendRequest:
const response = await fetch(agentUrl, {
  method:  "POST",
  headers: await this._headers(agentUrl),
  body:    this._rpcBody(method, params),
  agent:   mtlsAgent,  // <-- attach mTLS agent
});

Audit Logging

Enterprise security requires a full audit trail. Every task submission, state transition, and access denial needs a structured log entry that can be shipped to a SIEM or log aggregator. This middleware runs after auth and RBAC and writes a structured log for every request:

// src/middleware/auditLog.js

export function auditLog(req, res, next) {
  const startTime = Date.now();

  // Capture the original json method to intercept the response
  const originalJson = res.json.bind(res);
  let responseBody = null;

  res.json = (body) => {
    responseBody = body;
    return originalJson(body);
  };

  res.on("finish", () => {
    const identity = req.agentIdentity;
    const body = req.body;

    const entry = {
      timestamp:    new Date().toISOString(),
      traceId:      req.headers["x-trace-id"] || req.headers["x-request-id"] || "none",
      method:       body?.method,
      taskId:       body?.params?.id,
      agentId:      identity?.sub || "anonymous",
      clientId:     identity?.clientId,
      scopes:       identity?.scopes,
      detectedSkill: req.detectedSkill,
      httpStatus:   res.statusCode,
      rpcError:     responseBody?.error?.code,
      durationMs:   Date.now() - startTime,
      ip:           req.ip,
      userAgent:    req.headers["user-agent"],
    };

    // In production: ship to your log aggregator (Azure Monitor, Datadog, ELK, etc.)
    console.log(`[AUDIT] ${JSON.stringify(entry)}`);
  });

  next();
}

Complete Middleware Stack

Here is how the complete security middleware stack wires together in the Express server:

// server.js - production security stack

import express from "express";
import helmet from "helmet"; // npm install helmet
import rateLimit from "express-rate-limit"; // npm install express-rate-limit
import wellKnownRouter from "./src/routes/wellKnown.js";
import tasksRouter from "./src/routes/tasks.js";
import { jwtAuth } from "./src/middleware/jwtAuth.js";
import { skillRbac } from "./src/middleware/rbac.js";
import { auditLog } from "./src/middleware/auditLog.js";

const app = express();

// Security headers
app.use(helmet());
app.use(express.json({ limit: "1mb" }));

// Rate limiting - prevent flooding
const taskLimiter = rateLimit({
  windowMs: 60 * 1000,   // 1 minute
  max:      100,          // 100 task requests per minute per IP
  message:  { jsonrpc: "2.0", id: null, error: { code: -32001, message: "Rate limit exceeded" } },
  standardHeaders: true,
  legacyHeaders:   false,
});

// Agent Card - public, no auth
app.use(wellKnownRouter);
app.get("/health", (req, res) => res.json({ status: "ok" }));

// A2A task endpoint - full security stack
app.use(
  "/a2a",
  taskLimiter,    // 1. Rate limiting
  auditLog,       // 2. Audit logging (before auth so denials are logged too)
  jwtAuth,        // 3. JWT verification + identity extraction
  skillRbac,      // 4. RBAC - skill-level access control
  tasksRouter     // 5. Business logic
);

app.listen(3000, () => console.log("A2A server running on port 3000"));

Security Flow End to End

sequenceDiagram
    participant OA as Orchestrator Agent
    participant IDP as Identity Provider
    participant RA as Remote Agent (A2A Server)

    OA->>IDP: POST /token (client_credentials, scope=inventory:read)
    IDP-->>OA: access_token (JWT, 1h TTL)

    OA->>RA: GET /.well-known/agent.json
    RA-->>OA: Signed Agent Card
    OA->>OA: Verify card signature (trusted key registry)

    OA->>RA: POST /a2a (tasks/send)\nAuthorization: Bearer {JWT}\nmTLS client cert presented

    RA->>RA: 1. Rate limit check
    RA->>RA: 2. Audit log entry
    RA->>IDP: GET /jwks (fetch public key, cached)
    RA->>RA: 3. Verify JWT signature + claims
    RA->>RA: 4. Extract identity + scopes
    RA->>RA: 5. RBAC - check scope for detected skill

    alt authorized
        RA-->>OA: Task accepted + SSE stream
    else unauthorized
        RA-->>OA: 403 Forbidden + audit log
    end

Environment Variables Reference

Here is the complete set of environment variables the production security stack requires:

# .env.production

# JWT Verification (on the A2A server)
JWKS_URI=https://login.microsoftonline.com/{tenantId}/discovery/v2.0/keys
JWT_ISSUER=https://login.microsoftonline.com/{tenantId}/v2.0
JWT_AUDIENCE=https://inventory-agent.example.com

# OAuth2 Client Credentials (on the orchestrator)
AZURE_TENANT_ID=your-tenant-id
AZURE_CLIENT_ID=your-orchestrator-client-id
AZURE_CLIENT_SECRET=your-orchestrator-client-secret
AZURE_AUDIENCE=https://inventory-agent.example.com

# mTLS (optional, for high-security environments)
TLS_CERT_PATH=./certs/server.crt
TLS_KEY_PATH=./certs/server.key
TLS_CA_PATH=./certs/ca.crt
CLIENT_CERT_PATH=./certs/orchestrator.crt
CLIENT_KEY_PATH=./certs/orchestrator.key

# Agent Card Signing
SIGNING_KEY_PATH=./keys/agent-signing.key
SIGNING_KEY_ID=inventory-agent-key-v1

Security Checklist for Production

Before deploying an A2A agent to production, run through this list:

  • TLS 1.3 enforced on all A2A endpoints (no HTTP fallback)
  • JWT verification using JWKS endpoint, not static secrets
  • Token expiry validated (reject tokens with exp in the past)
  • Issuer and audience claims validated (not just signature)
  • RBAC configured per skill with minimum required scopes
  • Agent Card signed and signature verified during discovery
  • Rate limiting on the task endpoint
  • Audit logging on every request including access denials
  • Health check endpoint does not expose sensitive system info
  • Agent Card endpoint is public but task endpoints require auth
  • Private keys stored in a secrets manager (Key Vault, Secrets Manager, HashiCorp Vault), never in environment files in production
  • JWKS public key cache has a reasonable TTL (5 to 15 minutes)

What is Next

The system is now functionally complete and security-hardened. Part 7 brings MCP and A2A together into a single unified agentic stack. You will see how MCP handles the vertical layer (agent to tools and data sources) while A2A handles the horizontal layer (agent to agent), and how a real enterprise workflow uses both protocols simultaneously. This is where the full architecture picture comes together.

References

Written by:

582 Posts

View All Posts
Follow Me :
How to whitelist website on AdBlocker?

How to whitelist website on AdBlocker?

  1. 1 Click on the AdBlock Plus icon on the top right corner of your browser
  2. 2 Click on "Enabled on this site" from the AdBlock Plus option
  3. 3 Refresh the page and start browsing the site