By Sagar Shankaran, Founder of CallSphere
Implement secure authentication for AI agent APIs using API keys for simple access, OAuth2 for delegated authorization, and JWT tokens for stateless verification. Learn token lifecycle management, scope-based permissions, and security best practices.
Key takeaways
AI agent APIs face unique authentication challenges. Agents run unattended, so they cannot participate in interactive login flows. They often need different permission levels — a research agent should not have the same access as an admin agent. Agents may also delegate work to sub-agents, creating chains of authorization that need proper scoping.
The three primary authentication patterns — API keys, OAuth2 client credentials, and JWT tokens — each solve different parts of this puzzle. Most production agent systems combine two or three of them depending on the context.
API keys are the simplest starting point. They work well for server-to-server agent communication where both sides are trusted internal services:
flowchart LR
INPUT(["User intent"])
PARSE["Parse plus<br/>classify"]
PLAN["Plan and tool<br/>selection"]
AGENT["Agent loop<br/>LLM plus tools"]
GUARD{"Guardrails<br/>and policy"}
EXEC["Execute and<br/>verify result"]
OBS[("Trace and metrics")]
OUT(["Outcome plus<br/>next action"])
INPUT --> PARSE --> PLAN --> AGENT --> GUARD
GUARD -->|Pass| EXEC --> OUT
GUARD -->|Fail| AGENT
AGENT --> OBS
style AGENT fill:#4f46e5,stroke:#4338ca,color:#fff
style GUARD fill:#f59e0b,stroke:#d97706,color:#1f2937
style OBS fill:#ede9fe,stroke:#7c3aed,color:#1e1b4b
style OUT fill:#059669,stroke:#047857,color:#fff
from fastapi import FastAPI, Security, HTTPException
from fastapi.security import APIKeyHeader
import hashlib
import secrets
app = FastAPI()
api_key_header = APIKeyHeader(name="X-API-Key")
# In production, store hashed keys in a database
API_KEYS = {
# key_hash -> {agent_id, scopes, rate_limit_tier}
}
def hash_key(key: str) -> str:
return hashlib.sha256(key.encode()).hexdigest()
def generate_api_key(agent_id: str, scopes: list[str]) -> str:
key = f"sk-agent-{secrets.token_urlsafe(32)}"
API_KEYS[hash_key(key)] = {
"agent_id": agent_id,
"scopes": scopes,
"active": True,
}
return key # Only shown once at creation time
async def verify_api_key(key: str = Security(api_key_header)) -> dict:
key_hash = hash_key(key)
record = API_KEYS.get(key_hash)
if not record or not record["active"]:
raise HTTPException(status_code=401, detail="Invalid API key")
return record
@app.get("/v1/conversations")
async def list_conversations(auth: dict = Security(verify_api_key)):
if "conversations:read" not in auth["scopes"]:
raise HTTPException(status_code=403, detail="Insufficient scope")
return {"conversations": []}
Key design decisions: always hash keys before storage so a database leak does not expose credentials, prefix keys with a recognizable pattern like sk-agent- for easy identification in logs, and attach scopes to each key for fine-grained access control.
Hear it before you finish reading
Talk to a live CallSphere AI voice agent in your browser — 60 seconds, no signup.
When agents from different organizations or trust boundaries need to communicate, OAuth2 client credentials provide a standardized flow. The agent exchanges a client ID and secret for a short-lived access token:
from datetime import datetime, timedelta
import jwt
TOKEN_SECRET = "your-signing-secret" # Use a vault in production
TOKEN_EXPIRY = timedelta(minutes=30)
class OAuth2ClientStore:
clients = {
"agent-billing-v2": {
"secret_hash": hash_key("billing-secret-abc123"),
"scopes": ["billing:read", "billing:write"],
"agent_name": "Billing Agent",
},
}
client_store = OAuth2ClientStore()
@app.post("/oauth/token")
async def issue_token(client_id: str, client_secret: str, scope: str = ""):
client = client_store.clients.get(client_id)
if not client:
raise HTTPException(status_code=401, detail="Unknown client")
if hash_key(client_secret) != client["secret_hash"]:
raise HTTPException(status_code=401, detail="Invalid credentials")
requested_scopes = scope.split() if scope else client["scopes"]
# Only grant scopes the client is authorized for
granted = [s for s in requested_scopes if s in client["scopes"]]
token = jwt.encode(
{
"sub": client_id,
"scopes": granted,
"exp": datetime.utcnow() + TOKEN_EXPIRY,
"iat": datetime.utcnow(),
"type": "access_token",
},
TOKEN_SECRET,
algorithm="HS256",
)
return {
"access_token": token,
"token_type": "bearer",
"expires_in": int(TOKEN_EXPIRY.total_seconds()),
"scope": " ".join(granted),
}
The agent requests a token once, caches it, and includes it in subsequent API calls. When the token expires, the agent requests a new one. This avoids sending the client secret with every request.
Once tokens are issued, every endpoint needs to verify them. Build a reusable dependency:
from fastapi import Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
bearer_scheme = HTTPBearer()
async def get_current_agent(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
) -> dict:
token = credentials.credentials
try:
payload = jwt.decode(token, TOKEN_SECRET, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=401,
detail="Token expired",
headers={"WWW-Authenticate": "Bearer"},
)
except jwt.InvalidTokenError:
raise HTTPException(
status_code=401,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
return payload
def require_scope(required: str):
async def checker(agent: dict = Depends(get_current_agent)):
if required not in agent.get("scopes", []):
raise HTTPException(
status_code=403,
detail=f"Missing required scope: {required}",
)
return agent
return checker
@app.get("/v1/billing/balance")
async def get_balance(agent: dict = Depends(require_scope("billing:read"))):
return {"balance": 150.00, "currency": "USD"}
Agents need to handle token refresh without interrupting their work:
Still reading? Stop comparing — try CallSphere live.
CallSphere ships complete AI voice agents per industry — 14 tools for healthcare, 10 agents for real estate, 4 specialists for salons. See how it actually handles a call before you book a demo.
import httpx
import asyncio
from datetime import datetime
class AgentTokenManager:
def __init__(self, token_url: str, client_id: str, client_secret: str):
self.token_url = token_url
self.client_id = client_id
self.client_secret = client_secret
self.access_token: str | None = None
self.expires_at: datetime | None = None
self._lock = asyncio.Lock()
async def get_token(self) -> str:
async with self._lock:
if self.access_token and self.expires_at:
# Refresh 60 seconds before expiry
if datetime.utcnow() < self.expires_at - timedelta(seconds=60):
return self.access_token
async with httpx.AsyncClient() as client:
response = await client.post(self.token_url, data={
"client_id": self.client_id,
"client_secret": self.client_secret,
})
data = response.json()
self.access_token = data["access_token"]
self.expires_at = datetime.utcnow() + timedelta(
seconds=data["expires_in"]
)
return self.access_token
# Usage in an agent
token_mgr = AgentTokenManager(
token_url="https://auth.agents.internal/oauth/token",
client_id="agent-research-v1",
client_secret="research-secret-xyz",
)
async def call_billing_api():
token = await token_mgr.get_token()
async with httpx.AsyncClient() as client:
response = await client.get(
"https://billing-agent.internal/v1/billing/balance",
headers={"Authorization": f"Bearer {token}"},
)
return response.json()
The AgentTokenManager uses a lock to prevent multiple concurrent token refreshes and proactively refreshes 60 seconds before expiry to avoid any window where the token is invalid.
Use API keys for internal agent-to-agent communication within a single trust boundary where simplicity matters. Use OAuth2 client credentials when agents cross trust boundaries, when you need short-lived tokens, or when you want centralized token management with a dedicated auth server.
For API keys, mark the key as inactive in your database — revocation is immediate on the next request. For JWTs, you cannot truly revoke them before expiry since they are stateless. Mitigate this by keeping token lifetimes short (15-30 minutes) and maintaining a deny-list of revoked token IDs checked during verification.
Each agent should have its own credentials. Shared service accounts make it impossible to audit which agent performed an action, enforce per-agent rate limits, or revoke access to a single agent without affecting others. The small overhead of managing individual credentials pays for itself in observability and security.
#Authentication #AIAgents #OAuth2 #JWT #APISecurity #AgenticAI #LearnAI #AIEngineering

Written by
Sagar Shankaran· Founder, CallSphere
LinkedInSagar Shankaran is the founder of CallSphere, where he builds production AI voice and chat agents deployed across healthcare, hospitality, real estate, and home services. He writes about agentic AI, LLM engineering, and shipping voice agents that handle real calls in production.
See how AI voice agents work for your industry. Live demo available -- no signup required.
A founder's guide to the personal AI assistant market: best AI assistant apps, business-grade options, and how CallSphere's voice agent fits in.
A founder's guide to free AI agents, low-code AI agent builders, and how to know when you should pay for a real platform like CallSphere.
Graphiti is the open-source temporal knowledge graph for AI agents in 2026. Learn how bi-temporal memory beats vector RAG for voice agents and long-running LLMs.
Chatbot app vs ChatGPT in 2026: a founder's clear take on the difference, when to use which, and how a real AI chatbot app development works.
How we built a fault-tolerant HVAC emergency triage and tech-dispatch platform on Kubernetes — three-tier CQRS, 11 micro-agents on the OpenAI Agents SDK + LangGraph, NATS JetStream, DTMF/SMS/WebSocket acceptance, circuit breakers, and an evaluation pipeline that catches regressions before they wake a tech at 3 AM.
Head-to-head: OpenAI Frontier and Anthropic's managed agent stack — strengths, fit, and what each means for enterprise AI voice and chat deployment.
© 2026 CallSphere Inc. All rights reserved.
Made within San Francisco